oficina / docs Public

PDF export, with the fonts embedded

internal/pdf writes PDF files: objects, a cross-reference table, Flate
streams, and a Type0/CIDFontType2 font with Identity-H encoding. It
knows nothing about documents — internal/export does the laying out and
calls it — because the code that has to be right about byte offsets
should not also be the code deciding where a line breaks.

Composite fonts rather than a simple /TrueType with WinAnsiEncoding,
which is more machinery for two reasons worth it: any character the face
has a glyph for rather than the 224 a single-byte encoding can name (a
café and an em dash are ordinary), and glyph ids addressed directly,
which is what the subsetter already produces, so CIDToGIDMap is
/Identity and there is no mapping table to get wrong. The cost is that
the content stream holds glyph ids, which is what /ToUnicode buys back:
without it, selecting and copying text out of the export yields
nonsense, and a PDF whose text cannot be copied is a picture.

internal/export/pdf.go lays a document out with the page geometry
show.html uses — Letter, 1in margins, 12pt/1.6 — measuring every line
with the same font programs the browser is served. Baselines come from
the faces' own ascent and descent through the CSS half-leading rule
rather than a fraction of the line height, which is what makes a heading
land where the heading on screen lands. Vertical margins collapse, a
first child drops its top margin, a word too long for the column is
broken rather than run off the paper, and a blockquote's rule is drawn
per line so a quote that spans a page break gets a rule on both halves.

This is NOT the layout engine §13 says cannot slip. It is a line-breaker
and a paginator for the shapes that exist today, with real measurement
rather than an approximation. When the engine lands, the writer, the
subsetter and the fonts survive and the line-breaking half is replaced.

A character no core face can set is reported rather than silently drawn
as a box — §11.2's degrade-loudly rule, and the only infidelity this
exporter can currently produce.

Tested by reading the PDF back through its own xref offsets, inflating
the streams, and reconstructing the text through each font's ToUnicode
CMap — so the assertion is "a person can select this and get their words
back", not "the bytes did not change". Plus the three things that break
a real reader and do not depend on agreeing with a reader written by the
same hand: a dangling reference, a wrong /Size, an xref offset that
lands on the wrong object.

What has NOT happened: no third-party PDF reader has opened one of
these. None is available in this build, and headless Chromium renders
PDFs blank here, which cannot distinguish a bad file from no viewer.
`DOCS_PDF_SAMPLE=<path> go test ./internal/export -run TestWriteSamplePDF`
writes one to open by hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYB9fUAfxXNzkeC3fsFYzR
Paul Campbell pushed by paul@keymail.dev 7df914333dfc0fe9a25d43a94062a3797dd9dd81 parent 6e236b7
5 files changed, +1706 −0
  • internal/export/pdf.go +610 −0
  • internal/export/pdf_test.go +300 −0
  • internal/export/pdfread_test.go +270 −0
  • internal/export/sample_pdf_test.go +71 −0
  • internal/pdf/pdf.go +455 −0
