diff --git a/README.md b/README.md
index ef7a0ee..f8d330b 100644
--- a/README.md
+++ b/README.md
@@ -124,9 +124,24 @@ typstCaller := typst.Docker{
err := typstCaller.Compile(input, output, &typst.OptionsCompile{FontPaths: []string{"/fonts"}})
```
+### Named Docker containers
+
+If you have an already running Docker container that you want to (re)use, you can use `typst.DockerExec` to invoke the Typst executable inside any running container by its name:
+
+```go
+typstCaller := typst.DockerExec{
+ ContainerName: "typst",
+}
+
+err := typstCaller.Compile(input, output, options)
+```
+
+This method has a lower latency than using `typst.Docker`, as it doesn't need to spin up a Docker container every call.
+But you need to manage the lifetime of the Container yourself, or use a Docker orchestrator.
+
## Caller interface
-`typst.CLI` and `typst.Docker` both implement the `typst.Caller` interface.
+`typst.CLI`, `typst.Docker` and `typst.DockerExec` implement the `typst.Caller` interface.
## Examples
diff --git a/docker-exec.go b/docker-exec.go
new file mode 100644
index 0000000..8aa7d58
--- /dev/null
+++ b/docker-exec.go
@@ -0,0 +1,160 @@
+// Copyright (c) 2025 David Vogel
+//
+// This software is released under the MIT License.
+// https://opensource.org/licenses/MIT
+
+package typst
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "os/exec"
+)
+
+// Theoretically it's possible to use the Docker SDK directly:
+// https://docs.docker.com/reference/api/engine/sdk/examples/
+// But that dependency is unnecessarily huge, therefore we will just call the Docker executable.
+
+// DockerExec allows you to invoke Typst commands in a running Docker container.
+//
+// This uses docker exec, and therefore needs you to set up a running container beforehand.
+// For a less complex setup see typst.Docker.
+type DockerExec struct {
+ ContainerName string // The name of the running container you want to invoke Typst in.
+ TypstPath string // The path to the Typst executable inside of the container. Defaults to `typst` if left empty.
+
+ // Custom "docker exec" command line options go here.
+ // For all available options, see: https://docs.docker.com/reference/cli/docker/container/exec/
+ //
+ // Example:
+ // typst.DockerExec{Custom: []string{"--user", "1000"}} // Use a non-root user inside the docker container.
+ Custom []string
+}
+
+// Ensure that DockerExec implements the Caller interface.
+var _ Caller = DockerExec{}
+
+// args returns docker related arguments.
+func (d DockerExec) args() ([]string, error) {
+ if d.ContainerName == "" {
+ return nil, fmt.Errorf("the provided ContainerName field is empty")
+ }
+
+ typstPath := "typst"
+ if d.TypstPath != "" {
+ typstPath = d.TypstPath
+ }
+
+ // Argument -i is needed for stdio to work.
+ args := []string{"exec", "-i"}
+
+ args = append(args, d.Custom...)
+
+ args = append(args, d.ContainerName, typstPath)
+
+ return args, nil
+}
+
+// VersionString returns the Typst version as a string.
+func (d DockerExec) VersionString() (string, error) {
+ args, err := d.args()
+ if err != nil {
+ return "", err
+ }
+ args = append(args, "--version")
+
+ cmd := exec.Command("docker", args...)
+
+ var output, errBuffer bytes.Buffer
+ cmd.Stdout = &output
+ cmd.Stderr = &errBuffer
+
+ if err := cmd.Run(); err != nil {
+ switch err := err.(type) {
+ case *exec.ExitError:
+ return "", ParseStderr(errBuffer.String(), err)
+ default:
+ return "", err
+ }
+ }
+
+ return output.String(), nil
+}
+
+// Fonts returns all fonts that are available to Typst.
+// The options parameter is optional, and can be nil.
+func (d DockerExec) Fonts(options *OptionsFonts) ([]string, error) {
+ args, err := d.args()
+ if err != nil {
+ return nil, err
+ }
+
+ if options == nil {
+ options = new(OptionsFonts)
+ }
+ args = append(args, options.Args()...)
+
+ cmd := exec.Command("docker", args...)
+
+ var output, errBuffer bytes.Buffer
+ cmd.Stdout = &output
+ cmd.Stderr = &errBuffer
+
+ if err := cmd.Run(); err != nil {
+ switch err := err.(type) {
+ case *exec.ExitError:
+ return nil, ParseStderr(errBuffer.String(), err)
+ default:
+ return nil, err
+ }
+ }
+
+ var result []string
+ scanner := bufio.NewScanner(&output)
+ for scanner.Scan() {
+ result = append(result, scanner.Text())
+ }
+
+ return result, nil
+}
+
+// Compile takes a Typst document from input, and renders it into the output writer.
+// The options parameter is optional, and can be nil.
+func (d DockerExec) Compile(input io.Reader, output io.Writer, options *OptionsCompile) error {
+ args, err := d.args()
+ if err != nil {
+ return err
+ }
+
+ if options == nil {
+ options = new(OptionsCompile)
+ }
+ args = append(args, options.Args()...)
+
+ cmd := exec.Command("docker", args...)
+ cmd.Stdin = input
+ cmd.Stdout = output
+
+ errBuffer := bytes.Buffer{}
+ cmd.Stderr = &errBuffer
+
+ if err := cmd.Run(); err != nil {
+ switch err := err.(type) {
+ case *exec.ExitError:
+ if err.ExitCode() >= 125 {
+ // Most likely docker related error.
+ // TODO: Find a better way to distinguish between Typst or Docker errors.
+ return fmt.Errorf("exit code %d: %s", err.ExitCode(), errBuffer.String())
+ } else {
+ // Typst related error.
+ return ParseStderr(errBuffer.String(), err)
+ }
+ default:
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/docker-exec_test.go b/docker-exec_test.go
new file mode 100644
index 0000000..b0b3e5d
--- /dev/null
+++ b/docker-exec_test.go
@@ -0,0 +1,184 @@
+// Copyright (c) 2025 David Vogel
+//
+// This software is released under the MIT License.
+// https://opensource.org/licenses/MIT
+
+package typst_test
+
+import (
+ "bytes"
+ "image"
+ "os/exec"
+ "strconv"
+ "testing"
+
+ "github.com/Dadido3/go-typst"
+)
+
+func TestDockerExec(t *testing.T) {
+ // Just to ensure that there is no container running.
+ exec.Command("docker", "stop", "-t", "1", "typst-instance").Run() //nolint:errcheck
+ exec.Command("docker", "rm", "typst-instance").Run() //nolint:errcheck
+
+ if err := exec.Command("docker", "run", "--name", "typst-instance", "-v", "./test-files:/test-files", "-id", "123marvin123/typst").Run(); err != nil {
+ t.Fatalf("Failed to run Docker container: %v.", err)
+ }
+ t.Cleanup(func() {
+ exec.Command("docker", "stop", "-t", "1", "typst-instance").Run() //nolint:errcheck
+ exec.Command("docker", "rm", "typst-instance").Run() //nolint:errcheck
+ })
+
+ tests := []struct {
+ Name string
+ Function func(*testing.T)
+ }{
+ {"VersionString", dockerExec_VersionString},
+ {"Fonts", dockerExec_Fonts},
+ {"FontsWithOptions", dockerExec_FontsWithOptions},
+ {"FontsWithFontPaths", dockerExec_FontsWithFontPaths},
+ {"Compile", dockerExec_Compile},
+ {"CompileWithWorkingDir", dockerExec_CompileWithWorkingDir},
+ }
+
+ for _, test := range tests {
+ t.Run(test.Name, func(t *testing.T) {
+ t.Parallel()
+ test.Function(t)
+ })
+ }
+}
+
+func dockerExec_VersionString(t *testing.T) {
+ typstCaller := typst.DockerExec{
+ ContainerName: "typst-instance",
+ }
+
+ v, err := typstCaller.VersionString()
+ if err != nil {
+ t.Fatalf("Failed to get typst version: %v.", err)
+ }
+
+ t.Logf("VersionString: %s", v)
+}
+
+func dockerExec_Fonts(t *testing.T) {
+ typstCaller := typst.DockerExec{
+ ContainerName: "typst-instance",
+ }
+
+ result, err := typstCaller.Fonts(nil)
+ if err != nil {
+ t.Fatalf("Failed to get available fonts: %v.", err)
+ }
+ if len(result) < 4 {
+ t.Errorf("Unexpected number of detected fonts. Got %d, want >= %d.", len(result), 4)
+ }
+}
+
+func dockerExec_FontsWithOptions(t *testing.T) {
+ typstCaller := typst.DockerExec{
+ ContainerName: "typst-instance",
+ }
+
+ result, err := typstCaller.Fonts(&typst.OptionsFonts{IgnoreSystemFonts: true})
+ if err != nil {
+ t.Fatalf("Failed to get available fonts: %v.", err)
+ }
+ if len(result) != 4 {
+ t.Errorf("Unexpected number of detected fonts. Got %d, want %d.", len(result), 4)
+ }
+}
+
+func dockerExec_FontsWithFontPaths(t *testing.T) {
+ typstCaller := typst.DockerExec{
+ ContainerName: "typst-instance",
+ }
+
+ result, err := typstCaller.Fonts(&typst.OptionsFonts{IgnoreSystemFonts: true, FontPaths: []string{"/test-files"}})
+ if err != nil {
+ t.Fatalf("Failed to get available fonts: %v.", err)
+ }
+ if len(result) != 5 {
+ t.Errorf("Unexpected number of detected fonts. Got %d, want %d.", len(result), 5)
+ }
+}
+
+// Test basic compile functionality.
+func dockerExec_Compile(t *testing.T) {
+ const inches = 1
+ const ppi = 144
+
+ typstCaller := typst.DockerExec{
+ ContainerName: "typst-instance",
+ }
+
+ r := bytes.NewBufferString(`#set page(width: ` + strconv.FormatInt(inches, 10) + `in, height: ` + strconv.FormatInt(inches, 10) + `in, margin: (x: 1mm, y: 1mm))
+= Test
+
+#lorem(5)`)
+
+ opts := typst.OptionsCompile{
+ Format: typst.OutputFormatPNG,
+ PPI: ppi,
+ }
+
+ var w bytes.Buffer
+ if err := typstCaller.Compile(r, &w, &opts); err != nil {
+ t.Fatalf("Failed to compile document: %v.", err)
+ }
+
+ imgConf, imgType, err := image.DecodeConfig(&w)
+ if err != nil {
+ t.Fatalf("Failed to decode image: %v.", err)
+ }
+ if imgType != "png" {
+ t.Fatalf("Resulting image is of type %q, expected %q.", imgType, "png")
+ }
+ if imgConf.Width != inches*ppi {
+ t.Fatalf("Resulting image width is %d, expected %d.", imgConf.Width, inches*ppi)
+ }
+ if imgConf.Height != inches*ppi {
+ t.Fatalf("Resulting image height is %d, expected %d.", imgConf.Height, inches*ppi)
+ }
+}
+
+// Test basic compile functionality with a given working directory.
+func dockerExec_CompileWithWorkingDir(t *testing.T) {
+ typstCaller := typst.DockerExec{
+ ContainerName: "typst-instance",
+ }
+
+ r := bytes.NewBufferString(`#import "hello-world-template.typ": template
+#show: doc => template()`)
+
+ var w bytes.Buffer
+ err := typstCaller.Compile(r, &w, &typst.OptionsCompile{Root: "/test-files"})
+ if err != nil {
+ t.Fatalf("Failed to compile document: %v.", err)
+ }
+ if w.Available() == 0 {
+ t.Errorf("No output was written.")
+ }
+}
+
+func TestDockerExec_EmptyContainerName(t *testing.T) {
+ typstCaller := typst.DockerExec{
+ ContainerName: "",
+ }
+
+ _, err := typstCaller.VersionString()
+ if err == nil {
+ t.Errorf("Expected error, but got nil.")
+ }
+}
+
+func TestDockerExec_NonRunningContainer(t *testing.T) {
+ typstCaller := typst.DockerExec{
+ ContainerName: "something-else",
+ }
+
+ _, err := typstCaller.VersionString()
+ if err == nil {
+ t.Errorf("Expected error, but got nil.")
+ }
+}
diff --git a/docker.go b/docker.go
index 2c398c5..b92299c 100644
--- a/docker.go
+++ b/docker.go
@@ -22,6 +22,10 @@ import (
const DockerDefaultImage = "ghcr.io/typst/typst:0.14.0"
// Docker allows you to invoke commands on a Typst Docker image.
+//
+// This uses docker run to automatically pull and run a container.
+// Therefore the container will start and stop automatically.
+// To have more control over the lifetime of a Docker container see typst.DockerExec.
type Docker struct {
Image string // The image to use, defaults to the latest supported offical Typst Docker image if left empty. See: typst.DockerDefaultImage.
WorkingDirectory string // The working directory of Docker. When left empty, Docker will be run with the process's current working directory.
@@ -33,6 +37,13 @@ type Docker struct {
// typst.Docker{Volumes: []string{".:/markup"}} // This bind mounts the current working directory to "/markup" inside the container.
// typst.Docker{Volumes: []string{"/usr/share/fonts:/usr/share/fonts"}} // This makes all system fonts available to Typst running inside the container.
Volumes []string
+
+ // Custom "docker run" command line options go here.
+ // For all available options, see: https://docs.docker.com/reference/cli/docker/container/run/
+ //
+ // Example:
+ // typst.Docker{Custom: []string{"--user", "1000"}} // Use a non-root user inside the docker container.
+ Custom []string // Custom "docker run" command line options go here.
}
// Ensure that Docker implements the Caller interface.
@@ -48,6 +59,8 @@ func (d Docker) args() []string {
// Argument -i is needed for stdio to work.
args := []string{"run", "-i"}
+ args = append(args, d.Custom...)
+
// Add mounts.
for _, volume := range d.Volumes {
args = append(args, "-v", volume)
@@ -120,8 +133,6 @@ func (d Docker) Fonts(options *OptionsFonts) ([]string, error) {
func (d Docker) Compile(input io.Reader, output io.Writer, options *OptionsCompile) error {
args := d.args()
- // From here on come Typst arguments.
-
if options == nil {
options = new(OptionsCompile)
}
diff --git a/documentation/images/readme-example-injection.svg b/documentation/images/readme-example-injection.svg
index c96bd54..ed4eb4c 100644
--- a/documentation/images/readme-example-injection.svg
+++ b/documentation/images/readme-example-injection.svg
@@ -33,7 +33,7 @@
-
+
@@ -49,13 +49,13 @@
-
+
-
-
+
+
-
-
+
+
@@ -222,8 +222,8 @@
-
-
+
+
@@ -231,9 +231,6 @@
-
-
-
diff --git a/readme_test.go b/readme_test.go
index bcf23d9..70a39c5 100644
--- a/readme_test.go
+++ b/readme_test.go
@@ -83,7 +83,7 @@ func TestREADME6(t *testing.T) {
}
}
-func TestREADME7(t *testing.T) {
+func TestREADME8(t *testing.T) {
markup := bytes.NewBufferString(`#set page(width: 100mm, height: auto, margin: 5mm)
= go-typst
@@ -110,7 +110,7 @@ A library to generate documents and reports by utilizing the command line versio
}
}
-func TestREADME8(t *testing.T) {
+func TestREADME9(t *testing.T) {
customValues := map[string]any{
"time": time.Now(),
"customText": "Hey there!",