Files
mopac-pdf/internal/markdown/markdown.go
T
mrcharles 10e930e46d 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
2026-08-29 05:44:24 -05:00

339 lines
8.6 KiB
Go

// 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
}