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
+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)
}
}