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