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:
@@ -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)) }
|
||||
Reference in New Issue
Block a user