Compare commits

..

18 Commits
v0.2.0 ... main

Author SHA1 Message Date
David Vogel
bdbda5f874
Merge pull request #4 from Dadido3/typst-0.13.1
Some checks failed
golangci-lint / lint (push) Successful in 2m48s
test / test (1.23.x, 0.12.0) (push) Failing after 7s
test / test (1.23.x, 0.13.0) (push) Failing after 4s
test / test (1.23.x, 0.13.1) (push) Failing after 4s
Add Typst 0.13.1 to Compatibility list
2025-03-07 14:28:14 +01:00
e1879e4d36 Add Typst 0.13.1 to Compatibility list 2025-03-07 13:56:11 +01:00
ab3cee4666 Correct capitalization of Typst and Go 2025-02-27 18:23:30 +01:00
c3876b340b Rename Variable* to Value*
- Rename MarshalVariable to MarshalValue
- Rename NewVariableEncoder to NewValueEncoder
- Rename VariableEncoder to ValueEncoder
- Rename VariableMarshaler to ValueMarshaler
- Rename MarshalTypstVariable to MarshalTypstValue

There are now wrappers which ensure compatibility with code that still uses some of the old functions/types.

- Improve image_test.go by adding an assertion
- Rename all occurrences of Variable to Value
- Remove "TODO: Handle images..." as that's already working with the image wrapper
- Update README.md
2025-02-27 18:07:46 +01:00
7c87e3fee8 Fix missing error handling in VariableEncoder.marshal 2025-02-27 17:35:43 +01:00
4ee94ec233 Add golangci-lint to GitHub actions 2025-02-27 17:29:42 +01:00
6ecfb61e5b Make TestInjectValues deterministic 2025-02-27 16:28:19 +01:00
2a1d2990e7 Add simple example 2025-02-27 16:08:02 +01:00
9268a6691e Rework passing-values example
This changes the example to use the template pattern; we now have a single Typst file containing a template function.
Instead of loading and rendering the template, we will now generate temporary Typst markup that will import the template function and call it with custom data.

This also means that it's pretty easy to test, preview and debug the template.typ outside of go-typst.
All that is needed is another Typst file which will call the template with mock data.

Also, we now use Compile instead of CompileWithVariables and inject encoded Go values into our temporary markup.
2025-02-27 16:07:34 +01:00
69bd0ed5b5 Add InjectValues function
This will make CompileWithVariables obsolete, as you can use InjectValues in combination with the normal Compile instead.

This also introduces a breaking change with CompileWithVariables, as now invalid identifiers will return an error.
2025-02-27 15:17:39 +01:00
112898d1d7 Rename example from passing-objects to passing-values 2025-02-27 10:35:01 +01:00
0668d15070 Add PDFStandard type and constants
Also rename the PDFStandard field in CLIOptions to PDFStandards, and change its type from string to []PDFStandard.

This is a breaking change.
2025-02-26 17:27:24 +01:00
c2d62b2373 Fix Custom command line options 2025-02-26 17:01:44 +01:00
David Vogel
d7fc966a42
Merge pull request #3 from faide/fontPath
Font path add unit test
2025-02-26 16:47:19 +01:00
Florent Aide
2b21d37fce Merge remote-tracking branch 'origin' into fontPath 2025-02-26 16:26:32 +01:00
Florent Aide
fb12dfb9fa add unit test for the font-path option 2025-02-26 16:20:36 +01:00
David Vogel
81bf84c51b
Merge pull request #2 from faide/fontPath
Add support for font-path option
2025-02-26 16:08:16 +01:00
Florent Aide
0e2bdda951 Add support for font-path option 2025-02-26 15:35:15 +01:00
30 changed files with 444 additions and 140 deletions

29
.github/workflows/lint.yml vendored Normal file
View File

@ -0,0 +1,29 @@
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: strategy:
matrix: matrix:
go-version: ['1.23.x'] go-version: ['1.23.x']
typst-version: ['0.12.0', '0.13.0'] typst-version: ['0.12.0', '0.13.0', '0.13.1']
steps: steps:
- name: Install typst-cli ${{ matrix.typst-version }} from crates.io - name: Install typst-cli ${{ matrix.typst-version }} from crates.io

View File

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

View File

