Add Docker caller & Add support for the fonts command

This commit is contained in:
David Vogel 2025-11-16 12:03:36 +00:00
parent a8a2466172
commit 37d4b485dd
5 changed files with 304 additions and 6 deletions

View File

@ -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

40
cli.go
View File

@ -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

View File

@ -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

148
docker.go Normal file
View File

@ -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
}

94
docker_test.go Normal file
View File

@ -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.")
}
}