Skip to content

Commit 9b42761

Browse files
committed
[go] streamline PDF metadata handling and improve error management
1 parent a6d888b commit 9b42761

6 files changed

Lines changed: 83 additions & 136 deletions

File tree

internal/report/typst/pdf_metadata.go

Lines changed: 28 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"strings"
1212
)
1313

14+
var pdfIndirectRefPattern = regexp.MustCompile(`(\d+)\s+(\d+)\s+R`)
15+
1416
// PDFMetadata describes the extra PDF metadata velocity.report wants to stamp
1517
// onto Typst-generated reports.
1618
type PDFMetadata struct {
@@ -92,10 +94,7 @@ func applyPDFMetadata(pdf []byte, meta PDFMetadata) ([]byte, error) {
9294
if err != nil {
9395
return nil, err
9496
}
95-
metadataRef, ok, err := parseIndirectRef(rootDict, "Metadata")
96-
if err != nil {
97-
return nil, err
98-
}
97+
metadataRef, ok := parseIndirectRef(rootDict, "Metadata")
9998
if ok {
10099
metadataOffset, found := xrefOffsets[metadataRef.number]
101100
if !found {
@@ -253,28 +252,16 @@ func parseLastTrailerDict(pdf []byte) ([]byte, error) {
253252
}
254253

255254
func parsePDFTrailer(dict []byte) (pdfTrailer, error) {
256-
size, ok, err := parsePDFInt(dict, "Size")
257-
if err != nil {
258-
return pdfTrailer{}, err
259-
}
255+
size, ok := parsePDFInt(dict, "Size")
260256
if !ok {
261257
return pdfTrailer{}, fmt.Errorf("pdf trailer missing Size")
262258
}
263-
root, ok, err := parseIndirectRef(dict, "Root")
264-
if err != nil {
265-
return pdfTrailer{}, err
266-
}
259+
root, ok := parseIndirectRef(dict, "Root")
267260
if !ok {
268261
return pdfTrailer{}, fmt.Errorf("pdf trailer missing Root")
269262
}
270-
info, hasInfo, err := parseIndirectRef(dict, "Info")
271-
if err != nil {
272-
return pdfTrailer{}, err
273-
}
274-
id, _, err := parsePDFRawValue(dict, "ID", `\[[^\]]+\]`)
275-
if err != nil {
276-
return pdfTrailer{}, err
277-
}
263+
info, hasInfo := parseIndirectRef(dict, "Info")
264+
id, _ := parsePDFRawValue(dict, "ID", `\[[^\]]+\]`)
278265

279266
trailer := pdfTrailer{size: size, root: root, id: id}
280267
if hasInfo {
@@ -396,49 +383,33 @@ func extractPDFStreamObject(obj []byte) ([]byte, []byte, error) {
396383
return obj[dictStart:dictEnd], stream, nil
397384
}
398385

399-
func parseIndirectRef(dict []byte, key string) (pdfRef, bool, error) {
400-
value, ok, err := parsePDFRawValue(dict, key, `(\d+)\s+(\d+)\s+R`)
401-
if err != nil || !ok {
402-
return pdfRef{}, ok, err
403-
}
404-
re := regexp.MustCompile(`(\d+)\s+(\d+)\s+R`)
405-
match := re.FindStringSubmatch(value)
406-
if len(match) != 3 {
407-
return pdfRef{}, false, fmt.Errorf("malformed pdf reference for %s", key)
408-
}
409-
number, err := strconv.Atoi(match[1])
410-
if err != nil {
411-
return pdfRef{}, false, fmt.Errorf("parse %s object number: %w", key, err)
412-
}
413-
generation, err := strconv.Atoi(match[2])
414-
if err != nil {
415-
return pdfRef{}, false, fmt.Errorf("parse %s generation: %w", key, err)
386+
func parseIndirectRef(dict []byte, key string) (pdfRef, bool) {
387+
value, ok := parsePDFRawValue(dict, key, `(\d+)\s+(\d+)\s+R`)
388+
if !ok {
389+
return pdfRef{}, false
416390
}
417-
return pdfRef{number: number, generation: generation}, true, nil
391+
match := pdfIndirectRefPattern.FindStringSubmatch(value)
392+
number, _ := strconv.Atoi(match[1])
393+
generation, _ := strconv.Atoi(match[2])
394+
return pdfRef{number: number, generation: generation}, true
418395
}
419396

420-
func parsePDFInt(dict []byte, key string) (int, bool, error) {
421-
value, ok, err := parsePDFRawValue(dict, key, `(\d+)`)
422-
if err != nil || !ok {
423-
return 0, ok, err
424-
}
425-
parsed, err := strconv.Atoi(value)
426-
if err != nil {
427-
return 0, false, fmt.Errorf("parse %s: %w", key, err)
397+
func parsePDFInt(dict []byte, key string) (int, bool) {
398+
value, ok := parsePDFRawValue(dict, key, `(\d+)`)
399+
if !ok {
400+
return 0, false
428401
}
429-
return parsed, true, nil
402+
parsed, _ := strconv.Atoi(value)
403+
return parsed, true
430404
}
431405

432-
func parsePDFRawValue(dict []byte, key, pattern string) (string, bool, error) {
406+
func parsePDFRawValue(dict []byte, key, pattern string) (string, bool) {
433407
re := regexp.MustCompile(`/` + regexp.QuoteMeta(key) + `\s+(` + pattern + `)`)
434408
match := re.FindSubmatch(dict)
435409
if len(match) == 0 {
436-
return "", false, nil
410+
return "", false
437411
}
438-
if len(match) < 2 {
439-
return "", false, fmt.Errorf("malformed pdf value for %s", key)
440-
}
441-
return string(match[1]), true, nil
412+
return string(match[1]), true
442413
}
443414

444415
func replaceOrAddPDFString(dict []byte, key, value string) []byte {
@@ -475,10 +446,7 @@ func insertIntoPDFDict(dict, entry []byte) []byte {
475446
}
476447

477448
func replaceOrAddXMLTag(doc []byte, tag, value string) ([]byte, error) {
478-
escaped, err := escapeXMLText(value)
479-
if err != nil {
480-
return nil, err
481-
}
449+
escaped := escapeXMLText(value)
482450
replacement := []byte(fmt.Sprintf("<%s>%s</%s>", tag, escaped, tag))
483451
re := regexp.MustCompile(`(?s)<` + regexp.QuoteMeta(tag) + `>.*?</` + regexp.QuoteMeta(tag) + `>`)
484452
if re.Match(doc) {
@@ -515,12 +483,10 @@ func escapePDFString(value string) string {
515483
return out.String()
516484
}
517485

518-
func escapeXMLText(value string) (string, error) {
486+
func escapeXMLText(value string) string {
519487
var out bytes.Buffer
520-
if err := xml.EscapeText(&out, []byte(value)); err != nil {
521-
return "", err
522-
}
523-
return out.String(), nil
488+
_ = xml.EscapeText(&out, []byte(value))
489+
return out.String()
524490
}
525491

526492
func skipPDFWhitespace(pdf []byte, index int) int {

internal/report/typst/render.go

Lines changed: 35 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
// out to `typst compile`. The Atkinson Hyperlegible fonts are materialised
77
// from the chart asset package so generation works from a deployed binary
88
// with no source tree present. The typst executable itself is resolved via the
9-
// typstbin subpackage (VELOCITY_TYPST_PATH → embedded binary → PATH → dev
10-
// download).
9+
// typstbin subpackage (embedded binary → PATH → dev download).
1110
package typst
1211

1312
import (
@@ -30,6 +29,21 @@ import (
3029
//go:embed templates
3130
var templatesFS embed.FS
3231

32+
type templateSourceFS interface {
33+
fs.FS
34+
fs.ReadFileFS
35+
}
36+
37+
var (
38+
renderTemplatesFS = templateSourceFS(templatesFS)
39+
renderResolveTypst = typstbin.Resolve
40+
renderMkdirTemp = os.MkdirTemp
41+
renderRemoveAll = os.RemoveAll
42+
renderMkdirAll = os.MkdirAll
43+
renderWriteFile = os.WriteFile
44+
renderAllFonts = assets.AllFonts
45+
)
46+
3347
// Asset is a binary blob (chart SVG, map SVG, etc.) that the report embeds
3448
// via #image(). The Name is used as the relative path inside the working
3549
// directory; the template references it as e.g. `data.charts.timeseries`.
@@ -69,22 +83,17 @@ type Options struct {
6983
// the document. Typst 0.13.x exposes document metadata, but not the PDF
7084
// creator tool, so we patch the finished PDF incrementally.
7185
PDFMetadata PDFMetadata
72-
73-
// TypstPath overrides the typst executable. When empty, the binary is
74-
// resolved via typstbin (VELOCITY_TYPST_PATH → embedded → PATH → dev
75-
// download).
76-
TypstPath string
7786
}
7887

7988
// Render compiles the embedded templates against opts.Data and writes the
8089
// resulting PDF to out. The working directory used for compilation is removed
8190
// before Render returns.
8291
func Render(out io.Writer, opts Options) error {
83-
workDir, err := os.MkdirTemp("", "velocity-report-typst-*")
92+
workDir, err := renderMkdirTemp("", "velocity-report-typst-*")
8493
if err != nil {
8594
return fmt.Errorf("create temp dir: %w", err)
8695
}
87-
defer os.RemoveAll(workDir)
96+
defer renderRemoveAll(workDir)
8897

8998
if err := materialiseTemplates(workDir); err != nil {
9099
return err
@@ -105,23 +114,19 @@ func Render(out io.Writer, opts Options) error {
105114
if dest != workDir && !strings.HasPrefix(dest, workDir+string(os.PathSeparator)) {
106115
return fmt.Errorf("asset name %q escapes work dir", asset.Name)
107116
}
108-
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
117+
if err := renderMkdirAll(filepath.Dir(dest), 0o755); err != nil {
109118
return fmt.Errorf("mkdir %s: %w", filepath.Dir(dest), err)
110119
}
111-
if err := os.WriteFile(dest, asset.Data, 0o644); err != nil {
120+
if err := renderWriteFile(dest, asset.Data, 0o644); err != nil {
112121
return fmt.Errorf("write asset %s: %w", asset.Name, err)
113122
}
114123
}
115124

116-
execPath := opts.TypstPath
117-
if execPath == "" {
118-
resolved, cleanup, rerr := typstbin.Resolve()
119-
if rerr != nil {
120-
return fmt.Errorf("resolve typst binary: %w", rerr)
121-
}
122-
defer cleanup()
123-
execPath = resolved
125+
execPath, cleanup, rerr := renderResolveTypst()
126+
if rerr != nil {
127+
return fmt.Errorf("resolve typst binary: %w", rerr)
124128
}
129+
defer cleanup()
125130
caller := gotypst.CLI{ExecutablePath: execPath}
126131

127132
// Bootstrap: typst reads the document from stdin, which has no implicit
@@ -173,14 +178,14 @@ func Render(out io.Writer, opts Options) error {
173178
// recompilable source ZIP that ships alongside each generated PDF.
174179
func Sources() (map[string][]byte, error) {
175180
out := map[string][]byte{}
176-
err := fs.WalkDir(templatesFS, "templates", func(path string, d fs.DirEntry, err error) error {
181+
err := fs.WalkDir(renderTemplatesFS, "templates", func(path string, d fs.DirEntry, err error) error {
177182
if err != nil {
178183
return err
179184
}
180185
if d.IsDir() {
181186
return nil
182187
}
183-
body, rerr := templatesFS.ReadFile(path)
188+
body, rerr := renderTemplatesFS.ReadFile(path)
184189
if rerr != nil {
185190
return rerr
186191
}
@@ -202,38 +207,35 @@ func MarshalData(data any) ([]byte, error) {
202207
// materialiseTemplates copies the embedded templates/ tree into workDir at
203208
// the top level (so report.typ ends up at workDir/report.typ).
204209
func materialiseTemplates(workDir string) error {
205-
return fs.WalkDir(templatesFS, "templates", func(path string, d fs.DirEntry, err error) error {
210+
return fs.WalkDir(renderTemplatesFS, "templates", func(path string, d fs.DirEntry, err error) error {
206211
if err != nil {
207212
return err
208213
}
209214
if d.IsDir() {
210215
return nil
211216
}
212-
rel, err := filepath.Rel("templates", path)
213-
if err != nil {
214-
return err
215-
}
217+
rel := strings.TrimPrefix(path, "templates/")
216218
dest := filepath.Join(workDir, rel)
217-
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
219+
if err := renderMkdirAll(filepath.Dir(dest), 0o755); err != nil {
218220
return err
219221
}
220-
body, err := templatesFS.ReadFile(path)
222+
body, err := renderTemplatesFS.ReadFile(path)
221223
if err != nil {
222224
return err
223225
}
224-
return os.WriteFile(dest, body, 0o644)
226+
return renderWriteFile(dest, body, 0o644)
225227
})
226228
}
227229

228230
// materialiseFonts writes the embedded Atkinson Hyperlegible fonts into
229231
// workDir/fonts and returns that directory for use as a typst --font-path.
230232
func materialiseFonts(workDir string) (string, error) {
231233
fontDir := filepath.Join(workDir, "fonts")
232-
if err := os.MkdirAll(fontDir, 0o755); err != nil {
234+
if err := renderMkdirAll(fontDir, 0o755); err != nil {
233235
return "", fmt.Errorf("mkdir fonts: %w", err)
234236
}
235-
for name, data := range assets.AllFonts() {
236-
if err := os.WriteFile(filepath.Join(fontDir, name), data, 0o644); err != nil {
237+
for name, data := range renderAllFonts() {
238+
if err := renderWriteFile(filepath.Join(fontDir, name), data, 0o644); err != nil {
237239
return "", fmt.Errorf("write font %s: %w", name, err)
238240
}
239241
}
@@ -245,5 +247,5 @@ func writeData(workDir string, data any) error {
245247
if err != nil {
246248
return fmt.Errorf("marshal report data: %w", err)
247249
}
248-
return os.WriteFile(filepath.Join(workDir, "data.json"), body, 0o644)
250+
return renderWriteFile(filepath.Join(workDir, "data.json"), body, 0o644)
249251
}

internal/report/typst/typstbin/deps.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ import (
55
"os/exec"
66
)
77

8+
type tempExecutable interface {
9+
Name() string
10+
Write([]byte) (int, error)
11+
Chmod(os.FileMode) error
12+
Close() error
13+
}
14+
815
var (
916
osGetenv = os.Getenv
1017
osStat = os.Stat
@@ -15,6 +22,7 @@ var (
1522
osCreateTemp = os.CreateTemp
1623
osRename = os.Rename
1724
osChmod = os.Chmod
25+
createTempExec = func(dir, pattern string) (tempExecutable, error) { return osCreateTemp(dir, pattern) }
1826

1927
cacheDirFunc = cacheDir
2028
typstTargetFunc = typstTarget

internal/report/typst/typstbin/embed_off.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
package typstbin
44

55
// embeddedTypst returns no binary in the default build. The typst executable
6-
// is resolved from VELOCITY_TYPST_PATH or PATH instead. Build with
6+
// is resolved from PATH or the development downloader instead. Build with
77
// `-tags typst_embed` (after placing a platform binary at dist/typst via
88
// `make install-typst-dist`) to embed the binary into the program.
99
func embeddedTypst() ([]byte, bool) { return nil, false }

0 commit comments

Comments
 (0)