@ -12,6 +12,7 @@ Supported Typst versions are tested by unit tests to ensure compatibility.
- Typst 0.12.0 - Typst 0.12.0
- Typst 0.13.0 - Typst 0.13.0
- Typst 0.13.1
While breaking changes may occur, i aim to minimize disruptions. While breaking changes may occur, i aim to minimize disruptions.
Use at your own discretion for production systems. Use at your own discretion for production systems.
@ -20,7 +21,7 @@ Use at your own discretion for production systems.
- PDF, SVG and PNG generation. - PDF, SVG and PNG generation.
- All Typst parameters are discoverable and documented in [cli-options.go](cli-options.go). - All Typst parameters are discoverable and documented in [cli-options.go](cli-options.go).
- 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. - 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.
- Errors from Typst CLI are returned as structured Go error objects with detailed information, such as line numbers and file paths. - 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. - Uses stdio; No temporary files will be created.
- Good unit test coverage. - Good unit test coverage.

View File

@ -1,4 +1,4 @@
// Copyright (c) 2024 David Vogel // Copyright (c) 2024-2025 David Vogel
// //
// This software is released under the MIT License. // This software is released under the MIT License.
// https://opensource.org/licenses/MIT // https://opensource.org/licenses/MIT
@ -22,6 +22,14 @@ const (
OutputFormatHTML OutputFormat = "html" // this format is only available since 0.13.0 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)
)
type CLIOptions struct { type CLIOptions struct {
Root string // Configures the project root (for absolute paths). Root string // Configures the project root (for absolute paths).
Input map[string]string // String key-value pairs visible through `sys.inputs`. Input map[string]string // String key-value pairs visible through `sys.inputs`.
@ -42,13 +50,10 @@ type CLIOptions struct {
Format OutputFormat // The format of the output file, inferred from the extension by default. 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. PPI int // The PPI (pixels per inch) to use for PNG export. Defaults to 144.
// One (or multiple comma-separated) PDF standards that Typst will enforce conformance with. // One (or multiple) PDF standards that Typst will enforce conformance with.
// //
// Possible values: // See typst.PDFStandard for possible values.
// PDFStandards []PDFStandard
// - 1.7: PDF 1.7
// - a-2b: PDF/A-2b
PDFStandard string
Custom []string // Custom command line options go here. Custom []string // Custom command line options go here.
} }
@ -71,6 +76,7 @@ func (c *CLIOptions) Args() (result []string) {
} }
paths += path paths += path
} }
result = append(result, "--font-path", paths)
} }
if c.IgnoreSystemFonts { if c.IgnoreSystemFonts {
@ -111,9 +117,18 @@ func (c *CLIOptions) Args() (result []string) {
result = append(result, "--ppi", strconv.FormatInt(int64(c.PPI), 10)) result = append(result, "--ppi", strconv.FormatInt(int64(c.PPI), 10))
} }
if c.PDFStandard != "" { if len(c.PDFStandards) > 0 {
result = append(result, "--pdf-standard", c.PDFStandard) var standards string
for i, standard := range c.PDFStandards {
if i > 0 {
standards += ","
}
standards += string(standard)
}
result = append(result, "--pdf-standard", standards)
} }
result = append(result, c.Custom...)
return return
} }

24
cli-options_test.go Normal file
View File

