From 37d4b485dd4f7a84feed48e537b0fe2ff4cb8a6e Mon Sep 17 00:00:00 2001 From: David Vogel Date: Sun, 16 Nov 2025 12:03:36 +0000 Subject: [PATCH] Add Docker caller & Add support for the fonts command --- caller.go | 16 ++++-- cli.go | 40 ++++++++++++- cli_test.go | 12 ++++ docker.go | 148 +++++++++++++++++++++++++++++++++++++++++++++++++ docker_test.go | 94 +++++++++++++++++++++++++++++++ 5 files changed, 304 insertions(+), 6 deletions(-) create mode 100644 docker.go create mode 100644 docker_test.go diff --git a/caller.go b/caller.go index 269f5ef..c48a2d2 100644 --- a/caller.go +++ b/caller.go @@ -1,18 +1,24 @@ +// Copyright (c) 2025 David Vogel +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + package typst import "io" -// TODO: Add an interface for the Typst caller and let CLI (and later docker and WASM) be implementations of that +// TODO: Add WASM caller -// TODO: Add docker support to CLI, by calling docker run instead +// TODO: Add special type "Filename" (or similar) that implements a io.Reader/io.Writer that can be plugged into the input and output parameters of the Compile method to signal the use of input/output files instead of readers/writers -// TODO: Add special type "Filename" (or similar) that implements a io.Reader/io.Writer that can be plugged into the input and output parameters of the Compile method - -// Caller contains all functions that can be +// Caller contains all Typst commands that are supported by this library. type Caller interface { // VersionString returns the version string as returned by Typst. VersionString() (string, error) + // Fonts returns all fonts that are available to Typst. + Fonts() ([]string, error) + // Compile takes a Typst document from the supplied input reader, and renders it into the output writer. // The options parameter is optional, and can be nil. Compile(input io.Reader, output io.Writer, options *Options) error diff --git a/cli.go b/cli.go index 84d3334..0466ee1 100644 --- a/cli.go +++ b/cli.go @@ -6,12 +6,14 @@ package typst import ( + "bufio" "bytes" "fmt" "io" "os/exec" ) +// CLI allows you to invoke commands on a native Typst executable. type CLI struct { ExecutablePath string // The Typst executable path can be overridden here. Otherwise the default path will be used. WorkingDirectory string // The path where the Typst executable is run in. When left empty, the Typst executable will be run in the process's current directory. @@ -50,6 +52,42 @@ func (c CLI) VersionString() (string, error) { return output.String(), nil } +// Fonts returns all fonts that are available to Typst. +func (c CLI) Fonts() ([]string, error) { + // Get path of executable. + execPath := ExecutablePath + if c.ExecutablePath != "" { + execPath = c.ExecutablePath + } + if execPath == "" { + return nil, fmt.Errorf("not supported on this platform") + } + + cmd := exec.Command(execPath, "fonts") + cmd.Dir = c.WorkingDirectory + + 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. func (c CLI) Compile(input io.Reader, output io.Writer, options *Options) error { @@ -57,7 +95,7 @@ func (c CLI) Compile(input io.Reader, output io.Writer, options *Options) error if options != nil { args = append(args, options.Args()...) } - args = append(args, "--diagnostic-format", "human", "-", "-") + args = append(args, "--diagnostic-format", "human", "-", "-") // TODO: Move these default arguments into Options // Get path of executable. execPath := ExecutablePath diff --git a/cli_test.go b/cli_test.go index 088cbea..76e23b0 100644 --- a/cli_test.go +++ b/cli_test.go @@ -25,6 +25,18 @@ func TestCLI_VersionString(t *testing.T) { } } +func TestCLI_Fonts(t *testing.T) { + caller := typst.CLI{} + + result, err := caller.Fonts() + 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) + } +} + // Test basic compile functionality. func TestCLI_Compile(t *testing.T) { const inches = 1 diff --git a/docker.go b/docker.go new file mode 100644 index 0000000..e941f6f --- /dev/null +++ b/docker.go @@ -0,0 +1,148 @@ +// 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. + +// The default Docker image to use. +// This is the latest supported version of Typst. +const DockerDefaultImage = "ghcr.io/typst/typst:0.14.0" + +// Docker allows you to invoke commands on a Typst Docker image. +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. + + // Additional bind-mounts or volumes that are passed via "--volume" flag to Docker. + // For details, see: https://docs.docker.com/engine/storage/volumes/#syntax + // + // Example: + // 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 +} + +// Ensure that Docker implements the Caller interface. +var _ Caller = Docker{} + +// VersionString returns the version string as returned by Typst. +func (d Docker) VersionString() (string, error) { + image := DockerDefaultImage + if d.Image != "" { + image = d.Image + } + + cmd := exec.Command("docker", "run", "-i", image, "--version") + + 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. +func (d Docker) Fonts() ([]string, error) { + image := DockerDefaultImage + if d.Image != "" { + image = d.Image + } + + cmd := exec.Command("docker", "run", "-i", image, "fonts") + + 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. +func (d Docker) Compile(input io.Reader, output io.Writer, options *Options) error { + image := DockerDefaultImage + if d.Image != "" { + image = d.Image + } + + // Argument -i is needed for stdio to work. + args := []string{"run", "-i"} + + // Add mounts. + for _, volume := range d.Volumes { + args = append(args, "-v", volume) + } + + args = append(args, image) + + // From here on come Typst arguments. + + args = append(args, "c") + if options != nil { + args = append(args, options.Args()...) + } + args = append(args, "--diagnostic-format", "human", "-", "-") // TODO: Move these default arguments into Options + + cmd := exec.Command("docker", args...) + cmd.Dir = d.WorkingDirectory + 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_test.go b/docker_test.go new file mode 100644 index 0000000..10936ef --- /dev/null +++ b/docker_test.go @@ -0,0 +1,94 @@ +// Copyright (c) 2025 David Vogel +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT + +package typst_test + +import ( + "bytes" + "image" + "path/filepath" + "strconv" + "testing" + + "github.com/Dadido3/go-typst" +) + +func TestDocker_VersionString(t *testing.T) { + caller := typst.Docker{} + + _, err := caller.VersionString() + if err != nil { + t.Fatalf("Failed to get typst version: %v.", err) + } +} + +func TestDocker_Fonts(t *testing.T) { + caller := typst.Docker{} + + result, err := caller.Fonts() + 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) + } +} + +// Test basic compile functionality. +func TestDocker_Compile(t *testing.T) { + const inches = 1 + const ppi = 144 + + typstCaller := typst.Docker{} + + 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.Options{ + 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 TestDocker_CompileWithWorkingDir(t *testing.T) { + typstCaller := typst.Docker{ + WorkingDirectory: filepath.Join(".", "test-files"), + Volumes: []string{".:/markup"}, + } + + r := bytes.NewBufferString(`#import "hello-world-template.typ": template +#show: doc => template()`) + + var w bytes.Buffer + err := typstCaller.Compile(r, &w, &typst.Options{Root: "/markup"}) + if err != nil { + t.Fatalf("Failed to compile document: %v.", err) + } + if w.Available() == 0 { + t.Errorf("No output was written.") + } +}