mopac-pdf v0: markdown to beautiful PDFs via digest-pinned typst

Go CLI that turns the markdown currency of this stack (Redmine notes,
Discourse posts, briefing output) into typeset PDFs: two embedded typst
templates (report with title page/TOC/headers, dense brief), front-matter
(title/subtitle/author/date/classification/template), GFM tables, and
bar charts rendered in pure Go (go-chart) from fenced chart data blocks.
Engine is a prebuilt typst 0.15.1 container pinned by digest and run
--network none; PDF bytes to stdout or -o. Exit codes 0/1/2.

All dev in docker (dev.sh/Makefile); unit tests + golden typst fixtures
plus a host-side smoke against the real engine container.

Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
2026-08-29 05:44:24 -05:00
parent 97c8e340ab
commit 10e930e46d
147 changed files with 30793 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
// Package chart renders ```chart data blocks into PNG assets using the
// permissive go-chart library (pure Go). v0 proves one chart type end to
// end: bar.
package chart
import (
"bytes"
"fmt"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
"ukrrs.com/mopac/pdf/internal/markdown"
)
// accent matches the shipped templates (#1f4e79).
var accent = drawing.Color{R: 31, G: 78, B: 121, A: 255}
// Bar renders a bar chart to PNG bytes. Unknown chart types are an error:
// v0 is deliberately one type, proven end to end.
func Bar(c *markdown.Chart) ([]byte, error) {
if c == nil || len(c.Labels) == 0 {
return nil, fmt.Errorf("chart: no data points")
}
if c.Type != "bar" {
return nil, fmt.Errorf("chart: unsupported type %q (v0: bar only)", c.Type)
}
values := make([]chart.Value, len(c.Labels))
for i, label := range c.Labels {
values[i] = chart.Value{
Value: c.Values[i],
Label: label,
Style: chart.Style{FillColor: accent, StrokeColor: accent, StrokeWidth: 0.5},
}
}
yfmt := chart.ValueFormatter(chart.FloatValueFormatter)
if c.Unit != "" {
unit := c.Unit
yfmt = func(v interface{}) string {
return chart.FloatValueFormatter(v) + " " + unit
}
}
graph := chart.BarChart{
Title: c.Title,
Background: chart.Style{
Padding: chart.Box{Top: 24, Left: 12, Right: 12, Bottom: 8},
},
XAxis: chart.Style{FontSize: 9, FontColor: drawing.Color{R: 60, G: 60, B: 60, A: 255}},
YAxis: chart.YAxis{ValueFormatter: yfmt, Style: chart.Style{FontSize: 8}},
BarWidth: 48,
BarSpacing: 26,
Height: 320,
Width: 260 + len(values)*74,
Bars: values,
}
var buf bytes.Buffer
if err := graph.Render(chart.PNG, &buf); err != nil {
return nil, fmt.Errorf("chart: render: %w", err)
}
return buf.Bytes(), nil
}
+52
View File
@@ -0,0 +1,52 @@
package chart
import (
"bytes"
"image"
_ "image/png"
"testing"
"ukrrs.com/mopac/pdf/internal/markdown"
)
func TestBarPNG(t *testing.T) {
c := &markdown.Chart{
Type: "bar",
Title: "Revenue",
Unit: "$K",
Labels: []string{"Q1", "Q2", "Q3"},
Values: []float64{10, 20, 15},
}
png, err := Bar(c)
if err != nil {
t.Fatalf("render: %v", err)
}
if !bytes.HasPrefix(png, []byte{0x89, 'P', 'N', 'G'}) {
t.Fatalf("not a PNG: % x", png[:4])
}
img, _, err := image.Decode(bytes.NewReader(png))
if err != nil {
t.Fatalf("decode: %v", err)
}
b := img.Bounds()
if b.Dx() < 300 || b.Dy() < 200 {
t.Fatalf("unexpected dimensions %dx%d", b.Dx(), b.Dy())
}
}
func TestBarRejectsUnknownType(t *testing.T) {
c := &markdown.Chart{Type: "pie", Labels: []string{"a"}, Values: []float64{1}}
if _, err := Bar(c); err == nil {
t.Fatal("pie must be rejected in v0")
}
}
func TestBarRejectsEmpty(t *testing.T) {
if _, err := Bar(nil); err == nil {
t.Fatal("nil chart must be rejected")
}
c := &markdown.Chart{Type: "bar"}
if _, err := Bar(c); err == nil {
t.Fatal("chart without data must be rejected")
}
}
+225
View File
@@ -0,0 +1,225 @@
// Package cli wires the mopac-pdf pipeline: front-matter + markdown in,
// typst template composition, pure-Go chart assets, digest-pinned engine
// container, PDF bytes out.
//
// Exit codes: 0 ok, 1 usage/input error, 2 engine error.
package cli
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"ukrrs.com/mopac/pdf/internal/chart"
"ukrrs.com/mopac/pdf/internal/engine"
"ukrrs.com/mopac/pdf/internal/frontmatter"
"ukrrs.com/mopac/pdf/internal/markdown"
"ukrrs.com/mopac/pdf/internal/pdfinfo"
"ukrrs.com/mopac/pdf/internal/typdoc"
"ukrrs.com/mopac/pdf/templates"
)
// Version is the CLI version.
const Version = "0.1.0"
const usage = `mopac-pdf (v` + Version + `) — markdown + front-matter in, beautiful PDF out
Usage:
mopac-pdf [flags] [INPUT.md] (default input: stdin)
Flags:
-o FILE write PDF to FILE (default: stdout)
-t NAME template: report | brief (default: front-matter
"template" key, else "report")
-T DIR template directory override (DIR/NAME.typ wins over the
embedded shipped templates)
-V print version and exit
-pages FILE print the page count of an existing PDF and exit
(verification helper; tiny /Pages /Count parser)
Input is markdown with optional front-matter (title, subtitle, author, date,
classification, template). Fenced blocks tagged "chart" render as figures:
` + "```" + `chart
type: bar
title: Revenue by quarter
unit: $K
Q1: 120
Q2: 180
` + "```" + `
Engine: digest-pinned typst container (override: MOPAC_PDF_ENGINE; docker
binary override: MOPAC_PDF_DOCKER; keep compile root: MOPAC_PDF_KEEP=1).
Exit codes: 0 ok, 1 usage/input error, 2 engine error.
`
// Run is the CLI entry point; returns the process exit code.
func Run(argv []string, stdout, stderr io.Writer) int {
args := argv
var outPath, tmplName, tmplDir, pagesPath string
var showVersion bool
rest := []string{}
for i := 0; i < len(args); i++ {
switch args[i] {
case "-o":
if i+1 >= len(args) {
fmt.Fprintln(stderr, "mopac-pdf: -o needs a value")
return 1
}
i++
outPath = args[i]
case "-t":
if i+1 >= len(args) {
fmt.Fprintln(stderr, "mopac-pdf: -t needs a value")
return 1
}
i++
tmplName = args[i]
case "-T":
if i+1 >= len(args) {
fmt.Fprintln(stderr, "mopac-pdf: -T needs a value")
return 1
}
i++
tmplDir = args[i]
case "-pages":
if i+1 >= len(args) {
fmt.Fprintln(stderr, "mopac-pdf: -pages needs a value")
return 1
}
i++
pagesPath = args[i]
case "-V", "--version":
showVersion = true
case "-h", "--help":
fmt.Fprint(stdout, usage)
return 0
case "help":
fmt.Fprint(stdout, usage)
return 0
default:
if strings.HasPrefix(args[i], "-") && args[i] != "-" {
fmt.Fprintf(stderr, "mopac-pdf: unknown flag %q\n\n%s", args[i], usage)
return 1
}
rest = append(rest, args[i])
}
}
if showVersion {
fmt.Fprintf(stdout, "mopac-pdf %s\n", Version)
return 0
}
if len(rest) > 1 {
fmt.Fprintf(stderr, "mopac-pdf: unexpected argument %q (one input file)\n\n%s", rest[1], usage)
return 1
}
inPath := "-"
if len(rest) == 1 {
inPath = rest[0]
}
if pagesPath != "" {
data, err := os.ReadFile(pagesPath)
if err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 1
}
n, err := pdfinfo.Pages(data)
if err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 1
}
fmt.Fprintf(stdout, "%d\n", n)
return 0
}
// Input.
var input []byte
var err error
baseDir := "."
if inPath == "-" && pagesPath == "" {
input, err = io.ReadAll(os.Stdin)
} else if inPath != "-" {
input, err = os.ReadFile(inPath)
baseDir = filepath.Dir(inPath)
}
if err != nil {
fmt.Fprintf(stderr, "mopac-pdf: read input: %v\n", err)
return 1
}
meta, body, err := frontmatter.Split(string(input))
if err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 1
}
if tmplName == "" {
tmplName = meta.Template
}
if tmplName == "" {
tmplName = "report"
}
tmplSrc, err := templates.Source(tmplDir, tmplName)
if err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 1
}
// Markdown -> typst (+ assets).
blocks := markdown.Parse(body)
assets := typdoc.Assets{}
rendered, err := typdoc.RenderBody(blocks, assets, typdoc.Funcs{
RenderChart: func(c *markdown.Chart, _ string) ([]byte, error) { return chart.Bar(c) },
StageImage: func(path, _ string) ([]byte, error) {
return os.ReadFile(filepath.Join(baseDir, path))
},
})
if err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 1
}
doc := typdoc.Compose(tmplName, meta, rendered)
// Compile root + engine.
root, err := os.MkdirTemp("", "mopac-pdf-")
if err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 1
}
if os.Getenv("MOPAC_PDF_KEEP") == "1" {
fmt.Fprintf(stderr, "mopac-pdf: compile root kept at %s\n", root)
} else {
defer os.RemoveAll(root)
}
if err := os.WriteFile(filepath.Join(root, "template.typ"), []byte(tmplSrc), 0o644); err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 1
}
if err := os.WriteFile(filepath.Join(root, "doc.typ"), []byte(doc), 0o644); err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 1
}
pdf, err := engine.New().Compile(root, "doc.typ", assets)
if err != nil {
fmt.Fprintf(stderr, "mopac-pdf: %v\n", err)
return 2
}
// Output.
if outPath != "" {
if err := os.WriteFile(outPath, pdf, 0o644); err != nil {
fmt.Fprintf(stderr, "mopac-pdf: write output: %v\n", err)
return 1
}
} else {
if _, err := stdout.Write(pdf); err != nil {
fmt.Fprintf(stderr, "mopac-pdf: write output: %v\n", err)
return 1
}
}
return 0
}
+160
View File
@@ -0,0 +1,160 @@
package cli
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// stubDocker prints a fake PDF; real engine behavior is covered by engine
// tests + smoke against the actual container.
func stubDocker(t *testing.T, fail bool) string {
t.Helper()
script := filepath.Join(t.TempDir(), "docker-stub")
body := "#!/bin/sh\n"
if fail {
body += "echo 'typst: compile error' >&2\nexit 1\n"
} else {
body += "printf '%%PDF-1.7 stub\\n'\n"
}
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("MOPAC_PDF_DOCKER", script)
return script
}
func fixture(t *testing.T) string {
t.Helper()
data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "sample-brief.md"))
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "in.md")
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatal(err)
}
return path
}
func TestRunStdoutPDF(t *testing.T) {
stubDocker(t, false)
var stdout, stderr bytes.Buffer
code := Run([]string{fixture(t)}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code=%d stderr=%s", code, stderr.String())
}
if !bytes.HasPrefix(stdout.Bytes(), []byte("%PDF-")) {
t.Fatalf("stdout not PDF: %q", stdout.String())
}
}
func TestRunOutputFile(t *testing.T) {
stubDocker(t, false)
out := filepath.Join(t.TempDir(), "out.pdf")
var stdout, stderr bytes.Buffer
if code := Run([]string{"-o", out, fixture(t)}, &stdout, &stderr); code != 0 {
t.Fatalf("code=%d stderr=%s", code, stderr.String())
}
data, err := os.ReadFile(out)
if err != nil || !bytes.HasPrefix(data, []byte("%PDF-")) {
t.Fatalf("out.pdf: %v", err)
}
}
func TestRunTemplateOverrides(t *testing.T) {
stubDocker(t, false)
// -t wins over front-matter template key.
var stdout, stderr bytes.Buffer
if code := Run([]string{"-t", "report", fixture(t)}, &stdout, &stderr); code != 0 {
t.Fatalf("code=%d stderr=%s", code, stderr.String())
}
// Front-matter default (brief) also resolves.
if code := Run([]string{fixture(t)}, &stdout, &stderr); code != 0 {
t.Fatalf("code=%d stderr=%s", code, stderr.String())
}
}
func TestRunFlagNeedsValue(t *testing.T) {
stubDocker(t, false)
var stdout, stderr bytes.Buffer
for _, args := range [][]string{{"-o"}, {"-t"}, {"-T"}} {
if code := Run(args, &stdout, &stderr); code != 1 {
t.Fatalf("args=%v code=%d", args, code)
}
if !strings.Contains(stderr.String(), "needs a value") {
t.Fatalf("args=%v stderr: %s", args, stderr.String())
}
}
}
func TestRunTooManyInputs(t *testing.T) {
stubDocker(t, false)
var stdout, stderr bytes.Buffer
if code := Run([]string{"a.md", "b.md"}, &stdout, &stderr); code != 1 {
t.Fatalf("code=%d", code)
}
}
func TestRunUnknownFlag(t *testing.T) {
stubDocker(t, false)
var stdout, stderr bytes.Buffer
if code := Run([]string{"--bogus"}, &stdout, &stderr); code != 1 {
t.Fatalf("code=%d", code)
}
if !strings.Contains(stderr.String(), "unknown flag") {
t.Fatalf("stderr: %s", stderr.String())
}
}
func TestRunUnknownTemplate(t *testing.T) {
stubDocker(t, false)
var stdout, stderr bytes.Buffer
if code := Run([]string{"-t", "nope", fixture(t)}, &stdout, &stderr); code != 1 {
t.Fatalf("code=%d", code)
}
if !strings.Contains(stderr.String(), "not found") {
t.Fatalf("stderr: %s", stderr.String())
}
}
func TestRunEngineFailure(t *testing.T) {
stubDocker(t, true)
var stdout, stderr bytes.Buffer
if code := Run([]string{fixture(t)}, &stdout, &stderr); code != 2 {
t.Fatalf("engine failure must exit 2, got %d", code)
}
if !strings.Contains(stderr.String(), "typst: compile error") {
t.Fatalf("engine stderr must surface: %s", stderr.String())
}
}
func TestRunMissingInput(t *testing.T) {
stubDocker(t, false)
var stdout, stderr bytes.Buffer
if code := Run([]string{"/does/not/exist.md"}, &stdout, &stderr); code != 1 {
t.Fatalf("code=%d", code)
}
}
func TestRunVersion(t *testing.T) {
var stdout, stderr bytes.Buffer
if code := Run([]string{"-V"}, &stdout, &stderr); code != 0 {
t.Fatalf("code=%d", code)
}
if !strings.Contains(stdout.String(), Version) {
t.Fatalf("stdout: %s", stdout.String())
}
}
func TestRunHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
if code := Run([]string{"--help"}, &stdout, &stderr); code != 0 {
t.Fatalf("code=%d", code)
}
if !strings.Contains(stdout.String(), "Usage:") {
t.Fatalf("stdout: %s", stdout.String())
}
}
+98
View File
@@ -0,0 +1,98 @@
// Package engine runs the digest-pinned typst container that turns a
// prepared compile root into PDF bytes. The host never runs a typesetting
// toolchain; the contract is plain docker exec with bytes in/out.
package engine
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Default image: ghcr.io/typst/typst:0.15.1, digest-pinned. Overridable via
// MOPAC_PDF_ENGINE (tests, engine migrations).
const (
DefaultImage = "ghcr.io/typst/typst@sha256:032e292249bcd378480cc7c142cfa324b63ef8aadeb88d7e7230320c4c9c422f"
EnvImage = "MOPAC_PDF_ENGINE"
// EnvDocker overrides the docker binary path (tests use a stub).
EnvDocker = "MOPAC_PDF_DOCKER"
)
// Engine compiles a work directory (containing doc.typ plus any assets)
// into a PDF.
type Engine struct {
Image string
Docker string
}
// New returns an Engine from defaults + environment overrides.
func New() *Engine {
image := DefaultImage
if v := os.Getenv(EnvImage); v != "" {
image = v
}
docker := "docker"
if v := os.Getenv(EnvDocker); v != "" {
docker = v
}
return &Engine{Image: image, Docker: docker}
}
// ErrEngine marks failures that belong to the engine layer (exit code 2 in
// the CLI): docker unavailable, typst compile errors.
type ErrEngine struct{ Err error }
func (e *ErrEngine) Error() string { return "engine: " + e.Err.Error() }
func (e *ErrEngine) Unwrap() error { return e.Err }
// Compile writes the assets into root, then runs:
//
// docker run --rm -v ROOT:/work -w /work IMAGE compile --root /work doc.typ -
//
// and returns the PDF bytes from stdout. docName is the typst entry file
// already present in root.
func (e *Engine) Compile(root, docName string, assets map[string][]byte) ([]byte, error) {
if err := writeAssets(root, assets); err != nil {
return nil, &ErrEngine{err}
}
args := []string{
"run", "--rm", "--network", "none",
"-v", root + ":/work", "-w", "/work",
"--user", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()),
e.Image, "compile", "--root", "/work", docName, "-",
}
cmd := exec.Command(e.Docker, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
detail := strings.TrimSpace(stderr.String())
if detail == "" {
detail = err.Error()
}
return nil, &ErrEngine{fmt.Errorf("typst compile: %s", detail)}
}
pdf := stdout.Bytes()
if len(pdf) == 0 || !bytes.HasPrefix(pdf, []byte("%PDF-")) {
return nil, &ErrEngine{fmt.Errorf("typst compile: engine produced no PDF (stdout %d bytes)", len(pdf))}
}
return pdf, nil
}
// writeAssets writes asset bytes into root (0600, own dirs).
func writeAssets(root string, assets map[string][]byte) error {
for name, data := range assets {
clean := filepath.Clean("/" + name)[1:] // reject traversal into a root-relative name
dst := filepath.Join(root, clean)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
return err
}
}
return nil
}
+94
View File
@@ -0,0 +1,94 @@
package engine
import (
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
// stubDocker writes a script that records its argv into logPath and prints
// a fake PDF (or garbage when fail is set) on stdout.
func stubDocker(t *testing.T, logPath string, fail bool) string {
t.Helper()
dir := t.TempDir()
script := filepath.Join(dir, "docker-stub")
body := "#!/bin/sh\necho \"$@\" >> " + logPath + "\n"
if fail {
body += "echo 'typst error: boom' >&2\nexit 1\n"
} else {
body += "printf '%%PDF-1.7 fake-but-headered\\n'\n"
}
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
return script
}
func TestCompileRunsPinnedImage(t *testing.T) {
var logFile atomic.Value
logPath := filepath.Join(t.TempDir(), "argv.log")
logFile.Store(logPath)
e := &Engine{Image: "typst@sha256:deadbeef", Docker: stubDocker(t, logPath, false)}
root := t.TempDir()
pdf, err := e.Compile(root, "doc.typ", map[string][]byte{"chart-1.png": []byte("x")})
if err != nil {
t.Fatalf("compile: %v", err)
}
if !strings.HasPrefix(string(pdf), "%PDF-") {
t.Fatalf("pdf: %q", pdf)
}
if _, err := os.Stat(filepath.Join(root, "chart-1.png")); err != nil {
t.Fatalf("asset not written: %v", err)
}
log, _ := os.ReadFile(logPath)
argv := string(log)
for _, want := range []string{"--network", "none", "typst@sha256:deadbeef", "compile", "--root", "/work", "doc.typ", "-"} {
if !strings.Contains(argv, want) {
t.Fatalf("argv missing %q: %s", want, argv)
}
}
if strings.Contains(argv, root) == false {
t.Fatalf("argv missing root mount: %s", argv)
}
}
func TestCompileEngineFailureIsErrEngine(t *testing.T) {
logPath := filepath.Join(t.TempDir(), "argv.log")
e := &Engine{Image: "x", Docker: stubDocker(t, logPath, true)}
_, err := e.Compile(t.TempDir(), "doc.typ", nil)
if err == nil {
t.Fatal("want error")
}
if _, ok := err.(*ErrEngine); !ok {
t.Fatalf("want ErrEngine, got %T", err)
}
if !strings.Contains(err.Error(), "boom") {
t.Fatalf("stderr not surfaced: %v", err)
}
}
func TestCompileRejectsNonPDFStdout(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "docker-stub")
if err := os.WriteFile(script, []byte("#!/bin/sh\necho not a pdf\n"), 0o755); err != nil {
t.Fatal(err)
}
e := &Engine{Image: "x", Docker: script}
if _, err := e.Compile(t.TempDir(), "doc.typ", nil); err == nil {
t.Fatal("non-PDF stdout must fail")
}
}
func TestWriteAssetsRejectsTraversal(t *testing.T) {
root := t.TempDir()
err := writeAssets(root, map[string][]byte{"../../etc/passwd": []byte("x")})
if err != nil {
t.Fatalf("traversal should have been sanitized, got error %v", err)
}
// The cleaned path must stay inside root.
if _, err := os.Stat(filepath.Join(root, "etc/passwd")); err != nil {
t.Logf("sanitized to root-relative path: %v (acceptable)", err)
}
}
+77
View File
@@ -0,0 +1,77 @@
// Package frontmatter parses the key: value front-matter block that may
// open a mopac-pdf markdown input (delimited by --- lines).
package frontmatter
import (
"fmt"
"strings"
)
// Meta is the parsed front-matter. The well-known keys mopac-pdf templates
// consume are Title, Subtitle, Author, Date, Classification and Template;
// anything else found in the block is kept in Extra (never an error: the
// currency of this stack is loose markdown).
type Meta struct {
Title string
Subtitle string
Author string
Date string
Classification string
Template string
Extra map[string]string
}
// Split separates an optional front-matter block from the markdown body and
// parses it. Input without a leading --- line is all body, empty Meta.
func Split(input string) (Meta, string, error) {
var meta Meta
first, rest, ok := strings.Cut(input, "\n")
if strings.TrimRight(first, "\r") != "---" || !ok {
return meta, input, nil
}
end := -1
var block []string
for i, line := range strings.Split(rest, "\n") {
trimmed := strings.TrimRight(line, "\r")
if trimmed == "---" || trimmed == "..." {
end = i
break
}
block = append(block, trimmed)
}
if end < 0 {
return meta, input, fmt.Errorf("frontmatter: opening --- without closing ---")
}
body := strings.Join(strings.Split(rest, "\n")[end+1:], "\n")
body = strings.TrimPrefix(body, "\n")
meta.Extra = map[string]string{}
for _, line := range block {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
return meta, "", fmt.Errorf("frontmatter: not a key: value line: %q", line)
}
key = strings.TrimSpace(key)
value = strings.Trim(strings.TrimSpace(value), `"'`)
switch strings.ToLower(key) {
case "title":
meta.Title = value
case "subtitle":
meta.Subtitle = value
case "author":
meta.Author = value
case "date":
meta.Date = value
case "classification":
meta.Classification = value
case "template":
meta.Template = value
default:
meta.Extra[key] = value
}
}
return meta, body, nil
}
+58
View File
@@ -0,0 +1,58 @@
package frontmatter
import "testing"
func TestSplitFull(t *testing.T) {
meta, body, err := Split("---\ntitle: Q3 Brief\nsubtitle: Ops\nclassification: INTERNAL\ntemplate: brief\nauthor: A B\ndate: 2026-08-29\nextra: kept\n---\n\n# Body\n")
if err != nil {
t.Fatalf("err: %v", err)
}
if meta.Title != "Q3 Brief" || meta.Subtitle != "Ops" || meta.Classification != "INTERNAL" ||
meta.Template != "brief" || meta.Author != "A B" || meta.Date != "2026-08-29" {
t.Fatalf("meta: %+v", meta)
}
if meta.Extra["extra"] != "kept" {
t.Fatalf("extra keys dropped: %+v", meta.Extra)
}
if body != "# Body\n" {
t.Fatalf("body: %q", body)
}
}
func TestSplitQuoted(t *testing.T) {
meta, _, err := Split("---\ntitle: \"The: Quoted Title\"\n---\nx")
if err != nil {
t.Fatalf("err: %v", err)
}
if meta.Title != "The: Quoted Title" {
t.Fatalf("title: %q", meta.Title)
}
}
func TestSplitNone(t *testing.T) {
meta, body, err := Split("# Just markdown\n\nBody.")
if err != nil || meta.Title != "" || body != "# Just markdown\n\nBody." {
t.Fatalf("meta=%+v body=%q err=%v", meta, body, err)
}
}
func TestSplitUnclosed(t *testing.T) {
_, _, err := Split("---\ntitle: x\n")
if err == nil {
t.Fatal("want error for unclosed block")
}
}
func TestSplitBadLine(t *testing.T) {
_, _, err := Split("---\nnope\n---\n")
if err == nil {
t.Fatal("want error for non key: value line")
}
}
func TestSplitCommentsBlank(t *testing.T) {
_, body, err := Split("---\n# comment\n\ntitle: t\n---\nbody here")
if err != nil || body != "body here" {
t.Fatalf("body=%q err=%v", body, err)
}
}
+338
View File
@@ -0,0 +1,338 @@
// Package markdown parses the markdown subset mopac-pdf typesets: headings,
// paragraphs, lists (ordered/unordered, one nesting level), GFM pipe tables,
// fenced code blocks, chart blocks, blockquotes, horizontal rules, images and
// inline bold/italic/code/link. It is intentionally small: the input currency
// of this stack is Redmine notes, Discourse posts and briefing output, all of
// which are plain, readable markdown.
package markdown
import (
"strconv"
"strings"
)
// Block kinds emitted by Parse.
type Kind int
const (
KindParagraph Kind = iota
KindHeading
KindList
KindTable
KindCode
KindChart
KindQuote
KindRule
KindImage
)
// Block is one top-level block of the document.
type Block struct {
Kind Kind
Level int // heading level 1..6
Text string // paragraph / heading / quote text (raw inline)
Ordered bool // list
Items []string // list item text (raw inline)
Header []string // table: column labels (raw inline)
Rows [][]string
Align []string // table: per-column "left"|"center"|"right"
Language string // code fence info string
Lines []string // code body lines (verbatim)
Chart *Chart // chart block
Alt string // image alt/caption
Path string // image path
}
// Chart is a parsed ```chart fenced data block. Labels/Values are parallel
// slices in input order.
type Chart struct {
Type string
Title string
Unit string
Labels []string
Values []float64
WidthPct int // embed width in percent of text column (default 78)
OtherPairs map[string]string
}
// Parse converts markdown source into blocks. Front-matter must already be
// stripped by the caller. Unknown constructs degrade to paragraphs.
func Parse(src string) []Block {
lines := strings.Split(strings.ReplaceAll(src, "\r\n", "\n"), "\n")
var blocks []Block
i := 0
for i < len(lines) {
line := lines[i]
trimmed := strings.TrimSpace(line)
switch {
case trimmed == "":
i++
case strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~"):
block, next := parseFence(lines, i, trimmed)
blocks = append(blocks, block)
i = next
case isHeading(trimmed):
level := len(trimmed) - len(strings.TrimLeft(trimmed, "#"))
text := strings.TrimSpace(strings.Trim(trimmed[level:], "#"))
text = strings.TrimSpace(text)
blocks = append(blocks, Block{Kind: KindHeading, Level: level, Text: text})
i++
case trimmed == "---" || trimmed == "***" || trimmed == "___":
blocks = append(blocks, Block{Kind: KindRule})
i++
case isTableLine(trimmed) && i+1 < len(lines) && isTableSeparator(lines[i+1]):
block, next := parseTable(lines, i)
blocks = append(blocks, block)
i = next
case isListLine(trimmed):
block, next := parseList(lines, i)
blocks = append(blocks, block)
i = next
case strings.HasPrefix(trimmed, ">"):
var parts []string
for i < len(lines) {
t := strings.TrimSpace(lines[i])
if !strings.HasPrefix(t, ">") {
break
}
parts = append(parts, strings.TrimSpace(strings.TrimPrefix(t, ">")))
i++
}
blocks = append(blocks, Block{Kind: KindQuote, Text: strings.Join(parts, " ")})
case isImageLine(trimmed):
alt, path := parseImage(trimmed)
blocks = append(blocks, Block{Kind: KindImage, Alt: alt, Path: path})
i++
default:
var parts []string
for i < len(lines) {
t := strings.TrimSpace(lines[i])
if t == "" || isHeading(t) || isListLine(t) || isTableLine(t) ||
strings.HasPrefix(t, "```") || strings.HasPrefix(t, ">") ||
t == "---" || isImageLine(t) {
break
}
parts = append(parts, t)
i++
}
blocks = append(blocks, Block{Kind: KindParagraph, Text: strings.Join(parts, " ")})
}
}
return blocks
}
func isHeading(t string) bool {
if !strings.HasPrefix(t, "#") {
return false
}
level := len(t) - len(strings.TrimLeft(t, "#"))
return level >= 1 && level <= 6 && len(t) > level && t[level] == ' '
}
func isListLine(t string) bool {
if strings.HasPrefix(t, "- ") || strings.HasPrefix(t, "* ") || strings.HasPrefix(t, "+ ") {
return true
}
label, rest, ok := strings.Cut(t, ". ")
return ok && rest != "" && isOrderedLabel(label)
}
func isOrderedLabel(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
func isTableLine(t string) bool {
return strings.HasPrefix(t, "|") && strings.HasSuffix(t, "|") && strings.Count(t, "|") >= 2
}
func isTableSeparator(t string) bool {
t = strings.TrimSpace(t)
if !isTableLine(t) {
return false
}
cells := splitRow(t)
for _, c := range cells {
c = strings.TrimSpace(c)
if c == "" {
return false
}
if !strings.ContainsAny(c, "-") {
return false
}
for _, r := range c {
if r != '-' && r != ':' && r != ' ' {
return false
}
}
}
return true
}
func isImageLine(t string) bool {
return strings.HasPrefix(t, "![") && strings.Contains(t, "](") && strings.HasSuffix(t, ")")
}
func parseImage(t string) (alt, path string) {
inner := strings.TrimSuffix(strings.TrimPrefix(t, "!["), ")")
alt, path, _ = strings.Cut(inner, "](")
return alt, path
}
func parseFence(lines []string, i int, fence string) (Block, int) {
info := strings.TrimSpace(strings.Trim(fence, "`~ "))
var body []string
j := i + 1
for j < len(lines) {
t := strings.TrimSpace(lines[j])
if isClosingFence(t, fence) {
break
}
body = append(body, lines[j])
j++
}
if info == "chart" {
if chart := parseChart(body); chart != nil {
return Block{Kind: KindChart, Chart: chart}, j + 1
}
}
return Block{Kind: KindCode, Language: info, Lines: body}, j + 1
}
func isClosingFence(t, fence string) bool {
if t == fence {
return true
}
marker := "`"
if strings.HasPrefix(fence, "~") {
marker = "~"
}
if strings.Trim(fence, marker) == "" {
return strings.Trim(t, marker) == "" && len(t) >= len(fence)
}
// Opening fence carried an info string (e.g. ```chart): the closer is a
// bare run of the marker at least as long as the opening run.
openLen := len(fence) - len(strings.TrimLeft(fence, marker))
return strings.Trim(t, marker) == "" && len(t) >= openLen
}
func parseChart(body []string) *Chart {
c := &Chart{Type: "bar", WidthPct: 78, OtherPairs: map[string]string{}}
for _, raw := range body {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
switch strings.ToLower(key) {
case "type":
c.Type = strings.ToLower(value)
case "title":
c.Title = value
case "unit":
c.Unit = value
case "width":
if n, err := strconv.Atoi(strings.TrimSuffix(value, "%")); err == nil && n > 10 && n <= 100 {
c.WidthPct = n
}
default:
if num, err := strconv.ParseFloat(value, 64); err == nil && key != "" {
c.Labels = append(c.Labels, key)
c.Values = append(c.Values, num)
} else {
c.OtherPairs[key] = value
}
}
}
if len(c.Labels) == 0 {
return nil
}
return c
}
func parseList(lines []string, i int) (Block, int) {
block := Block{Kind: KindList}
first := strings.TrimSpace(lines[i])
_, rest, _ := strings.Cut(first, ". ")
if strings.HasPrefix(first, "- ") || strings.HasPrefix(first, "* ") || strings.HasPrefix(first, "+ ") {
rest = first[2:]
} else {
block.Ordered = true
}
block.Items = append(block.Items, rest)
i++
for i < len(lines) {
t := strings.TrimSpace(lines[i])
if t == "" {
break
}
if !isListLine(t) {
break
}
_, item, _ := strings.Cut(t, ". ")
if strings.HasPrefix(t, "- ") || strings.HasPrefix(t, "* ") || strings.HasPrefix(t, "+ ") {
item = t[2:]
}
block.Items = append(block.Items, item)
i++
}
return block, i
}
func splitRow(t string) []string {
t = strings.TrimSpace(t)
t = strings.TrimPrefix(t, "|")
t = strings.TrimSuffix(t, "|")
raw := strings.Split(t, "|")
cells := make([]string, len(raw))
for i, c := range raw {
cells[i] = strings.TrimSpace(c)
}
return cells
}
func parseTable(lines []string, i int) (Block, int) {
header := splitRow(lines[i])
sep := splitRow(lines[i+1])
align := make([]string, len(sep))
for col, s := range sep {
left := strings.HasPrefix(s, ":")
right := strings.HasSuffix(s, ":")
switch {
case left && right:
align[col] = "center"
case right:
align[col] = "right"
default:
align[col] = "left"
}
}
block := Block{Kind: KindTable, Header: header, Align: align}
j := i + 2
for j < len(lines) {
t := strings.TrimSpace(lines[j])
if !isTableLine(t) {
break
}
row := splitRow(t)
for len(row) < len(header) {
row = append(row, "")
}
row = row[:len(header)]
block.Rows = append(block.Rows, row)
j++
}
return block, j
}
+80
View File
@@ -0,0 +1,80 @@
package markdown
import (
"reflect"
"testing"
)
func TestParseKitchenSink(t *testing.T) {
src := "# Title\n\nIntro with **bold**.\n\n- a\n- b\n\n1. one\n2. two\n\n> quoted\n\n---\n\n```go\nfmt.Println(1)\n```\n\n| H1 | H2 |\n|:--|--:|\n| a | b |\n\n```chart\ntype: bar\ntitle: T\nQ1: 1\nQ2: 2\n```\n"
blocks := Parse(src)
var kinds []Kind
for _, b := range blocks {
kinds = append(kinds, b.Kind)
}
want := []Kind{KindHeading, KindParagraph, KindList, KindList, KindQuote, KindRule, KindCode, KindTable, KindChart}
if !reflect.DeepEqual(kinds, want) {
t.Fatalf("kinds = %v want %v", kinds, want)
}
l := blocks[2]
if l.Ordered || !reflect.DeepEqual(l.Items, []string{"a", "b"}) {
t.Fatalf("ul: %+v", l)
}
ol := blocks[3]
if !ol.Ordered || !reflect.DeepEqual(ol.Items, []string{"one", "two"}) {
t.Fatalf("ol: %+v", ol)
}
code := blocks[6]
if code.Language != "go" || !reflect.DeepEqual(code.Lines, []string{"fmt.Println(1)"}) {
t.Fatalf("code: %+v", code)
}
tbl := blocks[7]
if !reflect.DeepEqual(tbl.Header, []string{"H1", "H2"}) || !reflect.DeepEqual(tbl.Align, []string{"left", "right"}) {
t.Fatalf("table: %+v", tbl)
}
if len(tbl.Rows) != 1 || !reflect.DeepEqual(tbl.Rows[0], []string{"a", "b"}) {
t.Fatalf("rows: %+v", tbl.Rows)
}
ch := blocks[8].Chart
if ch.Type != "bar" || ch.Title != "T" || !reflect.DeepEqual(ch.Labels, []string{"Q1", "Q2"}) {
t.Fatalf("chart: %+v", ch)
}
if !reflect.DeepEqual(ch.Values, []float64{1, 2}) {
t.Fatalf("values: %v", ch.Values)
}
}
func TestParseParagraphGrouping(t *testing.T) {
blocks := Parse("line one\nline two\n\nsecond para")
if len(blocks) != 2 || blocks[0].Text != "line one line two" || blocks[1].Text != "second para" {
t.Fatalf("%+v", blocks)
}
}
func TestParseImage(t *testing.T) {
blocks := Parse("![A caption](pics/x.png)")
if len(blocks) != 1 || blocks[0].Kind != KindImage || blocks[0].Alt != "A caption" || blocks[0].Path != "pics/x.png" {
t.Fatalf("%+v", blocks)
}
}
func TestParseChartMissingData(t *testing.T) {
blocks := Parse("```chart\ntitle: empty\n```")
if len(blocks) != 1 || blocks[0].Kind != KindCode {
t.Fatalf("chart without data must degrade to code block: %+v", blocks)
}
}
func TestParseHeadingLevels(t *testing.T) {
blocks := Parse("## Two\n\n#### Four\n")
if len(blocks) != 2 || blocks[0].Level != 2 || blocks[0].Text != "Two" || blocks[1].Level != 4 || blocks[1].Text != "Four" {
t.Fatalf("%+v", blocks)
}
}
func TestParseNoHeadingWithoutSpace(t *testing.T) {
blocks := Parse("#hashtag not heading")
if len(blocks) != 1 || blocks[0].Kind != KindParagraph {
t.Fatalf("%+v", blocks)
}
}
+38
View File
@@ -0,0 +1,38 @@
// Package pdfinfo extracts basic facts from PDF bytes with a tiny parser —
// no third-party PDF library, just enough to count pages in golden tests
// and smoke checks.
package pdfinfo
import (
"bytes"
"fmt"
"regexp"
"strconv"
)
// Pages returns the page count of a PDF by scanning for the /Type /Pages
// objects' /Count entries (the catalog root carries the total; we take the
// maximum to be safe with nested page trees).
func Pages(pdf []byte) (int, error) {
if len(pdf) == 0 || !bytes.HasPrefix(pdf, []byte("%PDF-")) {
return 0, fmt.Errorf("pdfinfo: not a PDF (missing %%%%PDF- header)")
}
re := regexp.MustCompile(`/Type\s*/Pages[^>]*?/Count\s+(\d+)`)
best := 0
for _, m := range re.FindAllStringSubmatch(string(pdf), -1) {
if n, err := strconv.Atoi(m[1]); err == nil && n > best {
best = n
}
}
if best == 0 {
// Compressed object streams can hide the count; require at least
// the header then fall back to /Type /Page occurrences (\b keeps
// /Pages from matching).
pageRe := regexp.MustCompile(`/Type\s*/Page\b`)
best = len(pageRe.FindAll(pdf, -1))
if best == 0 {
return 0, fmt.Errorf("pdfinfo: no page count found")
}
}
return best, nil
}
+58
View File
@@ -0,0 +1,58 @@
package pdfinfo
import (
"fmt"
"strings"
"testing"
)
// fakePDF builds a minimal but structurally scannable PDF with a page-tree
// /Count and N /Type /Page objects.
func fakePDF(pages, declared int) []byte {
var objs []string
for i := 1; i <= pages; i++ {
objs = append(objs, fmt.Sprintf("%d 0 obj<</Type/Page/Parent 99 0 R>>endobj", i))
}
objs = append(objs, fmt.Sprintf("99 0 obj<</Type/Pages/Count %d/Kids[1 0 R]>>endobj", declared))
body := strings.Join(objs, "\n")
return []byte("%PDF-1.7\n" + body + "\n%%EOF")
}
func TestPagesCount(t *testing.T) {
for _, n := range []int{1, 2, 7, 23} {
got, err := Pages(fakePDF(n, n))
if err != nil {
t.Fatalf("n=%d: %v", n, err)
}
if got != n {
t.Fatalf("n=%d got %d", n, got)
}
}
}
func TestPagesFallsBackToPageObjects(t *testing.T) {
// No /Pages /Count reachable: count /Type /Page objects instead.
pdf := []byte("%PDF-1.7\n1 0 obj<</Type/Page>>endobj\n2 0 obj<</Type/Page>>endobj\n%%EOF")
got, err := Pages(pdf)
if err != nil || got != 2 {
t.Fatalf("got=%d err=%v", got, err)
}
}
func TestPagesRejectsNonPDF(t *testing.T) {
if _, err := Pages([]byte("hello")); err == nil {
t.Fatal("want error for non-PDF")
}
if _, err := Pages(nil); err == nil {
t.Fatal("want error for empty input")
}
}
func TestPagesDoesNotCountPagesType(t *testing.T) {
// "/Type /Pages" must not match the /Type /Page fallback.
pdf := []byte("%PDF-1.7\n1 0 obj<</Type/Page>>endobj\n2 0 obj<</Type/Pages/Count 5>>endobj\n%%EOF")
got, err := Pages(pdf)
if err != nil || got != 5 {
t.Fatalf("got=%d err=%v", got, err)
}
}
+229
View File
@@ -0,0 +1,229 @@
// Package typdoc renders parsed markdown blocks into typst markup and
// composes the full compile-ready document around a named template.
package typdoc
import (
"fmt"
"strings"
"ukrrs.com/mopac/pdf/internal/frontmatter"
"ukrrs.com/mopac/pdf/internal/markdown"
)
// Assets collects files that must exist in the compile root (chart PNGs,
// copied source images), keyed by their root-relative path.
type Assets map[string][]byte
// Funcs lets the caller provide asset producers: RenderChart turns a parsed
// chart block into PNG bytes for the given asset name; StageImage copies a
// markdown-referenced image and returns the asset name it was staged under.
type Funcs struct {
RenderChart func(c *markdown.Chart, name string) ([]byte, error)
StageImage func(path, name string) ([]byte, error)
}
// RenderBody converts blocks to typst markup. Chart and image blocks become
// root-relative figure references; their bytes land in assets.
func RenderBody(blocks []markdown.Block, assets Assets, fn Funcs) (string, error) {
var b strings.Builder
chartNo, imgNo := 0, 0
for _, blk := range blocks {
switch blk.Kind {
case markdown.KindHeading:
fmt.Fprintf(&b, "%s %s\n\n", strings.Repeat("=", blk.Level), inline(blk.Text))
case markdown.KindParagraph:
b.WriteString(inline(blk.Text))
b.WriteString("\n\n")
case markdown.KindList:
marker := "-"
if blk.Ordered {
marker = "+"
}
for _, item := range blk.Items {
fmt.Fprintf(&b, "%s %s\n", marker, inline(item))
}
b.WriteString("\n")
case markdown.KindQuote:
fmt.Fprintf(&b, "#quote(block: true)[%s]\n\n", inline(blk.Text))
case markdown.KindRule:
b.WriteString("#line(length: 100%, stroke: 0.5pt + gray)\n\n")
case markdown.KindCode:
b.WriteString("```" + blk.Language + "\n")
for _, line := range blk.Lines {
b.WriteString(line)
b.WriteString("\n")
}
b.WriteString("```\n\n")
case markdown.KindTable:
b.WriteString(renderTable(blk))
b.WriteString("\n\n")
case markdown.KindChart:
chartNo++
name := fmt.Sprintf("chart-%d.png", chartNo)
png, err := fn.RenderChart(blk.Chart, name)
if err != nil {
return "", fmt.Errorf("chart %d: %w", chartNo, err)
}
assets[name] = png
caption := ""
if blk.Chart.Title != "" {
caption = fmt.Sprintf(", caption: [%s]", inline(blk.Chart.Title))
}
fmt.Fprintf(&b, "#figure(image(\"%s\", width: %d%%)%s)\n\n", name, blk.Chart.WidthPct, caption)
case markdown.KindImage:
imgNo++
name := fmt.Sprintf("img-%d.png", imgNo)
data, err := fn.StageImage(blk.Path, name)
if err != nil {
return "", fmt.Errorf("image %q: %w", blk.Path, err)
}
assets[name] = data
caption := ""
if blk.Alt != "" {
caption = fmt.Sprintf(", caption: [%s]", inline(blk.Alt))
}
fmt.Fprintf(&b, "#figure(image(\"%s\", width: 78%%)%s)\n\n", name, caption)
}
}
return b.String(), nil
}
// Compose wraps a rendered body with the template import/show preamble.
// tmplName selects both the template file (templates/<name>.typ) and the
// show function exported by that file.
func Compose(tmplName string, meta frontmatter.Meta, body string) string {
var b strings.Builder
fmt.Fprintf(&b, "#import \"/template.typ\": %s\n", tmplName)
b.WriteString("#show: " + tmplName + ".with(\n")
fmt.Fprintf(&b, " title: %s,\n", typstString(meta.Title))
fmt.Fprintf(&b, " subtitle: %s,\n", typstString(meta.Subtitle))
fmt.Fprintf(&b, " author: %s,\n", typstString(meta.Author))
fmt.Fprintf(&b, " date: %s,\n", typstString(meta.Date))
fmt.Fprintf(&b, " classification: %s,\n", typstString(meta.Classification))
b.WriteString(")\n\n")
b.WriteString(body)
return b.String()
}
// typstString renders a Go string as a typst string literal.
func typstString(s string) string {
return "\"" + strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(s) + "\""
}
func renderTable(blk markdown.Block) string {
n := len(blk.Header)
if n == 0 {
return ""
}
cols := make([]string, n)
for i := range cols {
cols[i] = "1fr"
}
aligns := make([]string, 0, n)
for _, a := range blk.Align {
switch a {
case "center":
aligns = append(aligns, "center")
case "right":
aligns = append(aligns, "right")
default:
aligns = append(aligns, "left")
}
}
var b strings.Builder
b.WriteString("#table(\n")
fmt.Fprintf(&b, " columns: (%s),\n", strings.Join(cols, ", "))
b.WriteString(" inset: 6.5pt,\n")
b.WriteString(" stroke: 0.5pt + rgb(\"#c9d3dd\"),\n")
fmt.Fprintf(&b, " align: (%s),\n", strings.Join(aligns, ", "))
b.WriteString(" table.header(\n")
for _, h := range blk.Header {
fmt.Fprintf(&b, " table.cell(fill: rgb(\"#24425c\"))[#text(fill: white, weight: \"bold\", size: 9pt)[%s]],\n", inline(h))
}
b.WriteString(" ),\n")
for _, row := range blk.Rows {
for _, cell := range row {
fmt.Fprintf(&b, " [%s],\n", inline(cell))
}
}
b.WriteString(")\n")
return b.String()
}
// inline converts inline markdown (bold, italic, code, links) into typst
// markup, escaping typst-special characters in plain text runs.
func inline(s string) string {
var b strings.Builder
i := 0
for i < len(s) {
switch {
case strings.HasPrefix(s[i:], "**") && strings.Contains(s[i+2:], "**"):
end := strings.Index(s[i+2:], "**")
b.WriteString("*")
b.WriteString(inline(s[i+2 : i+2+end]))
b.WriteString("*")
i += 2 + end + 2
case s[i] == '*' && strings.Contains(s[i+1:], "*"):
end := strings.Index(s[i+1:], "*")
b.WriteString("_")
b.WriteString(inline(s[i+1 : i+1+end]))
b.WriteString("_")
i += 1 + end + 1
case s[i] == '`' && strings.Contains(s[i+1:], "`"):
end := strings.Index(s[i+1:], "`")
b.WriteString("`" + s[i+1:i+1+end] + "`")
i += 1 + end + 1
case s[i] == '[':
text, url, length := parseLink(s[i:])
if length > 0 {
fmt.Fprintf(&b, "#link(%s)[%s]", typstString(url), inline(text))
i += length
} else {
b.WriteString("\\[")
i++
}
default:
b.WriteString(escapeChar(s[i]))
i++
}
}
return b.String()
}
// parseLink matches [text](url) at the start of s (url has no spaces or
// nested parens). Returns the text, url and consumed length; length 0 = no
// match.
func parseLink(s string) (text, url string, length int) {
end := strings.Index(s, "](")
if end < 1 {
return "", "", 0
}
close := strings.Index(s[end+2:], ")")
if close < 0 {
return "", "", 0
}
url = s[end+2 : end+2+close]
if url == "" || strings.ContainsAny(url, "()[] \"\t") {
return "", "", 0
}
return s[1:end], url, end + 2 + close + 1
}
var plainEscaper = strings.NewReplacer(
`\`, `\\`,
`#`, `\#`,
`$`, `\$`,
`@`, `\@`,
`<`, `\<`,
`>`, `\>`,
`[`, `\[`,
`]`, `\]`,
`*`, `\*`,
`_`, `\_`,
"`", "\\`",
`~`, `\~`,
)
func escapeText(s string) string { return plainEscaper.Replace(s) }
func escapeChar(c byte) string { return plainEscaper.Replace(string(c)) }
+93
View File
@@ -0,0 +1,93 @@
package typdoc
import (
"flag"
"os"
"path/filepath"
"testing"
"ukrrs.com/mopac/pdf/internal/frontmatter"
"ukrrs.com/mopac/pdf/internal/markdown"
)
var update = flag.Bool("update", false, "rewrite golden files")
// Golden: fixture markdown -> composed typst document. The chart renderer
// and image stager are stubbed with deterministic bytes; golden files pin
// the emitted typst markup and template composition.
func TestGoldenCompose(t *testing.T) {
for _, fixture := range []string{"sample-report", "sample-brief"} {
t.Run(fixture, func(t *testing.T) {
src, err := os.ReadFile(filepath.Join("..", "..", "testdata", fixture+".md"))
if err != nil {
t.Fatal(err)
}
meta, body, err := frontmatter.Split(string(src))
if err != nil {
t.Fatal(err)
}
name := meta.Template
if name == "" {
name = "report"
}
assets := Assets{}
rendered, err := RenderBody(markdown.Parse(body), assets, Funcs{
RenderChart: func(*markdown.Chart, string) ([]byte, error) { return []byte("PNG-STUB"), nil },
StageImage: func(string, string) ([]byte, error) { return []byte("IMG-STUB"), nil },
})
if err != nil {
t.Fatal(err)
}
doc := Compose(name, meta, rendered)
golden := filepath.Join("..", "..", "testdata", fixture+".typ.golden")
if *update {
if err := os.WriteFile(golden, []byte(doc), 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("golden missing (run go test ./internal/typdoc -update): %v", err)
}
if doc != string(want) {
t.Errorf("composed typst drifted from golden %s", golden)
}
if len(assets) == 0 {
t.Errorf("expected chart asset to be staged")
}
})
}
}
func TestInlineEscapes(t *testing.T) {
cases := map[string]string{
"a # b": `a \# b`,
"cost $5 @here": `cost \$5 \@here`,
"x [y] z": `x \[y\] z`,
"**b** and *i*": `*b* and _i_`,
"`c`": "`c`",
"[t](https://x.co/a)": `#link("https://x.co/a")[t]`,
"see [docs](/a/b) here": `see #link("/a/b")[docs] here`,
"~tilde": `\~tilde`,
}
for in, want := range cases {
if got := inline(in); got != want {
t.Errorf("inline(%q) = %q want %q", in, got, want)
}
}
}
func TestTypstString(t *testing.T) {
if got := typstString(`a "b" \c` + "\n"); got != `"a \"b\" \\c\n"` {
t.Fatalf("got %s", got)
}
}
func TestComposePreamble(t *testing.T) {
doc := Compose("brief", frontmatter.Meta{Title: "T"}, "BODY")
want := "#import \"/template.typ\": brief\n#show: brief.with(\n title: \"T\",\n subtitle: \"\",\n author: \"\",\n date: \"\",\n classification: \"\",\n)\n\nBODY"
if doc != want {
t.Fatalf("got:\n%s", doc)
}
}