@ -0,0 +1,24 @@
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 David Vogel // Copyright (c) 2024-2025 David Vogel
// //
// This software is released under the MIT License. // This software is released under the MIT License.
// https://opensource.org/licenses/MIT // https://opensource.org/licenses/MIT
@ -15,12 +15,12 @@ import (
// TODO: Add docker support to CLI, by calling docker run instead // TODO: Add docker support to CLI, by calling docker run instead
type CLI struct { 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) { func (c CLI) VersionString() (string, error) {
// Get path of executable. // Get path of executable.
execPath := ExecutablePath execPath := ExecutablePath
@ -46,7 +46,7 @@ func (c CLI) VersionString() (string, error) {
return output.String(), nil 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. // The options parameter is optional.
func (c CLI) Compile(input io.Reader, output io.Writer, options *CLIOptions) error { func (c CLI) Compile(input io.Reader, output io.Writer, options *CLIOptions) error {
args := []string{"c"} args := []string{"c"}
@ -80,22 +80,17 @@ func (c CLI) Compile(input io.Reader, output io.Writer, options *CLIOptions) err
return nil return nil
} }
// Compile takes a typst document from input, and renders it into the output writer. // CompileWithVariables takes a Typst document from input, and renders it into the output writer.
// The options parameter is optional. // The options parameter is optional.
// //
// Additionally this will inject the given map of variables into the global scope of the typst document. // 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.
func (c CLI) CompileWithVariables(input io.Reader, output io.Writer, options *CLIOptions, variables map[string]any) error { func (c CLI) CompileWithVariables(input io.Reader, output io.Writer, options *CLIOptions, variables map[string]any) error {
varBuffer := bytes.Buffer{} varBuffer := bytes.Buffer{}
// TODO: Use io.pipe instead of a bytes.Buffer if err := InjectValues(&varBuffer, variables); err != nil {
return fmt.Errorf("failed to inject values into Typst markup: %w", err)
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) reader := io.MultiReader(&varBuffer, input)

View File

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

View File

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

View File

@ -14,13 +14,13 @@ import (
// ErrorDetails contains the details of a typst.Error. // ErrorDetails contains the details of a typst.Error.
type ErrorDetails struct { type ErrorDetails struct {
Message string // The parsed error message. 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. 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. 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. Column int // Column of the error. Zero value means that there is no further information.
} }
// Error represents a typst error. // Error represents an error as returned by Typst.
// This can contain multiple sub-errors or sub-warnings. // This can contain multiple sub-errors or sub-warnings which are listed in the field Details.
type Error struct { type Error struct {
Inner error Inner error

Binary file not shown.

View File

@ -1,33 +0,0 @@
#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

@ -0,0 +1,13 @@
# 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,6 +1,7 @@
package main package main
import ( import (
"bytes"
"log" "log"
"os" "os"
"time" "time"
@ -8,7 +9,7 @@ import (
"github.com/Dadido3/go-typst" "github.com/Dadido3/go-typst"
) )
// DataEntry contains fake data to be passed to typst. // DataEntry contains data to be passed to Typst.
type DataEntry struct { type DataEntry struct {
Name string Name string
Size struct{ X, Y, Z float64 } Size struct{ X, Y, Z float64 }
@ -25,21 +26,28 @@ var TestData = []DataEntry{
} }
func main() { func main() {
typstCLI := typst.CLI{} var markup bytes.Buffer
r, err := os.Open("template.typ") // Inject Go values as Typst markup.
if err != nil { 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 open template file for reading: %v.", err) log.Panicf("Failed to inject values into Typst markup: %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") f, err := os.Create("output.pdf")
if err != nil { if err != nil {
log.Panicf("Failed to create output file: %v.", err) log.Panicf("Failed to create output file: %v.", err)
} }
defer f.Close() defer f.Close()
if err := typstCLI.CompileWithVariables(r, f, nil, map[string]any{"Data": TestData}); err != nil { typstCLI := typst.CLI{}
if err := typstCLI.Compile(&markup, f, nil); err != nil {
log.Panicf("Failed to compile document: %v.", err) log.Panicf("Failed to compile document: %v.", err)
} }
} }

View File

@ -0,0 +1,16 @@
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,5 +1,12 @@
#let Data = ( // Prepare data that will be used as preview.
#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: (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: (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)), (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,36 @@
#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],)})],
)
}
)
}

12
examples/simple/README.md Normal file
View File

@ -0,0 +1,12 @@
# 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.

37
examples/simple/main.go Normal file
View File

@ -0,0 +1,37 @@
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

@ -0,0 +1,16 @@
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()
}

BIN
examples/simple/output.pdf Normal file

Binary file not shown.

View File

@ -1,4 +1,4 @@
// Copyright (c) 2024 David Vogel // Copyright (c) 2024-2025 David Vogel
// //
// This software is released under the MIT License. // This software is released under the MIT License.
// https://opensource.org/licenses/MIT // https://opensource.org/licenses/MIT
@ -11,7 +11,7 @@ import (
"github.com/smasher164/xid" "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. // 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. // See https://github.com/typst/typst/blob/76c24ee6e35715cd14bb892d7b6b8d775c680bf7/crates/typst-syntax/src/lexer.rs#L932 for details.

View File

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

View File

@ -33,18 +33,26 @@ func (p *testImage) Opaque() bool {
} }
func TestImage(t *testing.T) { func TestImage(t *testing.T) {
img := &testImage{image.Rect(0, 0, 255, 255)} img := &testImage{image.Rect(0, 0, 256, 256)}
// Wrap image. // Wrap image.
typstImage := typst.Image{img} typstImage := typst.Image{img}
cli := typst.CLI{} cli := typst.CLI{}
r := bytes.NewBufferString(`= Image test var r bytes.Buffer
#TestImage`) if err := typst.InjectValues(&r, map[string]any{"TestImage": typstImage}); err != nil {
t.Fatalf("Failed to inject values into Typst markup: %v.", err)
}
if err := cli.CompileWithVariables(r, io.Discard, nil, map[string]any{"TestImage": typstImage}); err != nil { 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 {
t.Fatalf("Failed to compile document: %v.", err) t.Fatalf("Failed to compile document: %v.", err)
} }
} }

47
util.go Normal file
View File

@ -0,0 +1,47 @@
// 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
}

36
util_test.go Normal file
View File

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

View File

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

View File

@ -0,0 +1,21 @@
// 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)
}