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!",