diff --git a/internal/export/pdf.go b/internal/export/pdf.go
new file mode 100644
index 0000000..38e9824
--- /dev/null
+++ b/internal/export/pdf.go
@@ -0,0 +1,610 @@
+package export
+
+import (
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/carlosframework/docs/internal/fonts"
+ "github.com/carlosframework/docs/internal/pdf"
+)
+
+// The page, in points. These are the numbers views/show.html sets on
+// [docs-page] and [docs-text], restated here because the exporter has
+// to lay text out in Go and the browser has to lay it out in CSS, and
+// the two must agree about the shape of a page or the PDF is of a
+// different document.
+//
+// Restated, not shared: there is no mechanism today that lets one
+// definition drive both, and inventing one (rendering the stylesheet
+// from these constants) would put a template expansion in the path of
+// every page load to solve a problem a test can solve instead —
+// internal/docs's TestPageGeometryMatchesTheStylesheet reads show.html
+// and fails if the two ever drift.
+//
+// §11.3 wants the PDF to come from "the same box tree the screen
+// renders". That box tree is the layout engine's, and the layout
+// engine does not exist yet (spec §13). This is the honest version
+// until it does: one set of numbers, asserted equal in both places,
+// and line breaking that measures with the same font programs the
+// browser is served.
+const (
+ PageWidthPt = 8.5 * 72 // US Letter
+ PageHeightPt = 11 * 72
+ MarginPt = 72 // 1in
+ BodySizePt = 12
+ BodyLeading = 1.6 // line-height
+
+ contentWidthPt = PageWidthPt - 2*MarginPt
+ contentBottom = PageHeightPt - MarginPt
+)
+
+// Block-level proportions, all mirroring show.html's stylesheet. The
+// heading scale is h1 1.6em down to h4-h6 at 1em italic; a heading's
+// own margins are in its own em, exactly as CSS resolves them.
+var headingScale = map[int]float64{1: 1.6, 2: 1.3, 3: 1.1, 4: 1, 5: 1, 6: 1}
+
+const (
+ headingLeading = 1.3
+ headingSpaceAbove = 1.5 // em of the heading's own size
+ headingSpaceBelow = 0.5
+
+ paragraphSpaceBelow = 1.0 // em of body size
+
+ listIndentEm = 1.4 // padding-inline-start
+ listItemSpace = 0.35 // margin-block-end on an li
+ listSpaceBelow = 1.0
+
+ quoteSpaceAround = 1.0
+ quoteIndentEm = 1.0
+ quoteRulePt = 3 * 0.75 // 3px at 96dpi is 2.25pt
+ quoteGray = 0.29 // #4a4a4a
+ quoteRuleGray = 0.78 // #c8c8c8
+
+ codeScale = 0.85 // [docs-text] code { font-size: 0.85em }
+)
+
+// sizeScale is what each size mark multiplies the body size by. The
+// same numbers appear in show.html's .docs-size-* rules; the geometry
+// test holds them equal.
+var sizeScale = map[string]float64{"size_sm": 0.85, "size_lg": 1.25, "size_xl": 1.5}
+
+// Report is what an export could not do faithfully — §11.2's
+// degrade-loudly rule, in the one form this exporter can currently
+// fail: a character none of the core faces has a glyph for. A document
+// in an unshipped script (§11.1 puts CJK and the wider locale set
+// outside the core payload) exports with .notdef boxes, and the person
+// who asked for it is entitled to know that rather than discover it.
+type Report struct {
+ // MissingRunes is sorted and deduplicated.
+ MissingRunes []rune
+}
+
+// Empty reports whether the export was fully faithful.
+func (r Report) Empty() bool { return len(r.MissingRunes) == 0 }
+
+// String renders the report for a log line.
+func (r Report) String() string {
+ if r.Empty() {
+ return "no substitutions"
+ }
+ var b strings.Builder
+ b.WriteString("no glyph for: ")
+ for i, ru := range r.MissingRunes {
+ if i > 0 {
+ b.WriteString(" ")
+ }
+ b.WriteString(strconv.QuoteRune(ru))
+ }
+ return b.String()
+}
+
+// style is one run's resolved typography.
+type style struct {
+ face fonts.Face
+ size float64 // points
+ gray float64
+}
+
+// blockContext is what a block inherits from the blocks it sits
+// inside: an indent, and whether it is inside a blockquote (which
+// makes its text italic and grey, and draws a rule beside every line).
+type blockContext struct {
+ indent float64
+ quoted bool
+ // scale multiplies the base font size — headings set it for their
+ // own content.
+ scale float64
+ bold bool
+ italic bool
+}
+
+// PDF lays a document out and returns the file.
+func PDF(doc Document) ([]byte, Report, error) {
+ l := &layout{
+ b: pdf.New(PageWidthPt, PageHeightPt),
+ missing: map[rune]bool{},
+ }
+ l.b.Title = doc.Title
+ l.newPage()
+
+ // The title is set as a level-1 heading at the top of the first
+ // page. It is not part of the CRDT and so not part of the box tree
+ // the screen renders — the screen puts it in the page header,
+ // which is chrome around the paper rather than on it. A PDF has no
+ // chrome: a file that does not say which document it is, is worse
+ // than one that occasionally repeats a heading its author also
+ // typed. Markdown does the same thing for the same reason.
+ if doc.Title != "" {
+ l.block(Node{
+ Type: "heading",
+ Attrs: Attrs{Level: 1},
+ Content: []Node{{Type: "text", Text: doc.Title}},
+ }, blockContext{scale: 1})
+ }
+
+ for _, n := range doc.Root.Content {
+ l.block(n, blockContext{scale: 1})
+ }
+
+ out, err := l.b.Bytes()
+ if err != nil {
+ return nil, Report{}, err
+ }
+ return out, l.report(), nil
+}
+
+type layout struct {
+ b *pdf.Builder
+ page *pdf.Page
+
+ // cursor is the distance from the top of the page to the top of
+ // the next line box. Measuring downward from the top is how the
+ // document itself is shaped; PDF's own origin is at the bottom
+ // left, and the single conversion happens at draw time.
+ cursor float64
+
+ // pending is the collapsed margin waiting to be applied before the
+ // next block — CSS collapses adjacent vertical margins to the
+ // larger of the two, and a paragraph after a heading must not get
+ // both margins stacked.
+ pending float64
+
+ // atPageTop suppresses a collapsed margin at the top of a page and
+ // at the very start of the document, mirroring show.html's
+ // `[docs-text] > :first-child { margin-block-start: 0 }`.
+ atPageTop bool
+
+ missing map[rune]bool
+}
+
+func (l *layout) newPage() {
+ l.page = l.b.AddPage()
+ l.cursor = MarginPt
+ l.pending = 0
+ l.atPageTop = true
+}
+
+func (l *layout) report() Report {
+ out := Report{}
+ for r := range l.missing {
+ out.MissingRunes = append(out.MissingRunes, r)
+ }
+ sort.Slice(out.MissingRunes, func(i, j int) bool { return out.MissingRunes[i] < out.MissingRunes[j] })
+ return out
+}
+
+// space queues a vertical margin, collapsed against whatever is
+// already queued.
+func (l *layout) space(pt float64) {
+ if pt > l.pending {
+ l.pending = pt
+ }
+}
+
+// applyPending consumes the queued margin. At the top of a page it is
+// dropped rather than applied, which is both what the stylesheet says
+// for the first child and what stops a page break from opening with a
+// band of white the reader cannot account for.
+func (l *layout) applyPending() {
+ if !l.atPageTop {
+ l.cursor += l.pending
+ }
+ l.pending = 0
+}
+
+// block lays out one node.
+func (l *layout) block(n Node, ctx blockContext) {
+ switch kindOf(n) {
+ case blockHeading:
+ level := headingLevel(n)
+ scale := headingScale[level]
+ size := BodySizePt * scale
+ l.space(size * headingSpaceAbove)
+ hctx := ctx
+ hctx.scale = scale
+ hctx.bold = true
+ // h4-h6 are italic in the stylesheet — they are the same size
+ // as body text, so slope is the only thing distinguishing
+ // them.
+ hctx.italic = ctx.italic || level >= 4
+ l.paragraph(n.Content, hctx, size*headingLeading)
+ l.space(size * headingSpaceBelow)
+
+ case blockParagraph:
+ l.paragraph(n.Content, ctx, BodySizePt*ctx.scale*BodyLeading)
+ l.space(BodySizePt * paragraphSpaceBelow)
+
+ case blockBulletList:
+ l.list(n, ctx, false)
+
+ case blockOrderedList:
+ l.list(n, ctx, true)
+
+ case blockQuote:
+ l.space(BodySizePt * quoteSpaceAround)
+ inner := ctx
+ inner.indent += BodySizePt * quoteIndentEm
+ inner.quoted = true
+ inner.italic = true
+ for i, child := range n.Content {
+ l.block(child, inner)
+ if i == len(n.Content)-1 {
+ // The quote's own bottom margin replaces the last
+ // child's, rather than stacking with it.
+ l.pending = 0
+ }
+ }
+ l.space(BodySizePt * quoteSpaceAround)
+
+ case blockListItem:
+ // A list item outside a list — not producible through
+ // ProseMirror's schema, but a client can post any Yjs bytes it
+ // likes. Laid out as its own content rather than dropped.
+ for _, child := range n.Content {
+ l.block(child, ctx)
+ }
+
+ default:
+ if n.IsText() {
+ l.paragraph([]Node{n}, ctx, BodySizePt*ctx.scale*BodyLeading)
+ l.space(BodySizePt * paragraphSpaceBelow)
+ return
+ }
+ // An unknown element: lay out its children. Same rule as the
+ // core's renderers and the Markdown writer — never lose the
+ // words.
+ for _, child := range n.Content {
+ l.block(child, ctx)
+ }
+ }
+}
+
+func (l *layout) list(list Node, ctx blockContext, ordered bool) {
+ inner := ctx
+ inner.indent += BodySizePt * listIndentEm
+
+ for i, item := range list.Content {
+ marker := "• " // bullet
+ if ordered {
+ marker = strconv.Itoa(i+1) + ". "
+ }
+ l.applyPending()
+ // The marker sits in the list's own indent, to the left of the
+ // item's text, and is drawn on the first line of the item —
+ // which is why the item's content is laid out first, at a
+ // remembered cursor, and the marker placed against that line's
+ // own baseline.
+ markerStyle := l.styleFor(nil, inner)
+ markerTop := l.cursor
+ markerPage := l.page
+
+ for j, child := range item.Content {
+ l.block(child, inner)
+ if j == len(item.Content)-1 {
+ l.pending = 0
+ }
+ }
+
+ // Only draw the marker if the item actually produced a line on
+ // the page it started on — an item whose first block landed on
+ // the next page would otherwise get a marker stranded at the
+ // bottom of the previous one.
+ if markerPage == l.page || markerTop < l.cursor {
+ asc, _ := ascentDescent(markerStyle)
+ baseline := markerTop + halfLeading(markerStyle, BodySizePt*BodyLeading) + asc
+ l.draw(markerPage, inner.indent-textWidth(marker, markerStyle), baseline, markerStyle, marker)
+ }
+ l.space(BodySizePt * listItemSpace)
+ }
+ l.pending = 0
+ l.space(BodySizePt * listSpaceBelow)
+}
+
+// token is one word or one run of whitespace, with the style it is set
+// in. Splitting into tokens before wrapping is what lets a line break
+// fall between two runs with different marks — "very **bold** words"
+// wraps between "very" and "bold" like any other text.
+type token struct {
+ text string
+ st style
+ space bool
+ width float64
+}
+
+// paragraph lays out a run of inline content as wrapped lines.
+func (l *layout) paragraph(content []Node, ctx blockContext, lineHeight float64) {
+ tokens := l.tokenize(content, ctx)
+ if len(tokens) == 0 {
+ // An empty paragraph is a blank line on screen (fragment_html
+ // preserves `<p></p>`), so it is one here too.
+ l.applyPending()
+ l.advance(lineHeight, ctx)
+ return
+ }
+
+ maxWidth := contentWidthPt - ctx.indent
+ var line []token
+ var lineWidth float64
+
+ flush := func() {
+ for len(line) > 0 && line[len(line)-1].space {
+ line = line[:len(line)-1]
+ }
+ if len(line) == 0 {
+ return
+ }
+ l.drawLine(line, ctx, lineHeight)
+ line = nil
+ lineWidth = 0
+ }
+
+ for _, t := range tokens {
+ if t.space {
+ if len(line) == 0 {
+ continue // a line never opens with a space
+ }
+ line = append(line, t)
+ lineWidth += t.width
+ continue
+ }
+ if lineWidth+t.width > maxWidth && len(line) > 0 {
+ flush()
+ }
+ // A single word wider than the whole line: break it. Letting
+ // it overflow would run it off the edge of the paper, where a
+ // URL or a long identifier is exactly the case that produces
+ // one.
+ for t.width > maxWidth {
+ head, rest := splitToWidth(t, maxWidth)
+ if head.text == "" {
+ break // cannot make progress; draw it and overflow
+ }
+ l.drawLine([]token{head}, ctx, lineHeight)
+ t = rest
+ }
+ line = append(line, t)
+ lineWidth += t.width
+ }
+ flush()
+}
+
+// drawLine places one assembled line and moves the cursor past it.
+func (l *layout) drawLine(line []token, ctx blockContext, lineHeight float64) {
+ l.applyPending()
+ if l.cursor+lineHeight > contentBottom {
+ l.newPage()
+ }
+
+ // The baseline sits where CSS puts it: the text's own content box
+ // is centred in the line box (half the leading above, half below),
+ // and the baseline is one ascent down from the top of that box.
+ // Getting this from real font metrics rather than a fraction of
+ // the line height is what makes a heading in the PDF sit where the
+ // heading on screen sits.
+ var ascent, descent float64
+ for _, t := range line {
+ a, d := ascentDescent(t.st)
+ if a > ascent {
+ ascent = a
+ }
+ if d > descent {
+ descent = d
+ }
+ }
+ baseline := l.cursor + (lineHeight-(ascent+descent))/2 + ascent
+
+ if ctx.quoted {
+ // One rule segment per line, so a quote that breaks across a
+ // page gets a rule on both halves and none in the gap.
+ l.page.Rule(ctx.indent-BodySizePt*quoteIndentEm, PageHeightPt-l.cursor-lineHeight,
+ quoteRulePt, lineHeight, quoteRuleGray)
+ }
+
+ x := ctx.indent
+ for _, t := range line {
+ l.draw(l.page, x, baseline, t.st, t.text)
+ x += t.width
+ }
+ l.cursor += lineHeight
+ l.atPageTop = false
+}
+
+// advance moves past a line box without drawing anything — an empty
+// paragraph.
+func (l *layout) advance(lineHeight float64, ctx blockContext) {
+ if l.cursor+lineHeight > contentBottom {
+ l.newPage()
+ }
+ l.cursor += lineHeight
+ l.atPageTop = false
+}
+
+// draw is the one place a top-down cursor becomes a PDF coordinate.
+func (l *layout) draw(page *pdf.Page, x, baselineFromTop float64, st style, s string) {
+ missing, err := page.Text(x, PageHeightPt-baselineFromTop, st.face, st.size, st.gray, s)
+ if err != nil {
+ // Text only fails if a face will not parse, which
+ // internal/fonts' own test proves cannot happen for the twelve
+ // embedded ones. Dropping the run rather than failing the
+ // whole export: a PDF missing one run is recoverable by the
+ // person reading it, a 500 on export is not.
+ return
+ }
+ for _, r := range missing {
+ l.missing[r] = true
+ }
+}
+
+// tokenize flattens inline content into words and spaces.
+func (l *layout) tokenize(content []Node, ctx blockContext) []token {
+ var out []token
+ var walk func(nodes []Node)
+ walk = func(nodes []Node) {
+ for _, n := range nodes {
+ if !n.IsText() {
+ walk(n.Content)
+ continue
+ }
+ if n.Text == "" {
+ continue
+ }
+ st := l.styleFor(n.Marks, ctx)
+ for _, piece := range splitWords(n.Text) {
+ out = append(out, token{
+ text: piece.text,
+ st: st,
+ space: piece.space,
+ width: textWidth(piece.text, st),
+ })
+ }
+ }
+ }
+ walk(content)
+ return out
+}
+
+type piece struct {
+ text string
+ space bool
+}
+
+// splitWords divides a run into alternating words and whitespace,
+// keeping both — the whitespace has a width and has to be measured,
+// and its width depends on the style it is set in.
+func splitWords(s string) []piece {
+ var out []piece
+ var cur strings.Builder
+ inSpace := false
+ for _, r := range s {
+ isSpace := r == ' ' || r == '\t' || r == '\n' || r == '\r'
+ if cur.Len() > 0 && isSpace != inSpace {
+ out = append(out, piece{text: cur.String(), space: inSpace})
+ cur.Reset()
+ }
+ inSpace = isSpace
+ cur.WriteRune(r)
+ }
+ if cur.Len() > 0 {
+ out = append(out, piece{text: cur.String(), space: inSpace})
+ }
+ return out
+}
+
+// splitToWidth cuts a token at the last character that fits.
+func splitToWidth(t token, maxWidth float64) (head, rest token) {
+ runes := []rune(t.text)
+ for i := 1; i < len(runes); i++ {
+ w := textWidth(string(runes[:i+1]), t.st)
+ if w > maxWidth {
+ h := token{text: string(runes[:i]), st: t.st}
+ h.width = textWidth(h.text, h.st)
+ r := token{text: string(runes[i:]), st: t.st}
+ r.width = textWidth(r.text, r.st)
+ return h, r
+ }
+ }
+ return t, token{st: t.st}
+}
+
+// styleFor resolves a run's marks and its block context into a face, a
+// size and a colour. This is the mirror of show.html's stylesheet, and
+// the order matters: the block's own scale first, then a size mark,
+// then code's own reduction — the same order the cascade applies them
+// in.
+func (l *layout) styleFor(marks []Mark, ctx blockContext) style {
+ has := func(name string) bool {
+ for _, m := range marks {
+ if m.Type == name {
+ return true
+ }
+ }
+ return false
+ }
+
+ family := fonts.Serif
+ switch {
+ case has("font_mono"):
+ family = fonts.Mono
+ case has("font_sans"):
+ family = fonts.Sans
+ }
+
+ size := BodySizePt * ctx.scale
+ for mark, scale := range sizeScale {
+ if has(mark) {
+ size *= scale
+ break
+ }
+ }
+
+ bold := ctx.bold || has("strong")
+ italic := ctx.italic || has("em")
+
+ if has("code") {
+ // A code run is monospace and smaller, whatever else it says —
+ // exactly as the stylesheet's `[docs-text] code` rule wins over
+ // the inherited family. The background tint the screen draws
+ // behind it is not reproduced: a filled rectangle behind every
+ // code run would have to be measured and drawn per line
+ // fragment, and its absence costs a reader nothing a PDF is
+ // for.
+ family = fonts.Mono
+ size *= codeScale
+ }
+
+ gray := 0.0
+ if ctx.quoted {
+ gray = quoteGray
+ }
+ return style{face: fonts.Get(family, bold, italic), size: size, gray: gray}
+}
+
+// textWidth measures a string in points.
+func textWidth(s string, st style) float64 {
+ font, err := st.face.Parsed()
+ if err != nil {
+ return 0
+ }
+ w, _ := font.WidthEm(s)
+ return w * st.size
+}
+
+// ascentDescent is the face's own vertical extent at this size, in
+// points. Descent comes back positive — the table stores it negative,
+// and every caller here wants a height.
+func ascentDescent(st style) (ascent, descent float64) {
+ font, err := st.face.Parsed()
+ if err != nil {
+ return st.size * 0.8, st.size * 0.2
+ }
+ upm := float64(font.UnitsPerEm)
+ return float64(font.Ascender) / upm * st.size, -float64(font.Descender) / upm * st.size
+}
+
+// halfLeading is the space CSS puts above the text's content box
+// inside a taller line box.
+func halfLeading(st style, lineHeight float64) float64 {
+ a, d := ascentDescent(st)
+ return (lineHeight - (a + d)) / 2
+}
diff --git a/internal/export/pdf_test.go b/internal/export/pdf_test.go
new file mode 100644
index 0000000..38458d7
--- /dev/null
+++ b/internal/export/pdf_test.go
@@ -0,0 +1,300 @@
+package export_test
+
+import (
+ "bytes"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/export"
+)
+
+func mustPDF(t *testing.T, doc export.Document) ([]byte, export.Report) {
+ t.Helper()
+ out, report, err := export.PDF(doc)
+ if err != nil {
+ t.Fatalf("PDF: %v", err)
+ }
+ return out, report
+}
+
+// The one that matters: the words a person typed have to be the words
+// they get when they select the exported PDF and copy it. Everything
+// between — glyph ids, the subset, the CMap — is machinery in service
+// of this.
+func TestPDFTextCanBeCopiedBackOut(t *testing.T) {
+ js := `{"type":"doc","content":[
+ {"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"A heading"}]},
+ {"type":"paragraph","content":[
+ {"type":"text","text":"Plain then "},
+ {"type":"text","text":"bold","marks":[{"type":"strong"}]},
+ {"type":"text","text":" and a caf"},
+ {"type":"text","text":"é."}
+ ]}
+ ]}`
+ data, report := mustPDF(t, mustParse(t, "Notes", js))
+ if !report.Empty() {
+ t.Errorf("unexpected substitutions: %s", report)
+ }
+
+ f := readPDF(t, data)
+ pages := f.pages(t)
+ if len(pages) != 1 {
+ t.Fatalf("got %d pages, want 1", len(pages))
+ }
+ got := f.text(t, pages[0])
+
+ for _, want := range []string{"Notes", "A heading", "Plain then", "bold", "café."} {
+ if !strings.Contains(got, want) {
+ t.Errorf("copied text is missing %q; got %q", want, got)
+ }
+ }
+}
+
+// Bold and italic are separate font programs, not a synthesised slant,
+// so a document using both must embed both and a page must declare
+// both in its resources.
+func TestPDFEmbedsAFaceForEachStyleUsed(t *testing.T) {
+ js := `{"type":"doc","content":[{"type":"paragraph","content":[
+ {"type":"text","text":"regular "},
+ {"type":"text","text":"bold ","marks":[{"type":"strong"}]},
+ {"type":"text","text":"italic ","marks":[{"type":"em"}]},
+ {"type":"text","text":"mono","marks":[{"type":"font_mono"}]}
+ ]}]}`
+ data, _ := mustPDF(t, mustParse(t, "", js))
+ f := readPDF(t, data)
+
+ var descriptors, files int
+ for _, o := range f.objects {
+ if strings.Contains(o.dict, "/Type /FontDescriptor") {
+ descriptors++
+ if !strings.Contains(o.dict, "/FontFile2") {
+ t.Error("a font descriptor with no embedded font file")
+ }
+ }
+ if strings.Contains(o.dict, "/Length1") {
+ files++
+ }
+ }
+ // Regular, bold, italic and mono: four distinct faces. (The title
+ // is empty here, so nothing pulls in a bold serif of its own.)
+ if descriptors != 4 {
+ t.Errorf("got %d font descriptors, want 4 (regular, bold, italic, mono)", descriptors)
+ }
+ if files != descriptors {
+ t.Errorf("got %d embedded font files for %d descriptors", files, descriptors)
+ }
+}
+
+// The embedded font must be a subset, not the whole face — an export
+// that embeds four full faces is a megabyte and a half of font for a
+// page of text.
+func TestPDFEmbedsSubsetsNotWholeFaces(t *testing.T) {
+ js := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}]}`
+ data, _ := mustPDF(t, mustParse(t, "", js))
+ f := readPDF(t, data)
+
+ found := false
+ for _, o := range f.objects {
+ if !strings.Contains(o.dict, "/Length1") {
+ continue
+ }
+ found = true
+ // Liberation Serif is about 385KB whole. A subset holding the
+ // glyphs of "Hello" plus loca and hmtx is far below a tenth of
+ // that; the threshold is deliberately loose so this fails on a
+ // subsetter that stopped subsetting, not on one that got a bit
+ // less efficient.
+ if len(o.stream) > 100_000 {
+ t.Errorf("embedded font is %d bytes — that is not a subset", len(o.stream))
+ }
+ if len(o.stream) < 1_000 {
+ t.Errorf("embedded font is %d bytes — that is too small to be a font", len(o.stream))
+ }
+ }
+ if !found {
+ t.Fatal("no embedded font file in the PDF")
+ }
+}
+
+// Text longer than a page has to become more pages, and no text may be
+// lost at the break.
+func TestPDFPaginatesLongDocuments(t *testing.T) {
+ var paras []string
+ for i := 0; i < 120; i++ {
+ paras = append(paras, `{"type":"paragraph","content":[{"type":"text","text":"Paragraph number `+
+ strings.Repeat("x", 3)+` of a document long enough to need more than one page."}]}`)
+ }
+ js := `{"type":"doc","content":[` + strings.Join(paras, ",") + `]}`
+
+ data, _ := mustPDF(t, mustParse(t, "Long", js))
+ f := readPDF(t, data)
+ pages := f.pages(t)
+ if len(pages) < 3 {
+ t.Fatalf("120 paragraphs produced %d pages", len(pages))
+ }
+
+ var all strings.Builder
+ for _, p := range pages {
+ all.WriteString(f.text(t, p))
+ }
+ if n := strings.Count(all.String(), "Paragraph number"); n != 120 {
+ t.Errorf("found %d paragraphs across %d pages, want 120", n, len(pages))
+ }
+}
+
+// A word wider than the text column must be broken rather than run off
+// the edge of the paper — a URL is the ordinary case.
+func TestPDFBreaksAWordTooLongForTheLine(t *testing.T) {
+ long := strings.Repeat("supercalifragilistic", 12)
+ js := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"` + long + `"}]}]}`
+ data, _ := mustPDF(t, mustParse(t, "", js))
+ f := readPDF(t, data)
+ got := f.text(t, f.pages(t)[0])
+ if len(got) < len(long) {
+ t.Errorf("the long word lost characters: %d of %d survived", len(got), len(long))
+ }
+}
+
+// A character no core face has a glyph for must be reported, not
+// silently dropped — §11.2's degrade-loudly rule, and the only form of
+// infidelity this exporter can currently produce.
+func TestPDFReportsCharactersItHasNoGlyphFor(t *testing.T) {
+ js := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello 漢字"}]}]}`
+ _, report := mustPDF(t, mustParse(t, "", js))
+ if report.Empty() {
+ t.Fatal("CJK is outside the core font set (§11.1) and must be reported as a substitution")
+ }
+ if !strings.Contains(report.String(), "漢") {
+ t.Errorf("report does not name the character: %s", report)
+ }
+}
+
+// The same document exported twice must produce the same bytes. Go's
+// map iteration is randomised, and a PDF whose object order varies run
+// to run cannot be diffed, cached or compared by anyone.
+func TestPDFIsDeterministic(t *testing.T) {
+ js := `{"type":"doc","content":[{"type":"paragraph","content":[
+ {"type":"text","text":"one "},
+ {"type":"text","text":"two","marks":[{"type":"strong"}]},
+ {"type":"text","text":" three","marks":[{"type":"font_sans"}]}
+ ]}]}`
+ doc := mustParse(t, "Same", js)
+ a, _ := mustPDF(t, doc)
+ b, _ := mustPDF(t, doc)
+ if !strings.EqualFold(string(a), string(b)) {
+ t.Error("two exports of the same document produced different bytes")
+ }
+}
+
+// Every indirect reference in the file must resolve, and the trailer
+// must agree with the cross-reference table about how many objects
+// there are.
+//
+// This is where the honesty of this package's PDF testing sits. No
+// third-party reader has opened these files — none is available in
+// this build — so the reader in pdfread_test.go is both the writer's
+// only checker and something written by the same hand, which is
+// exactly the arrangement that can be consistently wrong. What this
+// test adds is the set of properties that do not depend on agreeing
+// with that reader: a dangling reference, an off-by-one /Size, or an
+// xref offset that points at the wrong object are the three ways a
+// hand-written PDF writer breaks a real reader, and none of them can
+// be true here without failing.
+func TestPDFStructureIsInternallyConsistent(t *testing.T) {
+ js := `{"type":"doc","content":[
+ {"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"H"}]},
+ {"type":"paragraph","content":[{"type":"text","text":"body","marks":[{"type":"strong"}]}]},
+ {"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"q"}]}]}
+ ]}`
+ data, _ := mustPDF(t, mustParse(t, "Structure", js))
+
+ // readPDF already follows every xref offset and fails if one does
+ // not land on the object it claims.
+ f := readPDF(t, data)
+
+ refRe := regexp.MustCompile(`(\d+) 0 R`)
+ for num, o := range f.objects {
+ for _, m := range refRe.FindAllStringSubmatch(o.dict, -1) {
+ target, _ := strconv.Atoi(m[1])
+ if _, ok := f.objects[target]; !ok {
+ t.Errorf("object %d references object %d, which does not exist", num, target)
+ }
+ }
+ }
+
+ var size int
+ if _, err := fmt.Sscanf(
+ string(data[bytes.LastIndex(data, []byte("trailer")):]), "trailer\n<< /Size %d", &size); err != nil {
+ t.Fatalf("unreadable trailer: %v", err)
+ }
+ if want := len(f.objects) + 1; size != want {
+ t.Errorf("trailer says /Size %d, the xref lists %d objects plus the free head", size, want)
+ }
+}
+
+func TestPDFCarriesTheTitleAsMetadata(t *testing.T) {
+ data, _ := mustPDF(t, mustParse(t, "Café notes", `{"type":"doc","content":[]}`))
+ f := readPDF(t, data)
+ found := false
+ for _, o := range f.objects {
+ if strings.Contains(o.dict, "/Producer") {
+ found = true
+ // UTF-16BE with a BOM: "C" is 0043.
+ if !strings.Contains(o.dict, "/Title <FEFF0043") {
+ t.Errorf("title is not UTF-16BE encoded: %s", o.dict)
+ }
+ }
+ }
+ if !found {
+ t.Error("no document information dictionary")
+ }
+}
+
+// Text inside a node this build has never heard of must reach the PDF,
+// the same guarantee the core and the Markdown writer make.
+func TestPDFKeepsTextInsideUnknownNodes(t *testing.T) {
+ js := `{"type":"doc","content":[
+ {"type":"table","content":[{"type":"paragraph","content":[{"type":"text","text":"kept"}]}]}
+ ]}`
+ data, _ := mustPDF(t, mustParse(t, "", js))
+ f := readPDF(t, data)
+ if got := f.text(t, f.pages(t)[0]); !strings.Contains(got, "kept") {
+ t.Errorf("text inside an unknown node was lost: %q", got)
+ }
+}
+
+// An empty document still has to be a valid one-page PDF rather than
+// an error or a zero-page file no reader will open.
+func TestPDFOfAnEmptyDocument(t *testing.T) {
+ data, _ := mustPDF(t, mustParse(t, "Untitled document", `{"type":"doc","content":[]}`))
+ f := readPDF(t, data)
+ if n := len(f.pages(t)); n != 1 {
+ t.Errorf("got %d pages, want 1", n)
+ }
+}
+
+// Lists and quotes have to reach the page with their markers and their
+// text — a bullet drawn at a negative x, or a marker skipped, is
+// invisible in a byte comparison.
+func TestPDFDrawsListMarkersAndQuotedText(t *testing.T) {
+ js := `{"type":"doc","content":[
+ {"type":"bullet_list","content":[
+ {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"One"}]}]}
+ ]},
+ {"type":"ordered_list","content":[
+ {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"First"}]}]}
+ ]},
+ {"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"Quoted"}]}]}
+ ]}`
+ data, _ := mustPDF(t, mustParse(t, "", js))
+ f := readPDF(t, data)
+ got := f.text(t, f.pages(t)[0])
+ for _, want := range []string{"•", "One", "1.", "First", "Quoted"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("missing %q from the page; got %q", want, got)
+ }
+ }
+}
diff --git a/internal/export/pdfread_test.go b/internal/export/pdfread_test.go
new file mode 100644
index 0000000..09ad573
--- /dev/null
+++ b/internal/export/pdfread_test.go
@@ -0,0 +1,270 @@
+package export_test
+
+// A small PDF reader, for tests only.
+//
+// The point of it is that a PDF export cannot be checked by comparing
+// bytes — the interesting properties are "a reader can find the
+// objects", "the pages are there", and "the text can be selected and
+// copied back out", and none of those is visible in a byte comparison.
+// This walks the file the way a reader does: through the trailer to
+// the cross-reference table, through the offsets in it to the objects,
+// and through each font's ToUnicode CMap to turn the glyph ids in a
+// content stream back into words.
+//
+// It follows the file's own xref offsets rather than scanning for
+// "obj", which means a wrong offset fails the test rather than being
+// routed around — the offsets are exactly the kind of thing that is
+// silently wrong in a hand-written PDF writer.
+
+import (
+ "bytes"
+ "compress/zlib"
+ "fmt"
+ "io"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+type pdfObject struct {
+ dict string
+ stream []byte // already decompressed
+}
+
+type pdfFile struct {
+ objects map[int]pdfObject
+}
+
+func readPDF(t *testing.T, data []byte) pdfFile {
+ t.Helper()
+
+ if !bytes.HasPrefix(data, []byte("%PDF-1.")) {
+ t.Fatalf("not a PDF: starts with %q", firstLine(data))
+ }
+ if !bytes.HasSuffix(bytes.TrimRight(data, "\n"), []byte("%%EOF")) {
+ t.Fatal("file does not end with the end-of-file marker")
+ }
+
+ // startxref, then the offset on the following line.
+ idx := bytes.LastIndex(data, []byte("startxref"))
+ if idx < 0 {
+ t.Fatal("no startxref")
+ }
+ var xrefAt int
+ if _, err := fmt.Sscanf(string(data[idx:]), "startxref\n%d", &xrefAt); err != nil {
+ t.Fatalf("unreadable startxref: %v", err)
+ }
+ if xrefAt <= 0 || xrefAt >= len(data) {
+ t.Fatalf("startxref points outside the file: %d of %d", xrefAt, len(data))
+ }
+
+ var first, count int
+ if _, err := fmt.Sscanf(string(data[xrefAt:]), "xref\n%d %d", &first, &count); err != nil {
+ t.Fatalf("unreadable xref header at %d: %v", xrefAt, err)
+ }
+ if first != 0 {
+ t.Fatalf("xref starts at object %d, want 0", first)
+ }
+
+ // Entries are exactly 20 bytes each, starting after the two header
+ // lines. Finding the entry table by scanning past the header keeps
+ // this honest about the header's own length.
+ entriesAt := xrefAt + strings.Index(string(data[xrefAt:]), "\n") + 1
+ entriesAt += strings.Index(string(data[entriesAt:]), "\n") + 1
+
+ out := pdfFile{objects: map[int]pdfObject{}}
+ for i := 1; i < count; i++ {
+ at := entriesAt + i*20
+ if at+20 > len(data) {
+ t.Fatalf("xref entry %d runs past the end of the file", i)
+ }
+ entry := string(data[at : at+20])
+ off, err := strconv.Atoi(strings.TrimSpace(entry[:10]))
+ if err != nil {
+ t.Fatalf("xref entry %d is not a number: %q", i, entry)
+ }
+ out.objects[i] = readObject(t, data, i, off)
+ }
+ return out
+}
+
+var lengthRe = regexp.MustCompile(`/Length (\d+)`)
+
+func readObject(t *testing.T, data []byte, num, off int) pdfObject {
+ t.Helper()
+ if off <= 0 || off >= len(data) {
+ t.Fatalf("object %d: offset %d is outside the file", num, off)
+ }
+ want := fmt.Sprintf("%d 0 obj\n", num)
+ if !bytes.HasPrefix(data[off:], []byte(want)) {
+ t.Fatalf("object %d: xref points at %q, not %q", num, firstLine(data[off:]), strings.TrimSpace(want))
+ }
+ body := data[off+len(want):]
+
+ streamAt := bytes.Index(body, []byte("stream\n"))
+ endObj := bytes.Index(body, []byte("\nendobj"))
+ if streamAt < 0 || (endObj >= 0 && endObj < streamAt) {
+ if endObj < 0 {
+ t.Fatalf("object %d: no endobj", num)
+ }
+ return pdfObject{dict: string(body[:endObj])}
+ }
+
+ dict := string(body[:streamAt])
+ m := lengthRe.FindStringSubmatch(dict)
+ if m == nil {
+ t.Fatalf("object %d: a stream with no /Length", num)
+ }
+ n, _ := strconv.Atoi(m[1])
+ start := streamAt + len("stream\n")
+ if start+n > len(body) {
+ t.Fatalf("object %d: /Length %d runs past the end of the file", num, n)
+ }
+ raw := body[start : start+n]
+
+ if !strings.Contains(dict, "/FlateDecode") {
+ return pdfObject{dict: dict, stream: raw}
+ }
+ zr, err := zlib.NewReader(bytes.NewReader(raw))
+ if err != nil {
+ t.Fatalf("object %d: stream is not valid zlib: %v", num, err)
+ }
+ plain, err := io.ReadAll(zr)
+ if err != nil {
+ t.Fatalf("object %d: decompressing: %v", num, err)
+ }
+ return pdfObject{dict: dict, stream: plain}
+}
+
+func firstLine(b []byte) string {
+ if i := bytes.IndexByte(b, '\n'); i >= 0 {
+ b = b[:i]
+ }
+ if len(b) > 60 {
+ b = b[:60]
+ }
+ return string(b)
+}
+
+// pages returns every page object's dictionary, in /Kids order.
+func (f pdfFile) pages(t *testing.T) []pdfObject {
+ t.Helper()
+ var pagesDict string
+ for _, o := range f.objects {
+ if strings.Contains(o.dict, "/Type /Pages") {
+ pagesDict = o.dict
+ }
+ }
+ if pagesDict == "" {
+ t.Fatal("no /Type /Pages node")
+ }
+ kids := regexp.MustCompile(`(\d+) 0 R`).FindAllStringSubmatch(
+ betweenBrackets(t, pagesDict, "/Kids ["), -1)
+ var out []pdfObject
+ for _, k := range kids {
+ n, _ := strconv.Atoi(k[1])
+ o, ok := f.objects[n]
+ if !ok {
+ t.Fatalf("/Kids names object %d, which the xref does not list", n)
+ }
+ if !strings.Contains(o.dict, "/Type /Page") {
+ t.Fatalf("object %d is in /Kids but is not a /Page", n)
+ }
+ out = append(out, o)
+ }
+ return out
+}
+
+func betweenBrackets(t *testing.T, s, open string) string {
+ t.Helper()
+ i := strings.Index(s, open)
+ if i < 0 {
+ t.Fatalf("%q not found in %q", open, s)
+ }
+ rest := s[i+len(open):]
+ j := strings.Index(rest, "]")
+ if j < 0 {
+ t.Fatalf("unterminated %q", open)
+ }
+ return rest[:j]
+}
+
+var (
+ fontResRe = regexp.MustCompile(`/(F\d+) (\d+) 0 R`)
+ contentsRe = regexp.MustCompile(`/Contents (\d+) 0 R`)
+ toUnicodeRe = regexp.MustCompile(`/ToUnicode (\d+) 0 R`)
+ bfcharRe = regexp.MustCompile(`<([0-9A-Fa-f]+)> <([0-9A-Fa-f]+)>`)
+ showRe = regexp.MustCompile(`/(F\d+) [\d.]+ Tf [^<]*<([0-9A-Fa-f]*)> Tj`)
+)
+
+// text reconstructs what a person would get by selecting everything on
+// the page and copying it: the glyph ids in the content stream, mapped
+// back through each font's own ToUnicode CMap. If that mapping is
+// wrong or missing, this returns mojibake, which is exactly what a
+// reader would show.
+func (f pdfFile) text(t *testing.T, page pdfObject) string {
+ t.Helper()
+
+ // resource name -> gid -> rune
+ cmaps := map[string]map[uint16]rune{}
+ for _, m := range fontResRe.FindAllStringSubmatch(page.dict, -1) {
+ n, _ := strconv.Atoi(m[2])
+ fontObj, ok := f.objects[n]
+ if !ok {
+ t.Fatalf("page resources name font object %d, which does not exist", n)
+ }
+ tu := toUnicodeRe.FindStringSubmatch(fontObj.dict)
+ if tu == nil {
+ t.Fatalf("font %s has no /ToUnicode — its text cannot be copied out", m[1])
+ }
+ tn, _ := strconv.Atoi(tu[1])
+ cmap := map[uint16]rune{}
+ for _, e := range bfcharRe.FindAllStringSubmatch(string(f.objects[tn].stream), -1) {
+ gid, _ := strconv.ParseUint(e[1], 16, 16)
+ cmap[uint16(gid)] = decodeUTF16BE(e[2])
+ }
+ cmaps[m[1]] = cmap
+ }
+
+ cm := contentsRe.FindStringSubmatch(page.dict)
+ if cm == nil {
+ t.Fatal("page has no /Contents")
+ }
+ cn, _ := strconv.Atoi(cm[1])
+ content := string(f.objects[cn].stream)
+
+ var b strings.Builder
+ for _, m := range showRe.FindAllStringSubmatch(content, -1) {
+ cmap, ok := cmaps[m[1]]
+ if !ok {
+ t.Fatalf("content stream uses font %s, which the page resources do not declare", m[1])
+ }
+ hex := m[2]
+ for i := 0; i+4 <= len(hex); i += 4 {
+ gid, _ := strconv.ParseUint(hex[i:i+4], 16, 16)
+ r, ok := cmap[uint16(gid)]
+ if !ok {
+ t.Errorf("glyph %d is drawn but has no ToUnicode entry", gid)
+ continue
+ }
+ b.WriteRune(r)
+ }
+ }
+ return b.String()
+}
+
+func decodeUTF16BE(hex string) rune {
+ var units []rune
+ for i := 0; i+4 <= len(hex); i += 4 {
+ v, _ := strconv.ParseUint(hex[i:i+4], 16, 16)
+ units = append(units, rune(v))
+ }
+ if len(units) == 2 && units[0] >= 0xD800 && units[0] <= 0xDBFF {
+ return ((units[0] - 0xD800) << 10) + (units[1] - 0xDC00) + 0x10000
+ }
+ if len(units) == 0 {
+ return 0
+ }
+ return units[0]
+}
diff --git a/internal/export/sample_pdf_test.go b/internal/export/sample_pdf_test.go
new file mode 100644
index 0000000..5e05bbe
--- /dev/null
+++ b/internal/export/sample_pdf_test.go
@@ -0,0 +1,71 @@
+package export_test
+
+import (
+ "os"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/export"
+)
+
+// TestWriteSamplePDF is a development aid, not a gate: with
+// $DOCS_PDF_SAMPLE set to a path, it writes a PDF there so a person
+// (or a browser drive) can open it in a real reader. Skipped
+// otherwise, so it costs the gate nothing.
+//
+// It exists because everything else in this package checks the PDF
+// with this package's own reader (pdfread_test.go), and a writer and a
+// reader that agree with each other can still both be wrong about the
+// specification. Opening the output in something nobody here wrote is
+// the only check that closes that gap.
+func TestWriteSamplePDF(t *testing.T) {
+ path := os.Getenv("DOCS_PDF_SAMPLE")
+ if path == "" {
+ t.Skip("set DOCS_PDF_SAMPLE=<path> to write a sample PDF")
+ }
+
+ js := `{"type":"doc","content":[
+ {"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"What the exporter can set"}]},
+ {"type":"paragraph","content":[
+ {"type":"text","text":"Ordinary prose, with "},
+ {"type":"text","text":"bold","marks":[{"type":"strong"}]},
+ {"type":"text","text":", "},
+ {"type":"text","text":"italic","marks":[{"type":"em"}]},
+ {"type":"text","text":", and "},
+ {"type":"text","text":"code()","marks":[{"type":"code"}]},
+ {"type":"text","text":" in it. Accents (café, naïve, Zoë) and punctuation the web is full of — em dashes, “curly quotes” — come through the same path."}
+ ]},
+ {"type":"paragraph","content":[
+ {"type":"text","text":"This sentence is set in the sans face","marks":[{"type":"font_sans"}]},
+ {"type":"text","text":", this one is larger","marks":[{"type":"size_lg"}]},
+ {"type":"text","text":", and this one is small.","marks":[{"type":"size_sm"}]}
+ ]},
+ {"type":"heading","attrs":{"level":3},"content":[{"type":"text","text":"Lists"}]},
+ {"type":"bullet_list","content":[
+ {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"A bullet, long enough that it has to wrap onto a second line so the hanging indent is visible and can be judged rather than assumed."}]}]},
+ {"type":"list_item","content":[
+ {"type":"paragraph","content":[{"type":"text","text":"A bullet with a list inside it"}]},
+ {"type":"ordered_list","content":[
+ {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"First"}]}]},
+ {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"Second"}]}]}
+ ]}
+ ]}
+ ]},
+ {"type":"blockquote","content":[
+ {"type":"paragraph","content":[{"type":"text","text":"A quotation, set italic and grey with a rule down its left edge, long enough to wrap so the rule can be seen to run the whole height of it rather than only beside the first line."}]}
+ ]},
+ {"type":"paragraph","content":[{"type":"text","text":"A closing paragraph."}]}
+ ]}`
+
+ doc, err := export.Parse("Sample document", js)
+ if err != nil {
+ t.Fatal(err)
+ }
+ data, report, err := export.PDF(doc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("wrote %s (%d bytes), %s", path, len(data), report)
+}
diff --git a/internal/pdf/pdf.go b/internal/pdf/pdf.go
new file mode 100644
index 0000000..2eb3ab6
--- /dev/null
+++ b/internal/pdf/pdf.go
@@ -0,0 +1,455 @@
+// Package pdf writes PDF files with embedded, subsetted TrueType
+// fonts.
+//
+// It knows nothing about documents, paragraphs or CRDTs — it takes
+// pages, and text placed on them at a position in a face at a size,
+// and produces bytes. internal/export is what turns a document into
+// those calls. The split is deliberate: laying text out and writing a
+// PDF are two jobs, and the one that has to be right about byte
+// offsets and object numbers should not also be the one deciding where
+// a line breaks.
+//
+// # What it emits, and why that shape
+//
+// Text is drawn through a Type0 (composite) font with Identity-H
+// encoding and a CIDFontType2 descendant. That is more machinery than
+// a simple /TrueType font with WinAnsiEncoding, and it buys two things
+// worth the machinery:
+//
+// - Any character the face has a glyph for, not the 224 a single-byte
+// encoding can name. A document with a — or a “ or a café in it is
+// ordinary, and an exporter that mangles them is not usable.
+// - Glyph ids addressed directly, which is what internal/fonts'
+// subsetter produces (it keeps ids where they are), so CIDToGIDMap
+// is /Identity and there is no mapping table to get wrong.
+//
+// The cost is that the text in the content stream is glyph ids, which
+// no longer resemble the words. A /ToUnicode CMap is what gives them
+// back, and it is not optional garnish: without one, selecting and
+// copying text out of the exported PDF yields nonsense, and so does
+// searching it. §11.3 asks for a PDF, and a PDF whose text cannot be
+// copied is a picture.
+package pdf
+
+import (
+ "bytes"
+ "compress/zlib"
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/carlosframework/docs/internal/fonts"
+)
+
+// Builder accumulates pages and the fonts they use.
+type Builder struct {
+ Width, Height float64 // points
+ Title string
+
+ pages []*Page
+
+ // used records, per face, which glyphs the document draws and what
+ // each one means in Unicode. Both halves are needed at the end:
+ // the ids to subset with, the runes to write the ToUnicode CMap
+ // from.
+ used map[string]*faceUse
+}
+
+type faceUse struct {
+ face fonts.Face
+ font *fonts.Font
+ glyphs map[uint16]bool
+ unicode map[uint16]rune
+ // resource is the name this face is known by inside a page's
+ // resource dictionary ("F0", "F1", ...).
+ resource string
+}
+
+// Page is one page's content stream, built up as PDF operators.
+type Page struct {
+ b *Builder
+ ops bytes.Buffer
+}
+
+// New starts a document of the given page size, in points (72 to the
+// inch).
+func New(width, height float64) *Builder {
+ return &Builder{Width: width, Height: height, used: map[string]*faceUse{}}
+}
+
+// AddPage appends a page and returns it.
+func (b *Builder) AddPage() *Page {
+ p := &Page{b: b}
+ b.pages = append(b.pages, p)
+ return p
+}
+
+// PageCount is how many pages have been added.
+func (b *Builder) PageCount() int { return len(b.pages) }
+
+func (b *Builder) use(face fonts.Face) (*faceUse, error) {
+ if u, ok := b.used[face.File]; ok {
+ return u, nil
+ }
+ font, err := face.Parsed()
+ if err != nil {
+ return nil, err
+ }
+ u := &faceUse{
+ face: face,
+ font: font,
+ glyphs: map[uint16]bool{},
+ unicode: map[uint16]rune{},
+ resource: fmt.Sprintf("F%d", len(b.used)),
+ }
+ b.used[face.File] = u
+ return u, nil
+}
+
+// Text draws s with its baseline at (x, y), measured in points from
+// the bottom-left of the page — PDF's own origin, not the top-left the
+// layout above it thinks in. Converting between the two is
+// internal/export's job, done once, rather than a subtraction repeated
+// at every call site here.
+//
+// Returns the glyphs it could not find in the face, so a caller can
+// report a substitution rather than let a document export with silent
+// gaps in it (§11.2's degrade-loudly rule).
+// gray is 0 for ordinary black text; a blockquote sets it lighter.
+func (p *Page) Text(x, y float64, face fonts.Face, size, gray float64, s string) (missing []rune, err error) {
+ if s == "" {
+ return nil, nil
+ }
+ u, err := p.b.use(face)
+ if err != nil {
+ return nil, err
+ }
+
+ var hex strings.Builder
+ for _, r := range s {
+ gid := u.font.GlyphIndex(r)
+ if gid == 0 {
+ missing = append(missing, r)
+ // Still drawn: .notdef is a visible box in most faces,
+ // which is the loud degradation rather than a silent gap.
+ }
+ u.glyphs[gid] = true
+ // First rune wins for a gid several runes map to (a face may
+ // draw U+00A0 with the space glyph). Copying out the first is
+ // better than copying out whichever happened to be recorded
+ // last.
+ if _, seen := u.unicode[gid]; !seen {
+ u.unicode[gid] = r
+ }
+ fmt.Fprintf(&hex, "%04X", gid)
+ }
+
+ // q/Q around every run so a grey blockquote cannot leak its fill
+ // colour into whatever is drawn next — the operators are a stack,
+ // and the cheapest correct discipline is to leave it as it was
+ // found.
+ fmt.Fprintf(&p.ops, "q %s g BT /%s %s Tf 1 0 0 1 %s %s Tm <%s> Tj ET Q\n",
+ num(gray), u.resource, num(size), num(x), num(y), hex.String())
+ return missing, nil
+}
+
+// Rule draws a filled rectangle — the one piece of non-text drawing a
+// document needs today, for a blockquote's edge. gray is 0 (black) to
+// 1 (white).
+func (p *Page) Rule(x, y, w, h, gray float64) {
+ fmt.Fprintf(&p.ops, "q %s g %s %s %s %s re f Q\n", num(gray), num(x), num(y), num(w), num(h))
+}
+
+// num formats a coordinate. PDF has no exponent notation, so %g would
+// eventually emit something no reader accepts; and trailing zeros are
+// trimmed because a content stream is most of the file's bytes.
+func num(f float64) string {
+ s := fmt.Sprintf("%.3f", f)
+ s = strings.TrimRight(s, "0")
+ s = strings.TrimSuffix(s, ".")
+ if s == "" || s == "-" {
+ return "0"
+ }
+ return s
+}
+
+// --- serialisation ---
+
+// object is one indirect object, already serialised except for its
+// number.
+type object struct {
+ body []byte
+}
+
+type writer struct {
+ objects []object
+}
+
+// add appends an object and returns its number (1-based, as PDF
+// numbers them).
+func (w *writer) add(body string) int {
+ w.objects = append(w.objects, object{body: []byte(body)})
+ return len(w.objects)
+}
+
+func (w *writer) addStream(dict string, data []byte) int {
+ // Every stream is Flate-compressed. A page of text compresses to
+ // about a third, and an embedded font subset to about half; the
+ // alternative is a file several times larger for no benefit any
+ // reader notices.
+ var buf bytes.Buffer
+ zw := zlib.NewWriter(&buf)
+ // zlib.Writer.Write on a bytes.Buffer cannot fail, and neither can
+ // Close; the errors are checked anyway rather than ignored with a
+ // comment claiming that, because "cannot fail" is a property of
+ // today's implementation and not of the interface.
+ if _, err := zw.Write(data); err != nil {
+ panic("pdf: compressing a stream: " + err.Error())
+ }
+ if err := zw.Close(); err != nil {
+ panic("pdf: closing a compressed stream: " + err.Error())
+ }
+ body := fmt.Sprintf("<< %s /Filter /FlateDecode /Length %d >>\nstream\n", dict, buf.Len())
+ full := append([]byte(body), buf.Bytes()...)
+ full = append(full, []byte("\nendstream")...)
+ w.objects = append(w.objects, object{body: full})
+ return len(w.objects)
+}
+
+// Bytes serialises the document.
+func (b *Builder) Bytes() ([]byte, error) {
+ w := &writer{}
+
+ // Fonts first: a page's resource dictionary has to name them, and
+ // naming an object means knowing its number.
+ fontRefs := map[string]int{}
+ for _, u := range b.sortedUse() {
+ ref, err := b.writeFont(w, u)
+ if err != nil {
+ return nil, err
+ }
+ fontRefs[u.resource] = ref
+ }
+
+ var resources strings.Builder
+ resources.WriteString("<< /Font << ")
+ for _, u := range b.sortedUse() {
+ fmt.Fprintf(&resources, "/%s %d 0 R ", u.resource, fontRefs[u.resource])
+ }
+ resources.WriteString(">> >>")
+
+ // The Pages node has to be numbered before the pages that name it
+ // as /Parent, and it has to list them as /Kids — a chicken and egg
+ // resolved the way PDF itself expects: reserve the number by
+ // adding a placeholder and rewriting its body once the kids exist.
+ pagesRef := w.add("")
+
+ kids := make([]string, 0, len(b.pages))
+ for _, p := range b.pages {
+ contentRef := w.addStream("", p.ops.Bytes())
+ pageRef := w.add(fmt.Sprintf(
+ "<< /Type /Page /Parent %d 0 R /MediaBox [0 0 %s %s] /Resources %s /Contents %d 0 R >>",
+ pagesRef, num(b.Width), num(b.Height), resources.String(), contentRef))
+ kids = append(kids, fmt.Sprintf("%d 0 R", pageRef))
+ }
+ w.objects[pagesRef-1].body = []byte(fmt.Sprintf(
+ "<< /Type /Pages /Kids [%s] /Count %d >>", strings.Join(kids, " "), len(b.pages)))
+
+ catalogRef := w.add(fmt.Sprintf("<< /Type /Catalog /Pages %d 0 R >>", pagesRef))
+
+ infoRef := w.add(fmt.Sprintf("<< /Title %s /Producer (CARLOS Docs) >>", utf16String(b.Title)))
+
+ return w.serialise(catalogRef, infoRef), nil
+}
+
+// sortedUse returns the faces in resource-name order, so a document
+// built twice from the same content produces the same bytes. Go's map
+// iteration order is deliberately random, and an export that differs
+// run to run cannot be tested by comparison.
+func (b *Builder) sortedUse() []*faceUse {
+ out := make([]*faceUse, 0, len(b.used))
+ for _, u := range b.used {
+ out = append(out, u)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].resource < out[j].resource })
+ return out
+}
+
+// writeFont emits the four objects one embedded face needs and returns
+// the number of the Type0 font a page's resources point at.
+func (b *Builder) writeFont(w *writer, u *faceUse) (int, error) {
+ font := u.font
+ upm := float64(font.UnitsPerEm)
+ // PDF glyph space is 1000 units to the em regardless of what the
+ // font uses.
+ scale := func(v float64) float64 { return v * 1000 / upm }
+
+ subset, err := font.Subset(u.glyphs)
+ if err != nil {
+ return 0, fmt.Errorf("pdf: subset %s: %w", u.face.File, err)
+ }
+
+ fileRef := w.addStream(fmt.Sprintf("/Length1 %d", len(subset)), subset)
+
+ flags := 32 // Nonsymbolic: the characters are in the standard Latin set
+ if u.face.Family == fonts.Serif {
+ flags |= 2
+ }
+ if u.face.Family == fonts.Mono {
+ flags |= 1
+ }
+ if u.face.Italic {
+ flags |= 64
+ }
+ // StemV has no source in a TrueType file and every writer
+ // approximates it. A reader uses it only to synthesise a
+ // substitute face, which cannot happen here because the face is
+ // embedded.
+ stemV := 80
+ if u.face.Bold {
+ stemV = 140
+ }
+
+ descRef := w.add(fmt.Sprintf(
+ "<< /Type /FontDescriptor /FontName /%s /Flags %d /FontBBox [%s %s %s %s] "+
+ "/ItalicAngle %s /Ascent %s /Descent %s /CapHeight %s /StemV %d /FontFile2 %d 0 R >>",
+ subsetName(u), flags,
+ num(scale(float64(font.BBox[0]))), num(scale(float64(font.BBox[1]))),
+ num(scale(float64(font.BBox[2]))), num(scale(float64(font.BBox[3]))),
+ num(font.ItalicAngle),
+ num(scale(float64(font.Ascender))), num(scale(float64(font.Descender))),
+ num(scale(float64(font.CapHeight))), stemV, fileRef))
+
+ // /W: the advance of every glyph actually drawn. Glyphs not listed
+ // take /DW, which is why /DW is set to something sane rather than
+ // left at PDF's own 1000 default.
+ gids := make([]int, 0, len(u.glyphs))
+ for g := range u.glyphs {
+ gids = append(gids, int(g))
+ }
+ sort.Ints(gids)
+ var widths strings.Builder
+ for _, g := range gids {
+ fmt.Fprintf(&widths, "%d [%s] ", g, num(scale(float64(font.Advance(uint16(g))))))
+ }
+
+ cidRef := w.add(fmt.Sprintf(
+ "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /%s "+
+ "/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> "+
+ "/FontDescriptor %d 0 R /DW %s /W [%s] /CIDToGIDMap /Identity >>",
+ subsetName(u), descRef, num(scale(float64(font.Advance(0)))), widths.String()))
+
+ toUnicodeRef := w.addStream("", []byte(toUnicodeCMap(u)))
+
+ return w.add(fmt.Sprintf(
+ "<< /Type /Font /Subtype /Type0 /BaseFont /%s /Encoding /Identity-H "+
+ "/DescendantFonts [%d 0 R] /ToUnicode %d 0 R >>",
+ subsetName(u), cidRef, toUnicodeRef)), nil
+}
+
+// subsetName is the PDF convention for a subsetted font: six uppercase
+// letters, a plus sign, then the face's own name. The tag is supposed
+// to distinguish two different subsets of one face inside one file,
+// which cannot happen here — one Builder subsets each face once — so
+// it is derived from the resource name rather than randomly generated,
+// keeping the output byte-identical between runs.
+func subsetName(u *faceUse) string {
+ tag := []byte("AAAAAA")
+ n := 0
+ for _, c := range u.resource {
+ n = n*31 + int(c)
+ }
+ for i := 5; i >= 0; i-- {
+ tag[i] = byte('A' + n%26)
+ n /= 26
+ }
+ return string(tag) + "+" + u.face.PostScriptName()
+}
+
+// toUnicodeCMap writes the CMap that lets a reader turn the glyph ids
+// in the content stream back into text for selection and search.
+func toUnicodeCMap(u *faceUse) string {
+ gids := make([]int, 0, len(u.unicode))
+ for g := range u.unicode {
+ gids = append(gids, int(g))
+ }
+ sort.Ints(gids)
+
+ var b strings.Builder
+ b.WriteString("/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n")
+ b.WriteString("/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n")
+ b.WriteString("/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n")
+ b.WriteString("1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n")
+
+ // bfchar sections are capped at 100 entries by the specification.
+ for start := 0; start < len(gids); start += 100 {
+ end := start + 100
+ if end > len(gids) {
+ end = len(gids)
+ }
+ fmt.Fprintf(&b, "%d beginbfchar\n", end-start)
+ for _, g := range gids[start:end] {
+ fmt.Fprintf(&b, "<%04X> <%s>\n", g, utf16BEHex(u.unicode[uint16(g)]))
+ }
+ b.WriteString("endbfchar\n")
+ }
+ b.WriteString("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n")
+ return b.String()
+}
+
+// utf16BEHex encodes one rune as big-endian UTF-16 hex, surrogate pair
+// included — an emoji in a document is a perfectly ordinary thing to
+// want to copy back out.
+func utf16BEHex(r rune) string {
+ if r > 0xFFFF {
+ r -= 0x10000
+ hi := 0xD800 + (r >> 10)
+ lo := 0xDC00 + (r & 0x3FF)
+ return fmt.Sprintf("%04X%04X", hi, lo)
+ }
+ return fmt.Sprintf("%04X", r)
+}
+
+// utf16String encodes a string as a PDF text string. The UTF-16BE form
+// with a byte-order mark is the only one that can carry a title with
+// anything outside PDFDocEncoding in it, and a document title is
+// arbitrary text somebody typed.
+func utf16String(s string) string {
+ var b strings.Builder
+ b.WriteString("<FEFF")
+ for _, r := range s {
+ b.WriteString(utf16BEHex(r))
+ }
+ b.WriteString(">")
+ return b.String()
+}
+
+// serialise writes the header, the objects, the cross-reference table
+// and the trailer.
+func (w *writer) serialise(catalogRef, infoRef int) []byte {
+ var out bytes.Buffer
+ out.WriteString("%PDF-1.7\n")
+ // A comment line of high bytes, which is how a PDF tells the tools
+ // that transfer it that it is binary and must not be newline-
+ // translated.
+ out.Write([]byte{'%', 0xE2, 0xE3, 0xCF, 0xD3, '\n'})
+
+ offsets := make([]int, len(w.objects)+1)
+ for i, o := range w.objects {
+ offsets[i+1] = out.Len()
+ fmt.Fprintf(&out, "%d 0 obj\n", i+1)
+ out.Write(o.body)
+ out.WriteString("\nendobj\n")
+ }
+
+ xref := out.Len()
+ fmt.Fprintf(&out, "xref\n0 %d\n", len(w.objects)+1)
+ // Object 0 is always the head of the free list.
+ out.WriteString("0000000000 65535 f \n")
+ for i := 1; i <= len(w.objects); i++ {
+ fmt.Fprintf(&out, "%010d 00000 n \n", offsets[i])
+ }
+ fmt.Fprintf(&out, "trailer\n<< /Size %d /Root %d 0 R /Info %d 0 R >>\nstartxref\n%d\n%%%%EOF\n",
+ len(w.objects)+1, catalogRef, infoRef, xref)
+ return out.Bytes()
+}