commit c730d437ef30fdca9eae47b1ab358c2829373df6 Author: David Vogel Date: Sun Dec 1 15:03:28 2024 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1a9f7b --- /dev/null +++ b/.gitignore @@ -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) + diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ab86f4f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "cSpell.words": [ + "Dadido", + "Foogaloo", + "typst" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd2a2b7 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# go-typst + +A library to generate documents via [typst]. + +## Installation + +## Runtime requirements + +## Usage + +[typst]: https://typst.app/ \ No newline at end of file diff --git a/cli.go b/cli.go new file mode 100644 index 0000000..9ace026 --- /dev/null +++ b/cli.go @@ -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) +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..c221e3f --- /dev/null +++ b/errors.go @@ -0,0 +1,58 @@ +package typst + +import ( + "log" + "regexp" + "strconv" +) + +var stderrRegex = regexp.MustCompile(`^error: (?.+)\n ┌─ (?.+):(?\d+):(?\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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c4d100a --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/Dadido3/go-typst + +go 1.23.0 + +require github.com/google/go-cmp v0.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5a8d551 --- /dev/null +++ b/go.sum @@ -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= diff --git a/typst_unix.go b/typst_unix.go new file mode 100644 index 0000000..95136d8 --- /dev/null +++ b/typst_unix.go @@ -0,0 +1,5 @@ +//go:build unix + +package typst + +var ExecutablePath = "typst" diff --git a/typst_windows.go b/typst_windows.go new file mode 100644 index 0000000..57654b9 --- /dev/null +++ b/typst_windows.go @@ -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") diff --git a/variable-encoder.go b/variable-encoder.go new file mode 100644 index 0000000..d836e20 --- /dev/null +++ b/variable-encoder.go @@ -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 +} diff --git a/variable-encoder_test.go b/variable-encoder_test.go new file mode 100644 index 0000000..1d9f66e --- /dev/null +++ b/variable-encoder_test.go @@ -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)) + } + }) + } +}