Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

31 changed files with 227 additions and 624 deletions

View File

@ -1,29 +0,0 @@
name: golangci-lint
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
# Optional: allow read access to pull request. Use with `only-new-issues` option.
# pull-requests: read
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: stable
- name: Check out code
uses: actions/checkout@v4
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.64

View File

@ -10,7 +10,7 @@ jobs:
strategy:
matrix:
go-version: ['1.23.x']
typst-version: ['0.12.0', '0.13.0', '0.13.1']
typst-version: ['0.12.0']
steps:
- name: Install typst-cli ${{ matrix.typst-version }} from crates.io

View File

@ -2,7 +2,6 @@
"cSpell.words": [
"Dadido",
"Foogaloo",
"golangci",
"typst",
"Vogel"
]

View File

@ -6,13 +6,9 @@ Its goal is to provide Go developers with a seamless, "Go-like" interface to Typ
## Stability and Compatibility
`go-typst` is a work in progress, and the API may change as Typst evolves.
Supported Typst versions are tested by unit tests to ensure compatibility.
Supported Typst versions are tested by unit tests to ensure compatibility:
**Supported Versions:**
- Typst 0.12.0
- Typst 0.13.0
- Typst 0.13.1
- **Supported Version:** Typst 0.12.0
While breaking changes may occur, i aim to minimize disruptions.
Use at your own discretion for production systems.
@ -21,7 +17,7 @@ Use at your own discretion for production systems.
- PDF, SVG and PNG generation.
- All Typst parameters are discoverable and documented in [cli-options.go](cli-options.go).
- Go-to-Typst Value Encoder: Seamlessly inject any Go values (Including `image.Image` with a [wrapper](image.go)) into Typst documents via the provided encoder.
- Go-to-Typst Object Encoder: Seamlessly inject any Go values (Including `image.Image` with a [wrapper](image.go)) into Typst documents via the provided encoder.
- Errors from Typst CLI are returned as structured Go error objects with detailed information, such as line numbers and file paths.
- Uses stdio; No temporary files will be created.
- Good unit test coverage.

View File

