Initial commit

This commit is contained in:
David Vogel 2024-12-01 15:03:28 +01:00
commit c730d437ef
11 changed files with 531 additions and 0 deletions

76
.gitignore vendored Normal file
View File

@ -0,0 +1,76 @@
# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig
# Created by https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,go
# Edit at https://www.toptal.com/developers/gitignore?templates=windows,visualstudiocode,go
### Go ###
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,go
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)

7
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"cSpell.words": [
"Dadido",
"Foogaloo",
"typst"
]
}

11
README.md Normal file
View File

@ -0,0 +1,11 @@
# go-typst
A library to generate documents via [typst].
## Installation
## Runtime requirements
## Usage
[typst]: https://typst.app/

37
cli.go Normal file
View File

@ -0,0 +1,37 @@
package typst
import (
"bytes"
"io"
"os/exec"
)
type CLI struct {
//ExecutablePath string
}
func (c CLI) Render(input io.Reader, output io.Writer) error {
cmd := exec.Command(ExecutablePath, "c", "-", "-")
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:
return NewError(errBuffer.String(), err)
default:
return err
}
}
return nil
}
func (c CLI) RenderWithVariables(input io.Reader, output io.Writer, variables map[string]any) error {
reader := io.MultiReader(nil, input)
return c.Render(reader, output)
}

58
errors.go Normal file
View File

@ -0,0 +1,58 @@
package typst
import (
"log"
"regexp"
"strconv"
)
var stderrRegex = regexp.MustCompile(`^error: (?<error>.+)\n ┌─ (?<path>.+):(?<line>\d+):(?<column>\d+)\n`)
// Error represents a typst error.
type Error struct {
Inner error
Raw string // The raw error string as returned by the executable.
Message string // Error message from typst.
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.
}
// NewError returns a new error based on the stderr from the typst process.
func NewError(stderr string, inner error) *Error {
err := Error{
Raw: stderr,
Inner: inner,
}
parsed := stderrRegex.FindStringSubmatch(stderr)
if i := stderrRegex.SubexpIndex("error"); i > 0 && i < len(parsed) {
err.Message = parsed[i]
}
if i := stderrRegex.SubexpIndex("path"); i > 0 && i < len(parsed) {
err.Path = parsed[i]
}
if i := stderrRegex.SubexpIndex("line"); i > 0 && i < len(parsed) {
line, _ := strconv.ParseInt(parsed[i], 10, 0)
err.Line = int(line)
}
if i := stderrRegex.SubexpIndex("column"); i > 0 && i < len(parsed) {
column, _ := strconv.ParseInt(parsed[i], 10, 0)
err.Column = int(column)
}
log.Printf("%#v", err)
return &err
}
func (e *Error) Error() string {
return e.Raw
}
func (e *Error) Unwrap() error {
return e.Inner
}

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module github.com/Dadido3/go-typst
go 1.23.0
require github.com/google/go-cmp v0.6.0

2
go.sum Normal file
View File

@ -0,0 +1,2 @@
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=

5
typst_unix.go Normal file
View File

@ -0,0 +1,5 @@
//go:build unix
package typst
var ExecutablePath = "typst"

8
typst_windows.go Normal file
View File

@ -0,0 +1,8 @@
//go:build windows
package typst
import "path/filepath"
// We assume the executable is in the current working directory.
var ExecutablePath = "." + string(filepath.Separator) + filepath.Join("typst.exe")

255
variable-encoder.go Normal file
View File

