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,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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.HasSuffix(t, ")")
|
||||
}
|
||||
|
||||
func parseImage(t string) (alt, path string) {
|
||||
inner := strings.TrimSuffix(strings.TrimPrefix(t, "
|
||||
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
|
||||
}
|
||||
@@ -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("")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user