@ -1,4 +1,4 @@
// Copyright (c) 2024-2025 David Vogel
// Copyright (c) 2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -16,18 +16,9 @@ type OutputFormat string
const (
OutputFormatAuto OutputFormat = ""
OutputFormatPDF OutputFormat = "pdf"
OutputFormatPNG OutputFormat = "png"
OutputFormatSVG OutputFormat = "svg"
OutputFormatHTML OutputFormat = "html" // this format is only available since 0.13.0
)
type PDFStandard string
const (
PDFStandard1_7 PDFStandard = "1.7" // PDF 1.7
PDFStandardA_2B PDFStandard = "a-2b" // PDF/A-2b
PDFStandardA_3B PDFStandard = "a-3b" // PDF/A-3b (Available since Typst 0.13.0 and later)
OutputFormatPDF OutputFormat = "pdf"
OutputFormatPNG OutputFormat = "png"
OutputFormatSVG OutputFormat = "svg"
)
type CLIOptions struct {
@ -50,10 +41,13 @@ type CLIOptions struct {
Format OutputFormat // The format of the output file, inferred from the extension by default.
PPI int // The PPI (pixels per inch) to use for PNG export. Defaults to 144.
// One (or multiple) PDF standards that Typst will enforce conformance with.
// One (or multiple comma-separated) PDF standards that Typst will enforce conformance with.
//
// See typst.PDFStandard for possible values.
PDFStandards []PDFStandard
// Possible values:
//
// - 1.7: PDF 1.7
// - a-2b: PDF/A-2b
PDFStandard string
Custom []string // Custom command line options go here.
}
@ -76,7 +70,6 @@ func (c *CLIOptions) Args() (result []string) {
}
paths += path
}
result = append(result, "--font-path", paths)
}
if c.IgnoreSystemFonts {
@ -105,30 +98,15 @@ func (c *CLIOptions) Args() (result []string) {
if c.Format != OutputFormatAuto {
result = append(result, "-f", string(c.Format))
if c.Format == OutputFormatHTML {
// this is specific to version 0.13.0 where html
// is a feature than need explicit activation
// we must remove this when html becomes standard
result = append(result, "--features", "html")
}
}
if c.PPI > 0 {
result = append(result, "--ppi", strconv.FormatInt(int64(c.PPI), 10))
}
if len(c.PDFStandards) > 0 {
var standards string
for i, standard := range c.PDFStandards {
if i > 0 {
standards += ","
}
standards += string(standard)
}
result = append(result, "--pdf-standard", standards)
if c.PDFStandard != "" {
result = append(result, "--pdf-standard", c.PDFStandard)
}
result = append(result, c.Custom...)
return
}

View File

@ -1,24 +0,0 @@
package typst_test
import (
"os"
"testing"
"github.com/Dadido3/go-typst"
)
func TestCliOptions(t *testing.T) {
o := typst.CLIOptions{
FontPaths: []string{"somepath/to/somewhere", "another/to/somewhere"},
}
args := o.Args()
if len(args) != 2 {
t.Errorf("wrong number of arguments, expected 2, got %d", len(args))
}
if "--font-path" != args[0] {
t.Error("wrong font path option, expected --font-path, got", args[0])
}
if "somepath/to/somewhere"+string(os.PathListSeparator)+"another/to/somewhere" != args[1] {
t.Error("wrong font path option, expected my two paths concatenated, got", args[1])
}
}

27
cli.go
View File

@ -1,4 +1,4 @@
// Copyright (c) 2024-2025 David Vogel
// Copyright (c) 2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -15,12 +15,12 @@ import (
// TODO: Add docker support to CLI, by calling docker run instead
type CLI struct {
ExecutablePath string // The Typst executable path can be overridden here. Otherwise the default path will be used.
ExecutablePath string // The typst executable path can be overridden here. Otherwise the default path will be used.
}
// TODO: Add method for querying the Typst version resulting in a semver object
// TODO: Add method for querying the typst version resulting in a semver object
// VersionString returns the version string as returned by Typst.
// VersionString returns the version string as returned by typst.
func (c CLI) VersionString() (string, error) {
// Get path of executable.
execPath := ExecutablePath
@ -46,7 +46,7 @@ func (c CLI) VersionString() (string, error) {
return output.String(), nil
}
// Compile takes a Typst document from input, and renders it into the output writer.
// 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 *CLIOptions) error {
args := []string{"c"}
@ -80,17 +80,22 @@ func (c CLI) Compile(input io.Reader, output io.Writer, options *CLIOptions) err
return nil
}
// CompileWithVariables takes a Typst document from input, and renders it into the output writer.
// Compile takes a typst document from input, and renders it into the output writer.
// The options parameter is optional.
//
// Additionally this will inject the given map of variables into the global scope of the Typst document.
//
// Deprecated: You should use InjectValues in combination with the normal Compile method instead.
// Additionally this will inject the given map of variables into the global scope of the typst document.
func (c CLI) CompileWithVariables(input io.Reader, output io.Writer, options *CLIOptions, variables map[string]any) error {
varBuffer := bytes.Buffer{}
if err := InjectValues(&varBuffer, variables); err != nil {
return fmt.Errorf("failed to inject values into Typst markup: %w", err)
// TODO: Use io.pipe instead of a bytes.Buffer
enc := NewVariableEncoder(&varBuffer)
for k, v := range variables {
varBuffer.WriteString("#let " + CleanIdentifier(k) + " = ")
if err := enc.Encode(v); err != nil {
return fmt.Errorf("failed to encode variables with key %q: %w", k, err)
}
varBuffer.WriteRune('\n')
}
reader := io.MultiReader(&varBuffer, input)

View File

@ -1,4 +1,4 @@
// Copyright (c) 2024-2025 David Vogel
// Copyright (c) 2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -7,5 +7,5 @@
package typst
// The path to the Typst executable.
// The path to the typst executable.
var ExecutablePath = "typst"

View File

@ -1,4 +1,4 @@
// Copyright (c) 2024-2025 David Vogel
// Copyright (c) 2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -7,9 +7,9 @@
package typst
// The path to the Typst executable.
// The path to the typst executable.
// We assume the executable is in the current working directory.
//var ExecutablePath = "." + string(filepath.Separator) + filepath.Join("typst.exe")
// The path to the Typst executable.
// The path to the typst executable.
var ExecutablePath = "typst.exe"

123
errors.go
View File

@ -1,4 +1,4 @@
// Copyright (c) 2024-2025 David Vogel
// Copyright (c) 2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -8,26 +8,14 @@ package typst
import (
"regexp"
"strconv"
"strings"
)
// ErrorDetails contains the details of a typst.Error.
type ErrorDetails struct {
Message string // The parsed error message.
Path string // Path of the Typst file where the error is located in. Zero value means that there is no further information.
Line int // Line number of the error. Zero value means that there is no further information.
Column int // Column of the error. Zero value means that there is no further information.
}
// Error represents an error as returned by Typst.
// This can contain multiple sub-errors or sub-warnings which are listed in the field Details.
// Error represents a generic typst error.
type Error struct {
Inner error
Raw string // The raw output from stderr.
// Raw output parsed into errors and warnings.
Details []ErrorDetails
Raw string // The raw output from stderr.
Message string // The parsed error message.
}
func (e *Error) Error() string {
@ -38,43 +26,72 @@ func (e *Error) Unwrap() error {
return e.Inner
}
var stderrRegex = regexp.MustCompile(`(?s)^(?<error>.+?)(?:(?:\n\s+┌─ (?<path>.+?):(?<line>\d+):(?<column>\d+)\n)|(?:$))`)
// ErrorWithPath represents a typst error that also contains information about its origin (filepath, line and column).
type ErrorWithPath struct {
Inner error
// ParseStderr will parse the given stderr output and return a typst.Error.
func ParseStderr(stderr string, inner error) error {
err := Error{
Inner: inner,
Raw: stderr,
}
Raw string // The raw error string as returned by the executable.
Message string // Error message from typst.
// Get all "blocks" ending with double new lines.
parts := strings.Split(stderr, "\n\n")
parts = parts[:len(parts)-1]
for _, part := range parts {
if parsed := stderrRegex.FindStringSubmatch(part); parsed != nil {
var details ErrorDetails
if i := stderrRegex.SubexpIndex("error"); i > 0 && i < len(parsed) && parsed[i] != "" {
details.Message = parsed[i]
}
if i := stderrRegex.SubexpIndex("path"); i > 0 && i < len(parsed) && parsed[i] != "" {
details.Path = parsed[i]
}
if i := stderrRegex.SubexpIndex("line"); i > 0 && i < len(parsed) && parsed[i] != "" {
if line, err := strconv.ParseInt(parsed[i], 10, 0); err == nil {
details.Line = int(line)
}
}
if i := stderrRegex.SubexpIndex("column"); i > 0 && i < len(parsed) && parsed[i] != "" {
if column, err := strconv.ParseInt(parsed[i], 10, 0); err == nil {
details.Column = int(column)
}
}
err.Details = append(err.Details, details)
}
}
return &err
Path string // Path of the typst file where the error is located in.
Line int // Line number of the error.
Column int // Column of the error.
}
func (e *ErrorWithPath) Error() string {
return e.Raw
}
func (e *ErrorWithPath) Unwrap() error {
return e.Inner
}
var stderrRegex = regexp.MustCompile(`(?s)^error: (?<error>.+?)\n`)
var stderrWithPathRegex = regexp.MustCompile(`(?s)^error: (?<error>.+?)\n\s+┌─ (?<path>.+?):(?<line>\d+):(?<column>\d+)\n`)
// ParseStderr will parse the given stderr output and return a suitable error object.
// Depending on the stderr message, this will return either a typst.Error or a typst.ErrorWithPath error.
func ParseStderr(stderr string, inner error) error {
if parsed := stderrWithPathRegex.FindStringSubmatch(stderr); parsed != nil {
err := ErrorWithPath{
Raw: stderr,
Inner: inner,
}
if i := stderrWithPathRegex.SubexpIndex("error"); i > 0 && i < len(parsed) {
err.Message = parsed[i]
}
if i := stderrWithPathRegex.SubexpIndex("path"); i > 0 && i < len(parsed) {
err.Path = parsed[i]
}
if i := stderrWithPathRegex.SubexpIndex("line"); i > 0 && i < len(parsed) {
line, _ := strconv.ParseInt(parsed[i], 10, 0)
err.Line = int(line)
}
if i := stderrWithPathRegex.SubexpIndex("column"); i > 0 && i < len(parsed) {
column, _ := strconv.ParseInt(parsed[i], 10, 0)
err.Column = int(column)
}
return &err
}
if parsed := stderrRegex.FindStringSubmatch(stderr); parsed != nil {
err := Error{
Raw: stderr,
Inner: inner,
}
if i := stderrRegex.SubexpIndex("error"); i > 0 && i < len(parsed) {
err.Message = parsed[i]
}
return &err
}
// Fall back to the raw error message.
return &Error{
Raw: stderr,
Inner: inner,
}
}

View File

@ -1,4 +1,4 @@
// Copyright (c) 2024-2025 David Vogel
// Copyright (c) 2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -11,7 +11,6 @@ import (
"testing"
"github.com/Dadido3/go-typst"
"github.com/google/go-cmp/cmp"
)
func TestErrors0(t *testing.T) {
@ -36,26 +35,22 @@ func TestErrors1(t *testing.T) {
if err := cli.Compile(r, &w, nil); err == nil {
t.Fatalf("Expected error, but got nil")
} else {
var errTypst *typst.Error
if errors.As(err, &errTypst) {
if len(errTypst.Details) != 1 {
t.Fatalf("Expected error doesn't contain the expected number of detail entries. Got %v, want %v", len(errTypst.Details), 1)
var errWithPath *typst.ErrorWithPath
if errors.As(err, &errWithPath) {
if errWithPath.Message != "assertion failed: Test" {
t.Errorf("Expected error with error message %q, got %q", "assertion failed: Test", errWithPath.Message)
}
details := errTypst.Details[0]
if details.Message != "error: assertion failed: Test" {
t.Errorf("Expected error with error message %q, got %q", "error: assertion failed: Test", details.Message)
}
/*if details.Path != "" {
t.Errorf("Expected error to point to path %q, got path %q", "", details.Path)
/*if errWithPath.Path != "" {
t.Errorf("Expected error to point to path %q, got path %q", "", errWithPath.Path)
}*/
if details.Line != 3 {
t.Errorf("Expected error to point at line %d, got line %d", 3, details.Line)
if errWithPath.Line != 3 {
t.Errorf("Expected error to point at line %d, got line %d", 3, errWithPath.Line)
}
if details.Column != 1 {
t.Errorf("Expected error to point at column %d, got column %d", 1, details.Column)
if errWithPath.Column != 1 {
t.Errorf("Expected error to point at column %d, got column %d", 1, errWithPath.Column)
}
} else {
t.Errorf("Expected error type %T, got %T: %v", errTypst, err, err)
t.Errorf("Expected error type %T, got %T: %v", errWithPath, err, err)
}
}
}
@ -75,108 +70,13 @@ func TestErrors2(t *testing.T) {
} else {
var errTypst *typst.Error
if errors.As(err, &errTypst) {
if len(errTypst.Details) != 1 {
t.Fatalf("Expected error doesn't contain the expected number of detail entries. Got %v, want %v", len(errTypst.Details), 1)
}
details := errTypst.Details[0]
// Don't check the specific error message, as that may change over time.
// The expected message should be similar to: error: invalid value 'a' for '--pages <PAGES>': not a valid page number.
if details.Message == "" {
t.Errorf("Expected error message, got %q", details.Message)
// The expected message should be similar to: invalid value 'a' for '--pages <PAGES>': not a valid page number.
if errTypst.Message == "" {
t.Errorf("Expected error message, got %q", errTypst.Message)
}
} else {
t.Errorf("Expected error type %T, got %T: %v", errTypst, err, err)
}
}
}
func TestErrorParsing(t *testing.T) {
var tests = map[string]struct {
StdErr string // The original and raw stderr message.
ExpectedDetails []typst.ErrorDetails // Expected parsed result.
}{
"Typst 0.13.0 HTML warning + error": {
StdErr: "warning: html export is under active development and incomplete\n = hint: its behaviour may change at any time\n = hint: do not rely on this feature for production use cases\n = hint: see https://github.com/typst/typst/issues/5512 for more information\n\nerror: page configuration is not allowed inside of containers\n ┌─ \\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>:1:1\n │\n1 │ #set page(width: 100mm, height: auto, margin: 5mm)\n │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n",
ExpectedDetails: []typst.ErrorDetails{
{
Message: "warning: html export is under active development and incomplete\n = hint: its behaviour may change at any time\n = hint: do not rely on this feature for production use cases\n = hint: see https://github.com/typst/typst/issues/5512 for more information",
},
{
Message: "error: page configuration is not allowed inside of containers",
Path: "\\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>",
Line: 1,
Column: 1,
},
},
},
"Typst 0.13.0 error with path": {
StdErr: "error: expected expression\n ┌─ \\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>:12:34\n │\n12 │ - Test coverage of most features.#\n │ ^\n\n",
ExpectedDetails: []typst.ErrorDetails{
{
Message: "error: expected expression",
Path: "\\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>",
Line: 12,
Column: 34,
},
},
},
"Typst 0.13.0 multiple errors with paths": {
StdErr: "error: expected expression\n ┌─ \\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>:11:53\n │\n11 │ - Uses stdio; No temporary files need to be created.#\n │ ^\n\nerror: expected expression\n ┌─ \\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>:12:34\n │\n12 │ - Test coverage of most features.#\n │ ^\n\n",
ExpectedDetails: []typst.ErrorDetails{
{
Message: "error: expected expression",
Path: "\\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>",
Line: 11,
Column: 53,
},
{
Message: "error: expected expression",
Path: "\\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>",
Line: 12,
Column: 34,
},
},
},
"Typst 0.13.0 stacked errors with paths": {
StdErr: "error: expected expression\n ┌─ \\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\test.typ:1:4\n │\n1 │ hey#\n │ ^\n\nhelp: error occurred while importing this module\n ┌─ \\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>:14:9\n │\n14 │ #include \"test.typ\"\n │ ^^^^^^^^^^\n\n",
ExpectedDetails: []typst.ErrorDetails{
{
Message: "error: expected expression",
Path: "\\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\test.typ",
Line: 1,
Column: 4,
},
{
Message: "help: error occurred while importing this module",
Path: "\\\\?\\C:\\Users\\David Vogel\\Desktop\\Synced\\Go\\Libraries\\go-typst\\<stdin>",
Line: 14,
Column: 9,
},
},
},
"Typst 0.13.0 error without path": {
StdErr: "error: invalid value 'a' for '--pages <PAGES>': not a valid page number\n\nFor more information, try '--help'.\n",
ExpectedDetails: []typst.ErrorDetails{
{
Message: "error: invalid value 'a' for '--pages <PAGES>': not a valid page number",
},
},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
result := typst.ParseStderr(tt.StdErr, nil)
var typstError *typst.Error
if errors.As(result, &typstError) {
if !cmp.Equal(typstError.Details, tt.ExpectedDetails) {
t.Errorf("Parsed details don't match expected details: %s", cmp.Diff(tt.ExpectedDetails, typstError.Details))
}
} else {
t.Errorf("Parsed error is not of type %T", typstError)
}
})
}
}

View File

@ -1,7 +1,6 @@
package main
import (
"bytes"
"log"
"os"
"time"
@ -9,7 +8,7 @@ import (
"github.com/Dadido3/go-typst"
)
// DataEntry contains data to be passed to Typst.
// DataEntry contains fake data to be passed to typst.
type DataEntry struct {
Name string
Size struct{ X, Y, Z float64 }
@ -26,28 +25,21 @@ var TestData = []DataEntry{
}
func main() {
var markup bytes.Buffer
typstCLI := typst.CLI{}
// Inject Go values as Typst markup.
if err := typst.InjectValues(&markup, map[string]any{"data": TestData, "customText": "This data is coming from a Go application."}); err != nil {
log.Panicf("Failed to inject values into Typst markup: %v.", err)
r, err := os.Open("template.typ")
if err != nil {
log.Panicf("Failed to open template file for reading: %v.", err)
}
defer r.Close()
// Import the template and invoke the template function with the custom data.
// Show is used to replace the current document with whatever content the template function in `template.typ` returns.
markup.WriteString(`
#import "template.typ": template
#show: doc => template(data, customText)`)
// Compile the prepared markup with Typst and write the result it into `output.pdf`.
f, err := os.Create("output.pdf")
if err != nil {
log.Panicf("Failed to create output file: %v.", err)
}
defer f.Close()
typstCLI := typst.CLI{}
if err := typstCLI.Compile(&markup, f, nil); err != nil {
if err := typstCLI.CompileWithVariables(r, f, nil, map[string]any{"Data": TestData}); err != nil {
log.Panicf("Failed to compile document: %v.", err)
}
}

Binary file not shown.

View File

@ -1,12 +1,5 @@
// Prepare data that will be used as preview.
#let data = (
#let Data = (
(Name: "Bell", Size: (X: 80, Y: 40, Z: 40), Created: datetime(year: 2010, month: 12, day: 1, hour: 12, minute: 13, second: 14), Numbers: (1, 2, 3)),
(Name: "Bell", Size: (X: 80, Y: 40, Z: 40), Created: datetime(year: 2010, month: 12, day: 1, hour: 12, minute: 13, second: 14), Numbers: (10, 12, 15)),
(Name: "Bell", Size: (X: 80, Y: 40, Z: 40), Created: datetime(year: 2010, month: 12, day: 1, hour: 12, minute: 13, second: 14), Numbers: (100, 109, 199, 200)),
)
#let customText = "Hey, this is example data to test the template."
// Invoke the template with the preview data and replace this whole document with the result.
#import "template.typ": template
#show: doc => template(data, customText)

View File

@ -0,0 +1,33 @@
#set page(paper: "a4")
// Uncomment to use test data.
//#import "template-test-data.typ": Data
= List of items
#show table.cell.where(y: 0): strong
#set table(
stroke: (x, y) => if y == 0 {
(bottom: 0.7pt + black)
},
)
#table(
columns: 5,
table.header(
[Name],
[Size],
[Example box],
[Created],
[Numbers],
),
..for value in Data {
(
[#value.Name],
[#value.Size],
box(fill: black, width: 0.1mm * value.Size.X, height: 0.1mm * value.Size.Y),
value.Created.display(),
[#list(..for num in value.Numbers {([#num],)})],
)
}
)

View File

@ -1,13 +0,0 @@
# Passing values example
This example demonstrates how to pass values to Typst, which can be useful in rendering custom documents such as reports, invoices, and more.
## How it works
This example follows the [template pattern](https://typst.app/docs/tutorial/making-a-template/) described in the Typst documentation.
Here is a short overview of the files:
- [template.typ](template.typ) defines a Typst template function that constructs a document based on parameters.
- [main.go](main.go) shows how to convert/encode Go values into Typst markup, and how to call/render the template with these converted values.
- [template-preview.typ](template-preview.typ) also invokes the template while providing mock data.
This is useful when you want to preview, update or debug the template.

View File

@ -1,16 +0,0 @@
package main
import (
"testing"
)
// Run the example as a test.
func TestMain(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Error(r)
}
}()
main()
}

Binary file not shown.

View File

@ -1,36 +0,0 @@
#let template(data, customText) = {
set page(paper: "a4")
[= Example]
customText
[== List of items]
show table.cell.where(y: 0): strong
set table(
stroke: (x, y) => if y == 0 {
(bottom: 0.7pt + black)
},
)
table(
columns: 5,
table.header(
[Name],
[Size],
[Example box],
[Created],
[Numbers],
),
..for value in data {
(
[#value.Name],
[#value.Size],
box(fill: black, width: 0.1mm * value.Size.X, height: 0.1mm * value.Size.Y),
value.Created.display(),
[#list(..for num in value.Numbers {([#num],)})],
)
}
)
}

View File

@ -1,12 +0,0 @@
# Simple example
This example shows how to render Typst documents directly from strings in Go.
## The pros and cons
The main advantage of this method is that it's really easy to set up.
In the most simple case you build your Typst markup by concatenating strings, or by using `fmt.Sprintf`.
The downside is that the final Typst markup is only generated on demand.
This means that you can't easily use the existing Typst tooling to write, update or debug your Typst markup.
Especially as your your documents get more complex, you should switch to other methods.

View File

@ -1,37 +0,0 @@
package main
import (
"bytes"
"fmt"
"log"
"os"
"time"
"github.com/Dadido3/go-typst"
)
func main() {
// Convert a time.Time value into Typst markup.
date, err := typst.MarshalValue(time.Now())
if err != nil {
log.Panicf("Failed to marshal date into Typst markup: %v", err)
}
// Write Typst markup into buffer.
var markup bytes.Buffer
fmt.Fprintf(&markup, `= Hello world
This document was created at #%s.display() using typst-go.`, date)
// Compile the prepared markup with Typst and write the result it into `output.pdf`.
f, err := os.Create("output.pdf")
if err != nil {
log.Panicf("Failed to create output file: %v.", err)
}
defer f.Close()
typstCLI := typst.CLI{}
if err := typstCLI.Compile(&markup, f, nil); err != nil {
log.Panic("failed to compile document: %w", err)
}
}

View File

@ -1,16 +0,0 @@
package main
import (
"testing"
)
// Run the example as a test.
func TestMain(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Error(r)
}
}()
main()
}

Binary file not shown.

View File

@ -1,4 +1,4 @@
// Copyright (c) 2024-2025 David Vogel
// Copyright (c) 2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -11,7 +11,7 @@ import (
"github.com/smasher164/xid"
)
// CleanIdentifier will return the input cleaned up in a way so that it can safely be used as a Typst identifier.
// CleanIdentifier will return the input cleaned up in a way so that it can safely be used as a typst identifier.
// This function will replace all illegal characters, which means collisions are possible in some cases.
//
// See https://github.com/typst/typst/blob/76c24ee6e35715cd14bb892d7b6b8d775c680bf7/crates/typst-syntax/src/lexer.rs#L932 for details.

View File

@ -1,8 +1,3 @@
// Copyright (c) 2024-2025 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package typst
import (
@ -13,12 +8,12 @@ import (
"strconv"
)
// Image can be used to encode any image.Image into a Typst image.
// Image can be used to encode any image.Image into a typst image.
//
// For this, just wrap any image.Image with this type before passing it to MarshalValue or a ValueEncoder.
// For this, just wrap any image.Image with this type before passing it to MarshalVariable or a VariableEncoder.
type Image struct{ image.Image }
func (i Image) MarshalTypstValue() ([]byte, error) {
func (i Image) MarshalTypstVariable() ([]byte, error) {
var buffer bytes.Buffer
if err := png.Encode(&buffer, i); err != nil {
@ -27,10 +22,8 @@ func (i Image) MarshalTypstValue() ([]byte, error) {
// TODO: Make image encoding more efficient: Use reader/writer, baseXX encoding
// TODO: Consider using raw pixel encoding instead of PNG
var buf bytes.Buffer
buf.WriteString("image.decode(bytes((") // TODO: Pass bytes directly to image once Typst 0.12.0 is not supported anymore
buf.WriteString("image.decode(bytes((")
for _, b := range buffer.Bytes() {
buf.WriteString(strconv.FormatUint(uint64(b), 10) + ",")
}

View File

@ -33,26 +33,18 @@ func (p *testImage) Opaque() bool {
}
func TestImage(t *testing.T) {
img := &testImage{image.Rect(0, 0, 256, 256)}
img := &testImage{image.Rect(0, 0, 255, 255)}
// Wrap image.
typstImage := typst.Image{img}
cli := typst.CLI{}
var r bytes.Buffer
r := bytes.NewBufferString(`= Image test
if err := typst.InjectValues(&r, map[string]any{"TestImage": typstImage}); err != nil {
t.Fatalf("Failed to inject values into Typst markup: %v.", err)
}
#TestImage`)
r.WriteString(`= Image test
#TestImage
#assert(type(TestImage) == content, message: "TestImage is not of expected type: got " + str(type(TestImage)) + ", want content")`) // TODO: Add another assertion for the image width and height as soon as it's possible to query that
if err := cli.Compile(&r, io.Discard, nil); err != nil {
if err := cli.CompileWithVariables(r, io.Discard, nil, map[string]any{"TestImage": typstImage}); err != nil {
t.Fatalf("Failed to compile document: %v.", err)
}
}

47
util.go
View File

@ -1,47 +0,0 @@
// Copyright (c) 2025 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package typst
import (
"fmt"
"io"
"maps"
"slices"
)
// InjectValues will write the given key-value pairs as Typst markup into output.
// This can be used to inject Go values into Typst documents.
//
// Every key in values needs to be a valid identifier, otherwise this function will return an error.
// Every value in values will be marshaled according to ValueEncoder into equivalent Typst markup.
//
// Passing {"foo": 1, "bar": 60 * time.Second} as values will produce the following output:
//
// #let foo = 1
// #let bar = duration(seconds: 60)
func InjectValues(output io.Writer, values map[string]any) error {
enc := NewValueEncoder(output)
// We will have to iterate over the sorted list of map keys.
// Otherwise the output is not deterministic, and tests will fail randomly.
for _, k := range slices.Sorted(maps.Keys(values)) {
v := values[k]
if !IsIdentifier(k) {
return fmt.Errorf("%q is not a valid identifier", k)
}
if _, err := output.Write([]byte("#let " + CleanIdentifier(k) + " = ")); err != nil {
return err
}
if err := enc.Encode(v); err != nil {
return fmt.Errorf("failed to encode values with key %q: %w", k, err)
}
if _, err := output.Write([]byte("\n")); err != nil {
return err
}
}
return nil
}

View File

@ -1,36 +0,0 @@
package typst
import (
"bytes"
"testing"
"time"
)
func TestInjectValues(t *testing.T) {
type args struct {
values map[string]any
}
tests := []struct {
name string
args args
wantOutput string
wantErr bool
}{
{"empty", args{values: nil}, "", false},
{"nil", args{values: map[string]any{"foo": nil}}, "#let foo = none\n", false},
{"example", args{values: map[string]any{"foo": 1, "bar": 60 * time.Second}}, "#let bar = duration(seconds: 60)\n#let foo = 1\n", false},
{"invalid identifier", args{values: map[string]any{"foo😀": 1}}, "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := &bytes.Buffer{}
if err := InjectValues(output, tt.args.values); (err != nil) != tt.wantErr {
t.Errorf("InjectValues() error = %v, wantErr %v", err, tt.wantErr)
return
}
if gotOutput := output.String(); gotOutput != tt.wantOutput {
t.Errorf("InjectValues() = %v, want %v", gotOutput, tt.wantOutput)
}
})
}
}

View File

@ -1,21 +0,0 @@
// Copyright (c) 2025 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package typst
import "io"
// This exists for compatibility reasons.
// Deprecated: Use NewValueEncoder instead, as this will be removed in a future version.
func NewVariableEncoder(w io.Writer) *ValueEncoder { return NewValueEncoder(w) }
// Deprecated: Use MarshalValue instead, as this will be removed in a future version.
func MarshalVariable(v any) ([]byte, error) { return MarshalValue(v) }
// Deprecated: Use ValueMarshaler interface instead, as this will be removed in a future version.
type VariableMarshaler interface {
MarshalTypstVariable() ([]byte, error)
}

View File

@ -1,4 +1,4 @@
// Copyright (c) 2024-2025 David Vogel
// Copyright (c) 2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -18,11 +18,11 @@ import (
"time"
)
// MarshalValue takes any Go type and returns a Typst markup representation as a byte slice.
func MarshalValue(v any) ([]byte, error) {
// MarshalVariable takes any go type and returns a typst markup representation as a byte slice.
func MarshalVariable(v any) ([]byte, error) {
var buf bytes.Buffer
enc := NewValueEncoder(&buf)
enc := NewVariableEncoder(&buf)
if err := enc.Encode(v); err != nil {
return nil, err
}
@ -30,37 +30,37 @@ func MarshalValue(v any) ([]byte, error) {
return buf.Bytes(), nil
}
// ValueMarshaler can be implemented by types to support custom Typst marshaling.
type ValueMarshaler interface {
MarshalTypstValue() ([]byte, error)
// VariableMarshaler can be implemented by types to support custom typst marshaling.
type VariableMarshaler interface {
MarshalTypstVariable() ([]byte, error)
}
type ValueEncoder struct {
type VariableEncoder struct {
indentLevel int
writer io.Writer
}
// NewValueEncoder returns a new encoder that writes into w.
func NewValueEncoder(w io.Writer) *ValueEncoder {
return &ValueEncoder{
// NewVariableEncoder returns a new encoder that writes into w.
func NewVariableEncoder(w io.Writer) *VariableEncoder {
return &VariableEncoder{
writer: w,
}
}
func (e *ValueEncoder) Encode(v any) error {
func (e *VariableEncoder) Encode(v any) error {
return e.marshal(reflect.ValueOf(v))
}
func (e *ValueEncoder) writeString(s string) error {
func (e *VariableEncoder) writeString(s string) error {
return e.writeBytes([]byte(s))
}
func (e *ValueEncoder) writeRune(r rune) error {
func (e *VariableEncoder) writeRune(r rune) error {
return e.writeBytes([]byte{byte(r)})
}
func (e *ValueEncoder) writeStringLiteral(s []byte) error {
func (e *VariableEncoder) writeStringLiteral(s []byte) error {
dst := make([]byte, 0, len(s)+5)
dst = append(dst, '"')
@ -85,7 +85,7 @@ func (e *ValueEncoder) writeStringLiteral(s []byte) error {
return e.writeBytes(dst)
}
func (e *ValueEncoder) writeBytes(b []byte) error {
func (e *VariableEncoder) writeBytes(b []byte) error {
if _, err := e.writer.Write(b); err != nil {
return fmt.Errorf("failed to write into writer: %w", err)
}
@ -93,11 +93,11 @@ func (e *ValueEncoder) writeBytes(b []byte) error {
return nil
}
func (e *ValueEncoder) writeIndentationCharacters() error {
func (e *VariableEncoder) writeIndentationCharacters() error {
return e.writeBytes(slices.Repeat([]byte{' ', ' '}, e.indentLevel))
}
func (e *ValueEncoder) marshal(v reflect.Value) error {
func (e *VariableEncoder) marshal(v reflect.Value) error {
if !v.IsValid() {
return e.writeString("none")
//return fmt.Errorf("invalid reflect.Value %v", v)
@ -113,7 +113,8 @@ func (e *ValueEncoder) marshal(v reflect.Value) error {
return nil
case *time.Time:
if i == nil {
return e.writeString("none")
e.writeString("none")
return nil
}
if err := e.encodeTime(*i); err != nil {
return err
@ -126,7 +127,8 @@ func (e *ValueEncoder) marshal(v reflect.Value) error {
return nil
case *time.Duration:
if i == nil {
return e.writeString("none")
e.writeString("none")
return nil
}
if err := e.encodeDuration(*i); err != nil {
return err
@ -134,18 +136,8 @@ func (e *ValueEncoder) marshal(v reflect.Value) error {
return nil
}
if t.Implements(reflect.TypeFor[ValueMarshaler]()) {
if m, ok := v.Interface().(ValueMarshaler); ok {
bytes, err := m.MarshalTypstValue()
if err != nil {
return fmt.Errorf("error calling MarshalTypstValue for type %s: %w", t.String(), err)
}
return e.writeBytes(bytes)
}
return e.writeString("none")
}
// TODO: Handle images, maybe create a wrapper type that does this
// TODO: Remove this in a future update, it's only here for compatibility reasons
if t.Implements(reflect.TypeFor[VariableMarshaler]()) {
if m, ok := v.Interface().(VariableMarshaler); ok {
bytes, err := m.MarshalTypstVariable()
@ -232,11 +224,11 @@ func (e *ValueEncoder) marshal(v reflect.Value) error {
return err
}
func (e *ValueEncoder) encodeString(v reflect.Value) error {
func (e *VariableEncoder) encodeString(v reflect.Value) error {
return e.writeStringLiteral([]byte(v.String()))
}
func (e *ValueEncoder) encodeStruct(v reflect.Value, t reflect.Type) error {
func (e *VariableEncoder) encodeStruct(v reflect.Value, t reflect.Type) error {
if v.NumField() == 0 {
return e.writeString("()")
}
@ -286,7 +278,7 @@ func (e *ValueEncoder) encodeStruct(v reflect.Value, t reflect.Type) error {
return e.writeRune(')')
}
func (e *ValueEncoder) resolveKeyName(v reflect.Value) (string, error) {
func (e *VariableEncoder) resolveKeyName(v reflect.Value) (string, error) {
// From encoding/json/encode.go.
if v.Kind() == reflect.String {
return v.String(), nil
@ -307,7 +299,7 @@ func (e *ValueEncoder) resolveKeyName(v reflect.Value) (string, error) {
return "", fmt.Errorf("unsupported map key type %q", v.Type().String())
}
func (e *ValueEncoder) encodeMap(v reflect.Value) error {
func (e *VariableEncoder) encodeMap(v reflect.Value) error {
if v.Len() == 0 {
return e.writeString("()")
}
@ -367,12 +359,12 @@ func (e *ValueEncoder) encodeMap(v reflect.Value) error {
return e.writeRune(')')
}
func (e *ValueEncoder) EncodeByteSlice(bb []byte) error {
func (e *VariableEncoder) EncodeByteSlice(bb []byte) error {
if err := e.writeString("bytes(("); err != nil {
return err
}
// TODO: Encode byte slice via base64 or similar and use a Typst package to convert it into the corresponding bytes type
// TODO: Encode byte slice via base64 or similar and use a typst package to convert it into the corresponding bytes type
for i, b := range bb {
if i > 0 {
@ -395,7 +387,7 @@ func (e *ValueEncoder) EncodeByteSlice(bb []byte) error {
return e.writeString("))")
}
func (e *ValueEncoder) encodeSlice(v reflect.Value, t reflect.Type) error {
func (e *VariableEncoder) encodeSlice(v reflect.Value, t reflect.Type) error {
// Special case for byte slices.
if t.Elem().Kind() == reflect.Uint8 {
@ -427,7 +419,7 @@ func (e *ValueEncoder) encodeSlice(v reflect.Value, t reflect.Type) error {
return e.writeRune(')')
}
func (e *ValueEncoder) encodeArray(v reflect.Value) error {
func (e *VariableEncoder) encodeArray(v reflect.Value) error {
if err := e.writeRune('('); err != nil {
return err
}
@ -453,7 +445,7 @@ func (e *ValueEncoder) encodeArray(v reflect.Value) error {
return e.writeRune(')')
}
func (e *ValueEncoder) encodeTime(t time.Time) error {
func (e *VariableEncoder) encodeTime(t time.Time) error {
return e.writeString(fmt.Sprintf("datetime(year: %d, month: %d, day: %d, hour: %d, minute: %d, second: %d)",
t.Year(),
t.Month(),
@ -464,6 +456,6 @@ func (e *ValueEncoder) encodeTime(t time.Time) error {
))
}
func (e *ValueEncoder) encodeDuration(d time.Duration) error {
func (e *VariableEncoder) encodeDuration(d time.Duration) error {
return e.writeString(fmt.Sprintf("duration(seconds: %d)", int(math.Round(d.Seconds()))))
}

View File

@ -1,4 +1,4 @@
// Copyright (c) 2024-2025 David Vogel
// Copyright (c) 2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -18,7 +18,7 @@ import (
"github.com/google/go-cmp/cmp"
)
func TestMarshalValue(t *testing.T) {
func TestMarshalVariable(t *testing.T) {
tests := []struct {
name string
arg any
@ -31,33 +31,33 @@ func TestMarshalValue(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := typst.MarshalValue(tt.arg)
got, err := typst.MarshalVariable(tt.arg)
if (err != nil) != tt.wantErr {
t.Errorf("MarshalValue() error = %v, wantErr %v", err, tt.wantErr)
t.Errorf("MarshalVariable() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("MarshalValue() = %v, want %v", got, tt.want)
t.Errorf("MarshalVariable() = %v, want %v", got, tt.want)
}
})
}
}
type ValueMarshalerType []byte
type VariableMarshalerType []byte
func (v ValueMarshalerType) MarshalTypstValue() ([]byte, error) {
func (v VariableMarshalerType) MarshalTypstVariable() ([]byte, error) {
result := append([]byte{'"'}, v...)
result = append(result, '"')
return result, nil
}
type ValueMarshalerTypePointer []byte
type VariableMarshalerTypePointer []byte
var valueMarshalerTypePointer = ValueMarshalerTypePointer("test")
var valueMarshalerTypePointerNil = ValueMarshalerTypePointer(nil)
var variableMarshalerTypePointer = VariableMarshalerTypePointer("test")
var variableMarshalerTypePointerNil = VariableMarshalerTypePointer(nil)
func (v *ValueMarshalerTypePointer) MarshalTypstValue() ([]byte, error) {
func (v *VariableMarshalerTypePointer) MarshalTypstVariable() ([]byte, error) {
if v != nil {
result := append([]byte{'"'}, *v...)
result = append(result, '"')
@ -87,7 +87,7 @@ func (v *TextMarshalerTypePointer) MarshalText() ([]byte, error) {
return nil, fmt.Errorf("no data")
}
func TestValueEncoder(t *testing.T) {
func TestVariableEncoder(t *testing.T) {
tests := []struct {
name string
@ -156,11 +156,11 @@ func TestValueEncoder(t *testing.T) {
{"byte slice 1", []byte{1}, false, `bytes((1,))`},
{"byte slice empty", []byte{}, false, `bytes(())`},
{"byte slice nil", []byte(nil), false, `bytes(())`},
{"MarshalTypstValue value", ValueMarshalerType("test"), false, `"test"`},
{"MarshalTypstValue value nil", ValueMarshalerType(nil), false, `""`},
{"MarshalTypstValue pointer", &valueMarshalerTypePointer, false, `"test"`},
{"MarshalTypstValue pointer nil", &valueMarshalerTypePointerNil, false, `""`},
{"MarshalTypstValue nil pointer", struct{ A *ValueMarshalerTypePointer }{nil}, true, ``},
{"MarshalTypstVariable value", VariableMarshalerType("test"), false, `"test"`},
{"MarshalTypstVariable value nil", VariableMarshalerType(nil), false, `""`},
{"MarshalTypstVariable pointer", &variableMarshalerTypePointer, false, `"test"`},
{"MarshalTypstVariable pointer nil", &variableMarshalerTypePointerNil, false, `""`},
{"MarshalTypstVariable nil pointer", struct{ A *VariableMarshalerTypePointer }{nil}, true, ``},
{"MarshalText value", TextMarshalerType("test"), false, `"test"`},
{"MarshalText value nil", TextMarshalerType(nil), false, `""`},
{"MarshalText pointer", &textMarshalerTypePointer, false, `"test"`},
@ -179,12 +179,12 @@ func TestValueEncoder(t *testing.T) {
t.Parallel()
var result bytes.Buffer
vEnc := typst.NewValueEncoder(&result)
vEnc := typst.NewVariableEncoder(&result)
err := vEnc.Encode(tt.params)
switch {
case err != nil && !tt.wantErr:
t.Fatalf("Failed to encode Typst values: %v", err)
t.Fatalf("Failed to encode typst variables: %v", err)
case err == nil && tt.wantErr:
t.Fatalf("Expected error, but got none")
}
@ -199,7 +199,7 @@ func TestValueEncoder(t *testing.T) {
input := strings.NewReader("#" + result.String())
var output bytes.Buffer
if err := typstCLI.Compile(input, &output, nil); err != nil {
t.Errorf("Failed to compile generated Typst markup: %v", err)
t.Errorf("Failed to compile generated typst markup: %v", err)
}
}
})