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,77 @@
|
||||
// brief.typ — mopac-pdf "brief" template: dense 1-3 page executive brief.
|
||||
// No title page, no TOC; compact type, tight tables, classification footer.
|
||||
#let brief(
|
||||
title: "Untitled",
|
||||
subtitle: "",
|
||||
author: "",
|
||||
date: "",
|
||||
classification: "",
|
||||
doc,
|
||||
) = {
|
||||
let accent = rgb("#1f4e79")
|
||||
let ink = rgb("#1a1a1a")
|
||||
|
||||
set page(
|
||||
paper: "a4",
|
||||
margin: (top: 1.9cm, bottom: 2.0cm, x: 1.9cm),
|
||||
)
|
||||
set text(font: "Libertinus Serif", size: 10pt, fill: ink, lang: "en")
|
||||
set par(justify: true, leading: 0.55em, spacing: 0.9em)
|
||||
set heading(numbering: none)
|
||||
show heading: set block(above: 1.05em, below: 0.5em)
|
||||
show heading.where(level: 1): it => {
|
||||
text(size: 13.5pt, fill: accent, weight: "bold")[#it]
|
||||
v(-0.5em)
|
||||
line(length: 100%, stroke: 0.8pt + accent)
|
||||
v(0.2em)
|
||||
}
|
||||
show heading.where(level: 2): it => text(size: 11pt, weight: "bold")[#it]
|
||||
show heading: it => {
|
||||
if it.level >= 3 { text(size: 10pt, weight: "bold", style: "italic")[#it] } else { it }
|
||||
}
|
||||
// Dense tables: the body renderer already styles headers; shrink body text.
|
||||
show table: set text(size: 9pt)
|
||||
show raw: set text(size: 8.5pt)
|
||||
|
||||
set page(footer: context {
|
||||
set text(size: 8pt, fill: luma(95))
|
||||
grid(
|
||||
columns: (1fr, auto, 1fr),
|
||||
align: (left + horizon, center + horizon, right + horizon),
|
||||
text(style: "italic")[#title],
|
||||
counter(page).display(),
|
||||
if classification != "" [#classification],
|
||||
)
|
||||
})
|
||||
|
||||
// Title block (no separate page).
|
||||
block(above: 0em, below: 1.1em)[
|
||||
#set text(size: 10pt)
|
||||
#grid(
|
||||
columns: (1fr, auto),
|
||||
align: (left + horizon, right + horizon),
|
||||
[
|
||||
#text(size: 19pt, weight: "bold", fill: ink)[#title]
|
||||
#if subtitle != "" [
|
||||
#v(0.25em)
|
||||
#text(size: 11pt, fill: luma(80))[#subtitle]
|
||||
]
|
||||
#v(0.3em)
|
||||
#text(size: 9.5pt, fill: luma(70))[
|
||||
#if author != "" [#author]
|
||||
#if author != "" and date != "" [#h(1.2em)]
|
||||
#if date != "" [#date]
|
||||
]
|
||||
],
|
||||
if classification != "" [
|
||||
#box(stroke: 0.8pt + accent, inset: (x: 0.55em, y: 0.35em), radius: 2pt)[
|
||||
#text(size: 8.5pt, fill: accent, tracking: 1.5pt, weight: "semibold")[#upper(classification)]
|
||||
]
|
||||
],
|
||||
)
|
||||
#v(0.5em)
|
||||
#line(length: 100%, stroke: 1.4pt + accent)
|
||||
]
|
||||
|
||||
doc
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// report.typ — mopac-pdf "report" template: clean business report with
|
||||
// title page, TOC, numbered headings, headers/footers with classification.
|
||||
#let report(
|
||||
title: "Untitled",
|
||||
subtitle: "",
|
||||
author: "",
|
||||
date: "",
|
||||
classification: "",
|
||||
doc,
|
||||
) = {
|
||||
let accent = rgb("#1f4e79")
|
||||
let ink = rgb("#1a1a1a")
|
||||
let rule = luma(205)
|
||||
|
||||
set page(
|
||||
paper: "a4",
|
||||
margin: (top: 2.6cm, bottom: 2.6cm, x: 2.4cm),
|
||||
)
|
||||
set text(font: "Libertinus Serif", size: 10.5pt, fill: ink, lang: "en")
|
||||
set par(justify: true, leading: 0.65em, spacing: 1.15em)
|
||||
set heading(numbering: "1.1.")
|
||||
show heading: set block(above: 1.5em, below: 1.0em)
|
||||
show heading.where(level: 1): it => {
|
||||
text(size: 16pt, fill: accent, weight: "bold")[#it]
|
||||
v(-0.6em)
|
||||
line(length: 100%, stroke: 0.8pt + accent)
|
||||
v(0.3em)
|
||||
}
|
||||
show heading.where(level: 2): it => {
|
||||
text(size: 13pt, weight: "bold")[#it]
|
||||
v(-0.55em)
|
||||
line(length: 100%, stroke: 0.4pt + rule)
|
||||
v(0.25em)
|
||||
}
|
||||
show heading.where(level: 3): it => text(size: 11.5pt, weight: "bold")[#it]
|
||||
show heading: it => {
|
||||
if it.level >= 4 { text(size: 10.5pt, weight: "bold", style: "italic")[#it] } else { it }
|
||||
}
|
||||
|
||||
// Running header from page 2; footer everywhere with classification.
|
||||
set page(header: context {
|
||||
if counter(page).get().first() > 1 [
|
||||
#set text(size: 8.5pt, fill: luma(95))
|
||||
#grid(
|
||||
columns: (1fr, auto),
|
||||
align: (left + horizon, right + horizon),
|
||||
text(style: "italic")[#title],
|
||||
if classification != "" [#classification],
|
||||
)
|
||||
#v(-0.55em)
|
||||
#line(length: 100%, stroke: 0.4pt + rule)
|
||||
]
|
||||
})
|
||||
set page(footer: context {
|
||||
set text(size: 8.5pt, fill: luma(95))
|
||||
grid(
|
||||
columns: (1fr, auto, 1fr),
|
||||
align: (left + horizon, center + horizon, right + horizon),
|
||||
if date != "" [#date],
|
||||
counter(page).display("1 / 1", both: true),
|
||||
if classification != "" [#classification],
|
||||
)
|
||||
})
|
||||
|
||||
// Title page.
|
||||
align(center + horizon)[
|
||||
#v(1fr)
|
||||
#block(width: 76%)[
|
||||
#if classification != "" [
|
||||
#text(size: 10pt, fill: accent, tracking: 2.5pt, weight: "semibold")[#upper(classification)]
|
||||
#v(1.4em)
|
||||
]
|
||||
#text(size: 26pt, weight: "bold", fill: ink)[#title]
|
||||
#if subtitle != "" [#v(0.6em) #text(size: 14pt, fill: luma(80))[#subtitle]]
|
||||
#v(1.3em)
|
||||
#line(length: 30%, stroke: 1.2pt + accent)
|
||||
#v(1.3em)
|
||||
#text(size: 11pt, fill: luma(60))[
|
||||
#if author != "" [#author]
|
||||
#if author != "" and date != "" [#h(1.6em)]
|
||||
#if date != "" [#date]
|
||||
]
|
||||
]
|
||||
#v(1fr)
|
||||
]
|
||||
pagebreak()
|
||||
|
||||
// TOC when there is structure to show.
|
||||
context {
|
||||
let heads = query(heading)
|
||||
if heads.len() >= 2 [
|
||||
#outline(title: [Table of Contents], depth: 2)
|
||||
#pagebreak()
|
||||
]
|
||||
}
|
||||
|
||||
doc
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Package templates embeds the shipped typst templates so the mopac-pdf
|
||||
// binary is self-contained: the engine container only ever sees the
|
||||
// prepared compile root.
|
||||
package templates
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed *.typ
|
||||
var embedded embed.FS
|
||||
|
||||
// Names lists the shipped template names ("report", "brief").
|
||||
func Names() []string {
|
||||
entries, err := embedded.ReadDir(".")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".typ") {
|
||||
names = append(names, strings.TrimSuffix(e.Name(), ".typ"))
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Source returns the template source for name: from dir when set (custom
|
||||
// template drop-in), else the embedded copy.
|
||||
func Source(dir, name string) (string, error) {
|
||||
if strings.ContainsAny(name, `/\.`) || name == "" {
|
||||
return "", fmt.Errorf("template: invalid name %q", name)
|
||||
}
|
||||
if dir != "" {
|
||||
data, err := os.ReadFile(filepath.Join(dir, name+".typ"))
|
||||
if err == nil {
|
||||
return string(data), nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
data, err := embedded.ReadFile(name + ".typ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("template %q not found (shipped: %s)", name, strings.Join(Names(), ", "))
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
Reference in New Issue
Block a user