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