@ -0,0 +1,255 @@
package typst
import (
"encoding"
"fmt"
"io"
"reflect"
"slices"
"strconv"
"unicode/utf8"
)
type VariableMarshaler interface {
MarshalTypstVariable() ([]byte, error)
}
type VariableEncoder struct {
indentLevel int
writer io.Writer
}
func NewVariableEncoder(w io.Writer) *VariableEncoder {
return &VariableEncoder{
writer: w,
}
}
func (e *VariableEncoder) Encode(v any) error {
return e.marshal(reflect.ValueOf(v))
}
func (e *VariableEncoder) WriteString(s string) {
e.WriteBytes([]byte(s))
}
func (e *VariableEncoder) WriteBytes(b []byte) {
e.writer.Write(b)
}
func (e *VariableEncoder) WriteIndentationCharacters() {
e.WriteBytes(slices.Repeat([]byte{' ', ' '}, e.indentLevel))
}
func (e *VariableEncoder) marshal(v reflect.Value) error {
if !v.IsValid() {
return fmt.Errorf("invalid reflect.Value %v", v)
}
t := v.Type()
if (t.Kind() == reflect.Pointer || t.Kind() == reflect.Interface) && v.IsNil() {
e.WriteString("none")
return nil
}
if t.Implements(reflect.TypeFor[VariableMarshaler]()) {
if m, ok := v.Interface().(VariableMarshaler); ok {
bytes, err := m.MarshalTypstVariable()
e.WriteBytes(bytes)
return err
}
e.WriteString("none")
return nil
}
if t.Implements(reflect.TypeFor[encoding.TextMarshaler]()) {
if m, ok := v.Interface().(encoding.TextMarshaler); ok {
bytes, err := m.MarshalText()
e.WriteBytes(bytes)
return err
}
e.WriteString("none")
return nil
}
// TODO: Handle images
// TODO: Handle decimals
// TODO: Handle Time
// TODO: Handle durations
switch t.Kind() {
case reflect.Bool:
e.WriteString(strconv.FormatBool(v.Bool()))
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
e.WriteString(strconv.FormatInt(v.Int(), 10))
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
e.WriteString(strconv.FormatUint(v.Uint(), 10))
case reflect.Float32:
e.WriteString(strconv.FormatFloat(v.Float(), 'e', -1, 32))
case reflect.Float64:
e.WriteString(strconv.FormatFloat(v.Float(), 'e', -1, 64))
case reflect.String:
return e.encodeString(v)
case reflect.Interface, reflect.Pointer:
return e.marshal(v.Elem())
case reflect.Struct:
return e.encodeStruct(v, t)
case reflect.Slice:
return e.encodeSlice(v)
case reflect.Array:
return e.encodeArray(v)
default:
return fmt.Errorf("unsupported type %q", t.String())
}
return nil
}
func (e *VariableEncoder) encodeString(v reflect.Value) error {
src := v.String()
dst := make([]byte, 0, len(src)+2)
dst = append(dst, '"')
for _, r := range src {
switch r {
case '\\', '"':
dst = append(dst, '\\')
dst = utf8.AppendRune(dst, r)
case '\n':
dst = append(dst, '\\', 'n')
case '\r':
dst = append(dst, '\\', 'r')
case '\t':
dst = append(dst, '\\', 't')
}
dst = utf8.AppendRune(dst, r)
}
dst = append(dst, '"')
e.WriteBytes(dst)
return nil
}
func (e *VariableEncoder) encodeStruct(v reflect.Value, t reflect.Type) error {
e.WriteString("(\n")
e.indentLevel++
for i := 0; i < t.NumField(); i++ {
ft, fv := t.Field(i), v.Field(i)
if ft.PkgPath == "" { // Ignore unexported fields.
e.WriteIndentationCharacters()
e.WriteString(ft.Name + ": ")
if err := e.marshal(fv); err != nil {
return fmt.Errorf("failed to encode value of struct field %q", ft.Name)
}
e.WriteString(",\n")
}
}
e.indentLevel--
e.WriteIndentationCharacters()
e.WriteString(")")
return nil
}
func (e *VariableEncoder) resolveKeyName(v reflect.Value) (string, error) {
// From encoding/json/encode.go.
if v.Kind() == reflect.String {
return v.String(), nil
}
if tm, ok := v.Interface().(encoding.TextMarshaler); ok {
if v.Kind() == reflect.Pointer && v.IsNil() {
return "", nil
}
buf, err := tm.MarshalText()
return string(buf), err
}
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(v.Int(), 10), nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return strconv.FormatUint(v.Uint(), 10), nil
}
return "", fmt.Errorf("unsupported map key type %q", v.Type().String())
}
func (e *VariableEncoder) encodeMap(v reflect.Value) error {
e.WriteString("(\n")
e.indentLevel++
mi := v.MapRange()
for mi.Next() {
mk, mv := mi.Key(), mi.Value()
key, err := e.resolveKeyName(mk)
if err != nil {
return err
}
e.WriteIndentationCharacters()
e.WriteString(key + ": ")
if err := e.marshal(mv); err != nil {
return fmt.Errorf("failed to encode map field %q", key)
}
e.WriteString(",\n")
}
e.indentLevel--
e.WriteIndentationCharacters()
e.WriteString(")")
return nil
}
func (e *VariableEncoder) encodeSlice(v reflect.Value) error {
e.WriteString("(")
// TODO: Output byte slice as a base64 and use the typst based package to convert that into typst Bytes.
n := v.Len()
for i := 0; i < n; i++ {
if i > 0 {
e.WriteString(", ")
}
if err := e.marshal(v.Index(i)); err != nil {
return fmt.Errorf("failed to encode slice element %d of %d", i+1, n+1)
}
}
e.WriteString(")")
return nil
}
func (e *VariableEncoder) encodeArray(v reflect.Value) error {
e.WriteString("(")
n := v.Len()
for i := 0; i < n; i++ {
if i > 0 {
e.WriteString(", ")
}
if err := e.marshal(v.Index(i)); err != nil {
return fmt.Errorf("failed to encode array element %d of %d", i+1, n+1)
}
}
e.WriteString(")")
return nil
}

