diff --git a/README.md b/README.md index cd2a2b7..d13dce9 100644 --- a/README.md +++ b/README.md @@ -8,4 +8,4 @@ A library to generate documents via [typst]. ## Usage -[typst]: https://typst.app/ \ No newline at end of file +[typst]: https://typst.app/ diff --git a/cli-options.go b/cli-options.go new file mode 100644 index 0000000..6b342e7 --- /dev/null +++ b/cli-options.go @@ -0,0 +1,107 @@ +package typst + +import ( + "os" + "strconv" + "time" +) + +type OutputFormat string + +const ( + OutputFormatAuto OutputFormat = "" + + OutputFormatPDF OutputFormat = "pdf" + OutputFormatPNG OutputFormat = "png" + OutputFormatSVG OutputFormat = "svg" +) + +type CLIOptions struct { + Root string // Configures the project root (for absolute paths). + Input map[string]string // String key-value pairs visible through `sys.inputs`. + FontPaths []string // Adds additional directories that are recursively searched for fonts. + IgnoreSystemFonts bool // Ensures system fonts won't be searched, unless explicitly included via FontPaths. + CreationTime time.Time // The document's creation date. For more information, see https://reproducible-builds.org/specs/source-date-epoch/. + PackagePath string // Custom path to local packages, defaults to system-dependent location. + PackageCachePath string // Custom path to package cache, defaults to system-dependent location. + Jobs int // Number of parallel jobs spawned during compilation, defaults to number of CPUs. Setting it to 1 disables parallelism. + + // Which pages to export. When unspecified, all document pages are exported. + // + // Pages to export are separated by commas, and can be either simple page numbers (e.g. '2,5' to export only pages 2 and 5) or page ranges (e.g. '2,3-6,8-' to export page 2, pages 3 to 6 (inclusive), page 8 and any pages after it). + // + // Page numbers are one-indexed and correspond to real page numbers in the document (therefore not being affected by the document's page counter). + Pages string + + 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 comma-separated) PDF standards that Typst will enforce conformance with. + // + // Possible values: + // + // - 1.7: PDF 1.7 + // - a-2b: PDF/A-2b + PDFStandard string + + Custom []string // Custom command line options go here. +} + +// Args returns a list of CLI arguments that should be passed to the executable. +func (c *CLIOptions) Args() (result []string) { + if c.Root != "" { + result = append(result, "--root", c.Root) + } + + for key, value := range c.Input { + result = append(result, "--input", key+"="+value) + } + + if len(c.FontPaths) > 0 { + var paths string + for i, path := range c.FontPaths { + if i > 0 { + paths += string(os.PathListSeparator) + } + paths += path + } + } + + if c.IgnoreSystemFonts { + result = append(result, "--ignore-system-fonts") + } + + if !c.CreationTime.IsZero() { + result = append(result, "--creation-timestamp", strconv.FormatInt(c.CreationTime.Unix(), 10)) + } + + if c.PackagePath != "" { + result = append(result, "--package-path", c.PackagePath) + } + + if c.PackageCachePath != "" { + result = append(result, "--package-cache-path", c.PackageCachePath) + } + + if c.Jobs > 0 { + result = append(result, "-j", strconv.FormatInt(int64(c.Jobs), 10)) + } + + if c.Pages != "" { + result = append(result, "--pages", c.Pages) + } + + if c.Format != OutputFormatAuto { + result = append(result, "-f", string(c.Format)) + } + + if c.PPI > 0 { + result = append(result, "--ppi", strconv.FormatInt(int64(c.PPI), 10)) + } + + if c.PDFStandard != "" { + result = append(result, "--pdf-standard", c.PDFStandard) + } + + return +} diff --git a/cli.go b/cli.go index 9ace026..659f658 100644 --- a/cli.go +++ b/cli.go @@ -2,16 +2,33 @@ package typst import ( "bytes" + "fmt" "io" "os/exec" ) type CLI struct { - //ExecutablePath string + ExecutablePath string // The typst executable path can be overridden here. Otherwise the default path will be used. } -func (c CLI) Render(input io.Reader, output io.Writer) error { - cmd := exec.Command(ExecutablePath, "c", "-", "-") +// TODO: Method for querying typst version + +// Render takes a typst document from input, and renders it into the output writer. +// The options parameter is optional. +func (c CLI) Render(input io.Reader, output io.Writer, options *CLIOptions) error { + args := []string{"c"} + if options != nil { + args = append(args, options.Args()...) + } + args = append(args, "--diagnostic-format", "short", "-", "-") + + // Get path of executable. + execPath := ExecutablePath + if c.ExecutablePath != "" { + execPath = c.ExecutablePath + } + + cmd := exec.Command(execPath, args...) cmd.Stdin = input cmd.Stdout = output @@ -21,7 +38,7 @@ func (c CLI) Render(input io.Reader, output io.Writer) error { if err := cmd.Run(); err != nil { switch err := err.(type) { case *exec.ExitError: - return NewError(errBuffer.String(), err) + return ParseStderr(errBuffer.String(), err) default: return err } @@ -30,8 +47,25 @@ func (c CLI) Render(input io.Reader, output io.Writer) error { return nil } -func (c CLI) RenderWithVariables(input io.Reader, output io.Writer, variables map[string]any) error { - reader := io.MultiReader(nil, input) +// Render 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. +func (c CLI) RenderWithVariables(input io.Reader, output io.Writer, options *CLIOptions, variables map[string]any) error { + varBuffer := bytes.Buffer{} - return c.Render(reader, output) + // 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) + + return c.Render(reader, output, options) } diff --git a/cli_test.go b/cli_test.go new file mode 100644 index 0000000..a202dcb --- /dev/null +++ b/cli_test.go @@ -0,0 +1,48 @@ +package typst_test + +import ( + "bytes" + "image" + _ "image/png" + "strconv" + "testing" + + "github.com/Dadido3/go-typst" +) + +// Test basic render functionality. +func TestCLI_Render(t *testing.T) { + const inches = 1 + const ppi = 144 + + cli := typst.CLI{} + + r := bytes.NewBufferString(`#set page(width: ` + strconv.FormatInt(inches, 10) + `in, height: ` + strconv.FormatInt(inches, 10) + `in, margin: (x: 1mm, y: 1mm)) += Test + +#lorem(5)`) + + opts := typst.CLIOptions{ + Format: typst.OutputFormatPNG, + PPI: ppi, + } + + var w bytes.Buffer + if err := cli.Render(r, &w, &opts); err != nil { + t.Fatalf("Failed to render document: %v.", err) + } + + imgConf, imgType, err := image.DecodeConfig(&w) + if err != nil { + t.Fatalf("Failed to decode image: %v.", err) + } + if imgType != "png" { + t.Fatalf("Resulting image is of type %q, expected %q.", imgType, "png") + } + if imgConf.Width != inches*ppi { + t.Fatalf("Resulting image width is %d, expected %d.", imgConf.Width, inches*ppi) + } + if imgConf.Height != inches*ppi { + t.Fatalf("Resulting image height is %d, expected %d.", imgConf.Height, inches*ppi) + } +} diff --git a/errors.go b/errors.go index fd29902..c27b6a0 100644 --- a/errors.go +++ b/errors.go @@ -5,51 +5,92 @@ import ( "strconv" ) -var stderrRegex = regexp.MustCompile(`^error: (?.+)\n ┌─ (?.+):(?\d+):(?\d+)\n`) - // Error represents a generic 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) - } - - return &err + Raw string // The raw output from stderr. + Message string // The parsed error message. } func (e *Error) Error() string { + if e.Message != "" { + return e.Message + } + return e.Raw } func (e *Error) Unwrap() error { return e.Inner } + +// ErrorWithPath represents a typst error that also contains information about its origin (filepath, line and column). +type ErrorWithPath 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. +} + +func (e *ErrorWithPath) Error() string { + return e.Raw +} + +func (e *ErrorWithPath) Unwrap() error { + return e.Inner +} + +var stderrRegex = regexp.MustCompile(`^error: (?.+)\n`) +var stderrWithPathRegex = regexp.MustCompile(`^(?.+):(?\d+):(?\d+): error: (?.+)\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, + } +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..2641e87 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,77 @@ +package typst_test + +import ( + "bytes" + "errors" + "testing" + + "github.com/Dadido3/go-typst" +) + +func TestErrors0(t *testing.T) { + cli := typst.CLI{} + + r := bytes.NewBufferString(`This is a test!`) + + var w bytes.Buffer + if err := cli.Render(r, &w, nil); err != nil { + t.Fatalf("Failed to render document: %v", err) + } +} + +func TestErrors1(t *testing.T) { + cli := typst.CLI{} + + r := bytes.NewBufferString(`This is a test! + +#assert(1 < 1, message: "Test")`) + + var w bytes.Buffer + if err := cli.Render(r, &w, nil); err == nil { + t.Fatalf("Expected error, but got nil") + } else { + 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) + } + /*if errWithPath.Path != "" { + t.Errorf("Expected error to point to path %q, got path %q", "", errWithPath.Path) + }*/ + if errWithPath.Line != 3 { + t.Errorf("Expected error to point at line %d, got line %d", 3, errWithPath.Line) + } + 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", errWithPath, err, err) + } + } +} + +func TestErrors2(t *testing.T) { + cli := typst.CLI{} + + opts := typst.CLIOptions{ + Pages: "a", + } + + r := bytes.NewBufferString(`This is a test!`) + + var w bytes.Buffer + if err := cli.Render(r, &w, &opts); err == nil { + t.Fatalf("Expected error, but got nil") + } else { + var errTypst *typst.Error + if errors.As(err, &errTypst) { + // Don't check the specific error message, as that may change over time. + // The expected message should be similar to: invalid value 'a' for '--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) + } + } +} diff --git a/go.mod b/go.mod index c4d100a..c5288c9 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,9 @@ module github.com/Dadido3/go-typst go 1.23.0 -require github.com/google/go-cmp v0.6.0 +require ( + github.com/google/go-cmp v0.6.0 + github.com/smasher164/xid v0.1.2 +) + +require golang.org/x/text v0.3.3 // indirect diff --git a/go.sum b/go.sum index 5a8d551..8ebb729 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,7 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/smasher164/xid v0.1.2 h1:erplXSdBRIIw+MrwjJ/m8sLN2XY16UGzpTA0E2Ru6HA= +github.com/smasher164/xid v0.1.2/go.mod h1:tgivm8CQl19fH1c5y+8F4mA+qY6n2i6qDRBlY/6nm+I= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/identifier.go b/identifier.go new file mode 100644 index 0000000..875c0be --- /dev/null +++ b/identifier.go @@ -0,0 +1,75 @@ +package typst + +import ( + "unicode/utf8" + + "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. +// 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. +func CleanIdentifier(input string) string { + dst := make([]byte, 0, len(input)) + + for i, r := range input { + if i == 0 { + // Handle first rune of input. + switch { + case xid.Start(r), r == '_': + dst = utf8.AppendRune(dst, r) + default: + dst = append(dst, '_') + } + } else { + // Handle all other runes of input. + switch { + case xid.Continue(r), r == '_', r == '-': + dst = utf8.AppendRune(dst, r) + default: + dst = append(dst, '_') + } + } + } + + // Don't allow empty identifiers. + // We can't use a single placeholder ("_"), as it will cause errors when used in dictionaries. + result := string(dst) + if result == "_" || result == "" { + return "_invalid_" + } + + return string(dst) +} + +// IsIdentifier will return whether input is a valid typst identifier. +// +// See https://github.com/typst/typst/blob/76c24ee6e35715cd14bb892d7b6b8d775c680bf7/crates/typst-syntax/src/lexer.rs#L932 for details. +func IsIdentifier(input string) bool { + // Identifiers can't be empty. + // We will also disallow a single underscore. + if input == "" || input == "_" { + return false + } + + for i, r := range input { + if i == 0 { + // Handle first rune of input. + switch { + case xid.Start(r), r == '_': + default: + return false + } + } else { + // Handle all other runes of input. + switch { + case xid.Continue(r), r == '_', r == '-': + default: + return false + } + } + } + + return true +} diff --git a/identifier_test.go b/identifier_test.go new file mode 100644 index 0000000..b7a664b --- /dev/null +++ b/identifier_test.go @@ -0,0 +1,49 @@ +package typst + +import ( + "testing" +) + +func TestCleanIdentifier(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", "_invalid_"}, + {"_", "_invalid_"}, + {"_-", "_-"}, + {"-foo-", "_foo-"}, + {"foo", "foo"}, + {"😊", "_invalid_"}, + {"foo😊", "foo_"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := CleanIdentifier(tt.input); got != tt.want { + t.Errorf("IsIdentifier() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsIdentifier(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"", false}, + {"_", false}, + {"_-", true}, + {"-foo", false}, + {"foo", true}, + {"😊", false}, + {"_😊", false}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := IsIdentifier(tt.input); got != tt.want { + t.Errorf("IsIdentifier() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/typst_unix.go b/typst_unix.go index 95136d8..0e0a81c 100644 --- a/typst_unix.go +++ b/typst_unix.go @@ -2,4 +2,5 @@ package typst +// The path to the typst executable. var ExecutablePath = "typst" diff --git a/typst_windows.go b/typst_windows.go index 57654b9..d41f48a 100644 --- a/typst_windows.go +++ b/typst_windows.go @@ -4,5 +4,6 @@ package typst import "path/filepath" +// 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") diff --git a/variable-encoder.go b/variable-encoder.go index 325e85f..33ed35e 100644 --- a/variable-encoder.go +++ b/variable-encoder.go @@ -8,9 +8,10 @@ import ( "reflect" "slices" "strconv" - "unicode/utf8" + "time" ) +// VariableMarshaler can be implemented by types to support custom typst marshaling. type VariableMarshaler interface { MarshalTypstVariable() ([]byte, error) } @@ -21,6 +22,7 @@ type VariableEncoder struct { writer io.Writer } +// NewVariableEncoder returns a new encoder that writes into w. func NewVariableEncoder(w io.Writer) *VariableEncoder { return &VariableEncoder{ writer: w, @@ -31,156 +33,193 @@ 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) writeString(s string) error { + return 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() { - e.WriteString("none") - return nil - //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, reflect.Float64: - f := v.Float() - switch { - case math.IsNaN(f): - e.WriteString("float.nan") - case math.IsInf(f, 1): - e.WriteString("float.inf") - case math.IsInf(f, -1): - e.WriteString("-float.inf") - default: - e.WriteString(strconv.FormatFloat(f, 'e', -1, 64)) - } - case reflect.String: - return e.encodeString(v) - case reflect.Interface, reflect.Pointer: - return e.marshal(v.Elem()) - case reflect.Map: - return e.encodeMap(v) - 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) +func (e *VariableEncoder) writeStringLiteral(s []byte) error { + dst := make([]byte, 0, len(s)+5) dst = append(dst, '"') - for _, r := range src { + for _, r := range s { switch r { case '\\', '"': - dst = append(dst, '\\') - dst = utf8.AppendRune(dst, r) + dst = append(dst, '\\', r) case '\n': dst = append(dst, '\\', 'n') case '\r': dst = append(dst, '\\', 'r') case '\t': dst = append(dst, '\\', 't') + default: + dst = append(dst, r) } - dst = utf8.AppendRune(dst, r) } dst = append(dst, '"') - e.WriteBytes(dst) + return e.writeBytes(dst) +} + +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) + } return nil } -func (e *VariableEncoder) encodeStruct(v reflect.Value, t reflect.Type) error { - if v.NumField() == 0 { - e.WriteString("()") +func (e *VariableEncoder) writeIndentationCharacters() error { + return e.writeBytes(slices.Repeat([]byte{' ', ' '}, e.indentLevel)) +} + +func (e *VariableEncoder) marshal(v reflect.Value) error { + if !v.IsValid() { + return e.writeString("none") + //return fmt.Errorf("invalid reflect.Value %v", v) + } + + t := v.Type() + + switch i := v.Interface().(type) { + case time.Time: + if err := e.encodeTime(i); err != nil { + return err + } + return nil + case *time.Time: + if i == nil { + e.writeString("none") + return nil + } + if err := e.encodeTime(*i); err != nil { + return err + } + return nil + case time.Duration: + if err := e.encodeDuration(i); err != nil { + return err + } + return nil + case *time.Duration: + if i == nil { + e.writeString("none") + return nil + } + if err := e.encodeDuration(*i); err != nil { + return err + } return nil } - e.WriteString("(\n") + // TODO: Handle images, maybe create a wrapper type that does this + + if t.Implements(reflect.TypeFor[VariableMarshaler]()) { + if m, ok := v.Interface().(VariableMarshaler); ok { + bytes, err := m.MarshalTypstVariable() + if err != nil { + return fmt.Errorf("error calling MarshalTypstVariable for type %s: %w", t.String(), err) + } + return e.writeBytes(bytes) + } + return e.writeString("none") + } + + if t.Implements(reflect.TypeFor[encoding.TextMarshaler]()) { + if m, ok := v.Interface().(encoding.TextMarshaler); ok { + b, err := m.MarshalText() + if err != nil { + return fmt.Errorf("error calling MarshalText for type %s: %w", t.String(), err) + } + return e.writeStringLiteral(b) + } + return e.writeString("none") + } + + var err error + switch t.Kind() { + case reflect.Bool: + err = e.writeString(strconv.FormatBool(v.Bool())) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + err = e.writeString(strconv.FormatInt(v.Int(), 10)) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + err = e.writeString(strconv.FormatUint(v.Uint(), 10)) + case reflect.Float32, reflect.Float64: + f := v.Float() + switch { + case math.IsNaN(f): + err = e.writeString("float.nan") + case math.IsInf(f, 1): + err = e.writeString("float.inf") + case math.IsInf(f, -1): + err = e.writeString("-float.inf") + default: + err = e.writeString(strconv.FormatFloat(f, 'e', -1, 64)) + } + case reflect.String: + return e.encodeString(v) + case reflect.Interface, reflect.Pointer: + if v.IsNil() { + return e.writeString("none") + } + return e.marshal(v.Elem()) + case reflect.Map: + return e.encodeMap(v) + case reflect.Struct: + return e.encodeStruct(v, t) + case reflect.Slice: + return e.encodeSlice(v, t) + case reflect.Array: + return e.encodeArray(v) + default: + return fmt.Errorf("unsupported type %q", t.String()) + } + + return err +} + +func (e *VariableEncoder) encodeString(v reflect.Value) error { + return e.writeStringLiteral([]byte(v.String())) +} + +func (e *VariableEncoder) encodeStruct(v reflect.Value, t reflect.Type) error { + if v.NumField() == 0 { + return e.writeString("()") + } + + if err := e.writeString("(\n"); err != nil { + return err + } 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) + if err := e.writeIndentationCharacters(); err != nil { + return err + } + // TODO: Allow name customization via struct tags + if err := e.writeString(CleanIdentifier(ft.Name) + ": "); err != nil { + return err + } + if err := e.marshal(fv); err != nil { + return fmt.Errorf("failed to encode value of struct field %q: %w", ft.Name, err) + } + if err := e.writeString(",\n"); err != nil { + return err } - e.WriteString(",\n") } } e.indentLevel-- - e.WriteIndentationCharacters() - e.WriteString(")") + if err := e.writeIndentationCharacters(); err != nil { + return err + } - return nil + return e.writeString(")") } func (e *VariableEncoder) resolveKeyName(v reflect.Value) (string, error) { @@ -206,14 +245,17 @@ func (e *VariableEncoder) resolveKeyName(v reflect.Value) (string, error) { func (e *VariableEncoder) encodeMap(v reflect.Value) error { if v.Len() == 0 { - e.WriteString("()") - return nil + return e.writeString("()") } - e.WriteString("(\n") + if err := e.writeString("(\n"); err != nil { + return err + } e.indentLevel++ + // BUG: Map output needs to be sorted, otherwise this will cause the test to fail randomly + mi := v.MapRange() for mi.Next() { mk, mv := mi.Key(), mi.Value() @@ -222,57 +264,109 @@ func (e *VariableEncoder) encodeMap(v reflect.Value) error { return err } - e.WriteIndentationCharacters() - e.WriteString(key + ": ") + if err := e.writeIndentationCharacters(); err != nil { + return err + } + if err := e.writeString(CleanIdentifier(key) + ": "); err != nil { + return err + } if err := e.marshal(mv); err != nil { - return fmt.Errorf("failed to encode map field %q", key) + return fmt.Errorf("failed to encode map field %q: %w", key, err) } - e.WriteString(",\n") + if err := e.writeString(",\n"); err != nil { + return err + } } e.indentLevel-- - e.WriteIndentationCharacters() - e.WriteString(")") + if err := e.writeIndentationCharacters(); err != nil { + return err + } - return nil + return e.writeString(")") } -func (e *VariableEncoder) encodeSlice(v reflect.Value) error { - e.WriteString("(") +func (e *VariableEncoder) EncodeByteSlice(bb []byte) error { + if err := e.writeString("bytes(("); err != nil { + return err + } - // TODO: Output byte slice as a base64 and use the typst based package to convert that into typst Bytes. + // 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 { + if err := e.writeString(", "); err != nil { + return err + } + } + + if err := e.writeString(strconv.FormatUint(uint64(b), 10)); err != nil { + return err + } + } + + return e.writeString("))") +} + +func (e *VariableEncoder) encodeSlice(v reflect.Value, t reflect.Type) error { + + // Special case for byte slices. + if t.Elem().Kind() == reflect.Uint8 { + return e.EncodeByteSlice(v.Bytes()) + } + + if err := e.writeString("("); err != nil { + return err + } n := v.Len() for i := 0; i < n; i++ { if i > 0 { - e.WriteString(", ") + if err := e.writeString(", "); err != nil { + return err + } } if err := e.marshal(v.Index(i)); err != nil { - return fmt.Errorf("failed to encode slice element %d of %d", i+1, n+1) + return fmt.Errorf("failed to encode slice element %d of %d: %w", i+1, n+1, err) } } - e.WriteString(")") - - return nil + return e.writeString(")") } func (e *VariableEncoder) encodeArray(v reflect.Value) error { - e.WriteString("(") + if err := e.writeString("("); err != nil { + return err + } n := v.Len() for i := 0; i < n; i++ { if i > 0 { - e.WriteString(", ") + if err := e.writeString(", "); err != nil { + return err + } } if err := e.marshal(v.Index(i)); err != nil { - return fmt.Errorf("failed to encode array element %d of %d", i+1, n+1) + return fmt.Errorf("failed to encode array element %d of %d: %w", i+1, n+1, err) } } - e.WriteString(")") - - return nil + return e.writeString(")") +} + +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(), + t.Day(), + t.Hour(), + t.Minute(), + t.Second(), + )) +} + +func (e *VariableEncoder) encodeDuration(d time.Duration) error { + return e.writeString(fmt.Sprintf("duration(seconds: %d)", int(math.Round(d.Seconds())))) } diff --git a/variable-encoder_test.go b/variable-encoder_test.go index 4a3141a..ef503b6 100644 --- a/variable-encoder_test.go +++ b/variable-encoder_test.go @@ -2,12 +2,52 @@ package typst import ( "bytes" + "fmt" "math" "testing" + "time" "github.com/google/go-cmp/cmp" ) +type VariableMarshalerType []byte + +func (v VariableMarshalerType) MarshalTypstVariable() ([]byte, error) { + return v, nil +} + +type VariableMarshalerTypePointer []byte + +var variableMarshalerTypePointer = VariableMarshalerTypePointer("test") +var variableMarshalerTypePointerNil = VariableMarshalerTypePointer(nil) + +func (v *VariableMarshalerTypePointer) MarshalTypstVariable() ([]byte, error) { + if v != nil { + return *v, nil + } + + return nil, fmt.Errorf("no data") +} + +type TextMarshalerType []byte + +func (v TextMarshalerType) MarshalText() ([]byte, error) { + return v, nil +} + +type TextMarshalerTypePointer []byte + +var textMarshalerTypePointer = TextMarshalerTypePointer("test") +var textMarshalerTypePointerNil = TextMarshalerTypePointer(nil) + +func (v *TextMarshalerTypePointer) MarshalText() ([]byte, error) { + if v != nil { + return *v, nil + } + + return nil, fmt.Errorf("no data") +} + func TestVariableEncoder(t *testing.T) { tests := []struct { @@ -35,6 +75,7 @@ func TestVariableEncoder(t *testing.T) { {"float64 +inf", float64(math.Inf(1)), false, "float.inf"}, {"float64 -inf", float64(math.Inf(-1)), false, "-float.inf"}, {"string", "Hey!", false, `"Hey!"`}, + {"string escaped", "Hey!😀 \"This is quoted\"\nNew line!", false, `"Hey!😀 \"This is quoted\"\nNew line!"`}, {"struct", struct { Foo string Bar int @@ -49,6 +90,24 @@ func TestVariableEncoder(t *testing.T) { {"string slice empty", []string{}, false, `()`}, {"string slice nil", []string(nil), false, `()`}, {"string slice pointer", &[]string{"Foo", "Bar"}, false, `("Foo", "Bar")`}, + {"int slice", []int{1, 2, 3, 4, 5}, false, `(1, 2, 3, 4, 5)`}, + {"byte slice", []byte{1, 2, 3, 4, 5}, false, `bytes((1, 2, 3, 4, 5))`}, + {"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"`}, + {"MarshalText pointer nil", &textMarshalerTypePointerNil, false, `""`}, + {"MarshalText nil pointer", struct{ A *TextMarshalerTypePointer }{nil}, true, ``}, + {"time.Time", time.Date(2024, 12, 14, 12, 34, 56, 0, time.UTC), false, `datetime(year: 2024, month: 12, day: 14, hour: 12, minute: 34, second: 56)`}, + {"time.Time pointer", &[]time.Time{time.Date(2024, 12, 14, 12, 34, 56, 0, time.UTC)}[0], false, `datetime(year: 2024, month: 12, day: 14, hour: 12, minute: 34, second: 56)`}, + {"time.Time pointer nil", (*time.Time)(nil), false, `none`}, + {"time.Duration", 60 * time.Second, false, `duration(seconds: 60)`}, + {"time.Duration pointer", &[]time.Duration{60 * time.Second}[0], false, `duration(seconds: 60)`}, + {"time.Duration pointer nil", (*time.Duration)(nil), false, `none`}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -64,8 +123,8 @@ func TestVariableEncoder(t *testing.T) { 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)) + if !tt.wantErr && !cmp.Equal(result.String(), tt.want) { + t.Errorf("Got the following diff in output: %s", cmp.Diff(tt.want, result.String())) } }) }