67
variable-encoder_test.go Normal file
View File

@ -0,0 +1,67 @@
package typst
import (
"bytes"
"math"
"testing"
"github.com/google/go-cmp/cmp"
)
func TestVariableEncoder(t *testing.T) {
tests := []struct {
name string
params any
wantErr bool
want string
}{
{"nil", nil, false, "none"},
{"bool false", false, false, "false"},
{"bool true", true, false, "true"},
{"int", int(-123), false, "-123"},
{"int8", int8(-123), false, "-123"},
{"int16", int16(-123), false, "-123"},
{"int32", int32(-123), false, "-123"},
{"int64", int64(-123), false, "-123"},
{"uint", uint(123), false, "123"},
{"uint8", uint8(123), false, "123"},
{"uint16", uint16(123), false, "123"},
{"uint32", uint32(123), false, "123"},
{"uint64", uint64(123), false, "123"},
{"float32", float32(1), false, "1e+00"},
{"float64", float64(1), false, "1e+00"},
{"float64 nan", float64(math.NaN()), false, "float.nan"},
{"float64 +inf", float64(math.Inf(1)), false, "float.inf"},
{"float64 -inf", float64(math.Inf(-1)), false, "-float.inf"},
{"string", "Hey!", false, `"Hey!"`},
{"struct", struct {
Foo string
Bar int
}{"Hey!", 12345}, false, "(\n Foo: \"Hey!\",\n Bar: 12345,\n)"},
{"map string string", map[string]string{"Foo": "Bar", "Foo2": "Electric Foogaloo"}, false, "(\n Foo: \"Bar\",\n Foo2: \"Electric Foogaloo\",\n)"},
{"map string string nil", map[string]string(nil), false, "()"},
{"string array", [5]string{"Foo", "Bar"}, false, `("Foo", "Bar", "", "", "")`},
{"string slice", []string{"Foo", "Bar"}, false, `("Foo", "Bar")`},
{"string slice pointer", &[]string{"Foo", "Bar"}, false, `("Foo", "Bar")`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := bytes.Buffer{}
vEnc := NewVariableEncoder(&result)
err := vEnc.Encode(tt.params)
switch {
case err != nil && !tt.wantErr:
t.Fatalf("Failed to encode typst variables: %v", err)
case err == nil && tt.wantErr:
t.Fatalf("Expected error, but got none")
}
if !cmp.Equal(result.String(), tt.want) {
t.Errorf("Got unexpected result: %s", cmp.Diff(result.String(), tt.want))
}
})
}
}