A core font set, and Markdown export
internal/fonts embeds Liberation Serif, Sans and Mono, four faces each, under the OFL text that has to travel with them. The same font programs go to the browser and into the PDF, which is what §11.1 is actually about: a document that paginates one way on screen and another way in its own PDF is two documents, and it cannot be one while the screen lays out with whatever the reader's machine calls Georgia. Not the Carlito and Caladea §11.1 names — those are the metric-compatible substitutions for the faces a DOCX import names, and nothing here can produce a document that asks for Calibri yet. Liberation is the same idea one family over. Not woff2 either, for want of a compressor in this build; a browser fetches only the faces a page uses, so a declared-unused face costs a reader nothing, and the conversion has a real trigger later. internal/fonts/truetype.go reads the tables needed to measure text and embed a face, and subsets one. The subsetter keeps every glyph id where it was and empties the glyphs nothing uses, rather than renumbering densely: it is still a real subset (about 25KB of a 385KB face for a page of prose) and it removes the two places renumbering goes wrong — composite glyphs referring to components by id, and the PDF's CIDToGIDMap. It costs hmtx and loca staying proportional to the full glyph count, about 15KB, which is worth not having a subsetter whose failure mode is a document rendering with the wrong letters. Tested by parsing the output back and asserting ids, advances and outlines survived — not by asserting it got smaller. internal/export decodes the core's JSON and writes Markdown: headings, paragraphs, nested lists, blockquotes, bold/italic/code. Font and size marks are outside the subset — Markdown cannot say "sans, large" and an HTML span would produce a file that is no longer Markdown — but their text is never lost, and neither is text inside a node this build has never heard of. Escaping is where a Markdown export is actually lossy, so it is where the tests are: "2 * 3 * 4" must not come back as emphasis, "1." at the start of a line must not come back as a list, and a mid-sentence hyphen must not be escaped at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYB9fUAfxXNzkeC3fsFYzR
19 files changed,
+1869
−0
internal/export/document.go+127 −0internal/export/markdown.go+290 −0internal/export/markdown_test.go+217 −0internal/fonts/files/LICENSE-OFL-1.1.txt+91 −0internal/fonts/files/LiberationMono-Bold.ttfbinaryinternal/fonts/files/LiberationMono-BoldItalic.ttfbinaryinternal/fonts/files/LiberationMono-Italic.ttfbinaryinternal/fonts/files/LiberationMono-Regular.ttfbinaryinternal/fonts/files/LiberationSans-Bold.ttfbinaryinternal/fonts/files/LiberationSans-BoldItalic.ttfbinaryinternal/fonts/files/LiberationSans-Italic.ttfbinaryinternal/fonts/files/LiberationSans-Regular.ttfbinaryinternal/fonts/files/LiberationSerif-Bold.ttfbinaryinternal/fonts/files/LiberationSerif-BoldItalic.ttfbinaryinternal/fonts/files/LiberationSerif-Italic.ttfbinaryinternal/fonts/files/LiberationSerif-Regular.ttfbinaryinternal/fonts/fonts.go+256 −0internal/fonts/fonts_test.go+230 −0internal/fonts/truetype.go+658 −0
diff --git a/internal/export/document.go b/internal/export/document.go| new file mode 100644 |
| index 0000000..22b9189 |
| --- /dev/null |
| +++ b/internal/export/document.go |
| @@ -0,0 +1,127 @@ |
| +// Package export turns a document's structure into the formats a |
| +// person can take away with them: Markdown today, PDF alongside it |
| +// (pdf.go), DOCX when §11.2's import work makes a round trip worth |
| +// having. |
| +// |
| +// The structure arrives as ProseMirror-shaped JSON from the Rust core |
| +// (core/src/fragment.rs's fragment_json, read through |
| +// internal/core's Handle.JSON) — not as the HTML the read view |
| +// renders, because "where does this list end" and "is this run bold" |
| +// are questions the markup answers only to a parser, and Go's standard |
| +// library has none. |
| +package export |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| +) |
| + |
| +// Node is one node of the document tree: an element with children, or |
| +// a text run with marks. It is the JSON the core emits, decoded — a |
| +// deliberately loose mirror rather than a validated schema, because |
| +// the core carries unknown nodes and unknown marks through on purpose |
| +// (a newer editor build must not lose text through an older |
| +// exporter), and a decoder that rejected them would undo that. |
| +type Node struct { |
| + Type string `json:"type"` |
| + Attrs Attrs `json:"attrs"` |
| + Content []Node `json:"content"` |
| + Text string `json:"text"` |
| + Marks []Mark `json:"marks"` |
| +} |
| + |
| +// Attrs is the small, closed set of node attributes the core emits. |
| +type Attrs struct { |
| + Level int `json:"level"` |
| +} |
| + |
| +// Mark is one mark on a text run. Only the name matters — every mark |
| +// this build knows is attribute-free by design; see the MARKS table in |
| +// core/src/fragment.rs for why. |
| +type Mark struct { |
| + Type string `json:"type"` |
| +} |
| + |
| +// Has reports whether a run carries a named mark. |
| +func (n Node) Has(mark string) bool { |
| + for _, m := range n.Marks { |
| + if m.Type == mark { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// IsText reports whether this node is a text run rather than an |
| +// element. Checking the type rather than "does it have children" — |
| +// an empty paragraph has no children either, and is not text. |
| +func (n Node) IsText() bool { return n.Type == "text" } |
| + |
| +// Document is a whole document: its title (from the database row, not |
| +// from the CRDT — the title is not part of the content) and the root |
| +// node the core rendered. |
| +type Document struct { |
| + Title string |
| + Root Node |
| +} |
| + |
| +// Parse decodes the core's JSON into a Document. |
| +func Parse(title, structureJSON string) (Document, error) { |
| + var root Node |
| + if err := json.Unmarshal([]byte(structureJSON), &root); err != nil { |
| + return Document{}, fmt.Errorf("export: decode document structure: %w", err) |
| + } |
| + if root.Type != "doc" { |
| + return Document{}, fmt.Errorf("export: document structure has root type %q, want \"doc\"", root.Type) |
| + } |
| + return Document{Title: title, Root: root}, nil |
| +} |
| + |
| +// blockKind classifies a node for the writers below. Both Markdown and |
| +// PDF ask the same questions of the tree, and asking them in one place |
| +// is what keeps the two exports describing the same document. |
| +type blockKind int |
| + |
| +const ( |
| + blockUnknown blockKind = iota |
| + blockParagraph |
| + blockHeading |
| + blockBulletList |
| + blockOrderedList |
| + blockListItem |
| + blockQuote |
| +) |
| + |
| +func kindOf(n Node) blockKind { |
| + switch n.Type { |
| + case "paragraph": |
| + return blockParagraph |
| + case "heading": |
| + return blockHeading |
| + case "bullet_list": |
| + return blockBulletList |
| + case "ordered_list": |
| + return blockOrderedList |
| + case "list_item": |
| + return blockListItem |
| + case "blockquote": |
| + return blockQuote |
| + } |
| + return blockUnknown |
| +} |
| + |
| +// headingLevel clamps to 1–6 a second time. The core already clamps |
| +// (fragment.rs), so this can only fire on a level the core did not |
| +// produce — but the two are separate binaries' worth of trust apart |
| +// once the JSON has crossed the ABI, and a "#######" in a Markdown |
| +// file is not a heading at all. |
| +func headingLevel(n Node) int { |
| + l := n.Attrs.Level |
| + if l < 1 { |
| + return 1 |
| + } |
| + if l > 6 { |
| + return 6 |
| + } |
| + return l |
| +} |
diff --git a/internal/export/markdown.go b/internal/export/markdown.go| new file mode 100644 |
| index 0000000..fb1a120 |
| --- /dev/null |
| +++ b/internal/export/markdown.go |
| @@ -0,0 +1,290 @@ |
| +package export |
| + |
| +import ( |
| + "strconv" |
| + "strings" |
| +) |
| + |
| +// Markdown renders a document as CommonMark. |
| +// |
| +// §11.3 says "Markdown lossless within its subset", and this is where |
| +// that subset is drawn: headings, paragraphs, bullet and ordered lists |
| +// (nested), blockquotes, and the bold/italic/code marks. What falls |
| +// outside it is the font and size marks — Markdown has no way to say |
| +// "this run is in the sans face at the large size", and inventing an |
| +// HTML span to carry it would produce a file that is no longer |
| +// Markdown for the many readers who would then see the tag. A person |
| +// exporting to Markdown is asking for the words and their structure; |
| +// the typography is what PDF is for. |
| +// |
| +// The document title becomes an H1 at the top. It lives on the |
| +// database row rather than inside the CRDT, so it is not in the tree, |
| +// and a Markdown file that opens with nothing but the first paragraph |
| +// loses the one piece of a document a person is most likely to be |
| +// looking for in a folder of them. |
| +func Markdown(doc Document) string { |
| + var b strings.Builder |
| + if doc.Title != "" { |
| + b.WriteString("# ") |
| + b.WriteString(escapeMarkdown(doc.Title)) |
| + b.WriteString("\n\n") |
| + } |
| + writeBlocks(&b, doc.Root.Content, "") |
| + // Exactly one trailing newline: blocks each end with a blank line, |
| + // which leaves two. |
| + return strings.TrimRight(b.String(), "\n") + "\n" |
| +} |
| + |
| +// writeBlocks renders a run of block-level nodes at one indent level. |
| +// indent is the prefix every line of a nested block carries — that is |
| +// how a list inside a list stays inside it in the output. |
| +func writeBlocks(b *strings.Builder, nodes []Node, indent string) { |
| + for _, n := range nodes { |
| + writeBlock(b, n, indent) |
| + } |
| +} |
| + |
| +func writeBlock(b *strings.Builder, n Node, indent string) { |
| + switch kindOf(n) { |
| + case blockHeading: |
| + b.WriteString(indent) |
| + b.WriteString(strings.Repeat("#", headingLevel(n))) |
| + b.WriteString(" ") |
| + writeInline(b, n.Content) |
| + b.WriteString("\n\n") |
| + |
| + case blockParagraph: |
| + text := inlineString(n.Content) |
| + if strings.TrimSpace(text) == "" { |
| + // An empty paragraph is a blank line in the document and |
| + // nothing at all in Markdown — two blank lines in a row |
| + // are one paragraph break, not two. Emitting it would |
| + // change nothing about how the file reads and would make |
| + // the round trip look lossy when it is not. |
| + return |
| + } |
| + b.WriteString(indent) |
| + b.WriteString(text) |
| + b.WriteString("\n\n") |
| + |
| + case blockBulletList: |
| + writeList(b, n, indent, false) |
| + |
| + case blockOrderedList: |
| + writeList(b, n, indent, true) |
| + |
| + case blockQuote: |
| + // Render the quoted content into its own buffer and then |
| + // prefix every line, so a multi-paragraph or list-containing |
| + // quote is quoted throughout rather than only on its first |
| + // line. |
| + var inner strings.Builder |
| + writeBlocks(&inner, n.Content, "") |
| + for _, line := range strings.Split(strings.TrimRight(inner.String(), "\n"), "\n") { |
| + b.WriteString(indent) |
| + if line == "" { |
| + b.WriteString(">\n") |
| + continue |
| + } |
| + b.WriteString("> ") |
| + b.WriteString(line) |
| + b.WriteString("\n") |
| + } |
| + b.WriteString("\n") |
| + |
| + case blockListItem: |
| + // Reached only when a list_item turns up outside a list, which |
| + // ProseMirror's schema cannot produce but a client posting raw |
| + // Yjs bytes can. Rendered as its own content rather than |
| + // dropped — the module rule the core states and this file |
| + // inherits: never lose the words. |
| + writeBlocks(b, n.Content, indent) |
| + |
| + default: |
| + if n.IsText() { |
| + // A bare text run outside any block — same provenance as |
| + // the stray list_item above. Its own paragraph. |
| + b.WriteString(indent) |
| + writeInline(b, []Node{n}) |
| + b.WriteString("\n\n") |
| + return |
| + } |
| + // An element this build does not know: render its children. |
| + // A newer editor's node must not take a reader's text with it. |
| + writeBlocks(b, n.Content, indent) |
| + } |
| +} |
| + |
| +func writeList(b *strings.Builder, list Node, indent string, ordered bool) { |
| + for i, item := range list.Content { |
| + marker := "- " |
| + if ordered { |
| + marker = strconv.Itoa(i+1) + ". " |
| + } |
| + // Continuation lines line up under the text, not under the |
| + // marker, or a nested list reads as a sibling of the one above |
| + // it. |
| + childIndent := indent + strings.Repeat(" ", len(marker)) |
| + |
| + var inner strings.Builder |
| + writeBlocks(&inner, item.Content, "") |
| + lines := strings.Split(strings.TrimRight(inner.String(), "\n"), "\n") |
| + for j, line := range lines { |
| + switch { |
| + case j == 0: |
| + b.WriteString(indent) |
| + b.WriteString(marker) |
| + b.WriteString(line) |
| + case line == "": |
| + // Keep the blank line, but without trailing spaces. |
| + default: |
| + b.WriteString(childIndent) |
| + b.WriteString(line) |
| + } |
| + b.WriteString("\n") |
| + } |
| + } |
| + b.WriteString("\n") |
| +} |
| + |
| +func inlineString(nodes []Node) string { |
| + var b strings.Builder |
| + writeInline(&b, nodes) |
| + return b.String() |
| +} |
| + |
| +// writeInline renders a run of inline nodes with their marks. |
| +// |
| +// Marks are applied per run rather than merged across adjacent runs |
| +// that share one: "**bold**" followed by "**er**" reads identically to |
| +// "**bolder**" once rendered, and merging would mean tracking mark |
| +// state across a sequence for no visible gain. |
| +func writeInline(b *strings.Builder, nodes []Node) { |
| + for _, n := range nodes { |
| + if !n.IsText() { |
| + // An inline element (or a block that ended up inline). |
| + // Render its children so nothing is lost. |
| + writeInline(b, n.Content) |
| + continue |
| + } |
| + if n.Text == "" { |
| + continue |
| + } |
| + if n.Has("code") { |
| + // Code spans take no other markup and no escaping — the |
| + // backticks are the escape. A run containing a backtick |
| + // needs a longer fence than any run of backticks inside |
| + // it, which is what CommonMark says and what this does. |
| + b.WriteString(codeSpan(n.Text)) |
| + continue |
| + } |
| + // Leading and trailing spaces have to sit outside the |
| + // delimiters: "** bold **" is not emphasis in CommonMark, it |
| + // is four literal asterisks and a word. |
| + lead, core, trail := splitSpace(n.Text) |
| + if core == "" { |
| + b.WriteString(n.Text) |
| + continue |
| + } |
| + var opening, closing string |
| + if n.Has("strong") { |
| + opening, closing = opening+"**", "**"+closing |
| + } |
| + if n.Has("em") { |
| + opening, closing = opening+"*", "*"+closing |
| + } |
| + b.WriteString(lead) |
| + b.WriteString(opening) |
| + b.WriteString(escapeMarkdown(core)) |
| + b.WriteString(closing) |
| + b.WriteString(trail) |
| + } |
| +} |
| + |
| +// splitSpace divides s into its leading whitespace, its middle, and |
| +// its trailing whitespace. |
| +func splitSpace(s string) (lead, core, trail string) { |
| + core = strings.TrimLeft(s, " \t") |
| + lead = s[:len(s)-len(core)] |
| + trimmed := strings.TrimRight(core, " \t") |
| + trail = core[len(trimmed):] |
| + return lead, trimmed, trail |
| +} |
| + |
| +// codeSpan wraps s in enough backticks to contain it. |
| +func codeSpan(s string) string { |
| + longest, run := 0, 0 |
| + for _, r := range s { |
| + if r == '`' { |
| + run++ |
| + if run > longest { |
| + longest = run |
| + } |
| + } else { |
| + run = 0 |
| + } |
| + } |
| + fence := strings.Repeat("`", longest+1) |
| + // A code span that starts or ends with a backtick needs a space |
| + // inside the fence, which CommonMark strips again on the way back. |
| + pad := "" |
| + if strings.HasPrefix(s, "`") || strings.HasSuffix(s, "`") { |
| + pad = " " |
| + } |
| + return fence + pad + s + pad + fence |
| +} |
| + |
| +// escapeMarkdown backslash-escapes the characters that would otherwise |
| +// turn a person's own words into markup. A document containing |
| +// "2 * 3 * 4" must not come back as emphasis, and one containing |
| +// "# 1 priority" must not come back as a heading. |
| +// |
| +// The set is CommonMark's ASCII punctuation, minus the characters that |
| +// are only special in positions this function's callers never place |
| +// text in. It escapes more than the strict minimum on purpose: an |
| +// over-escaped file renders correctly and reads slightly noisily in |
| +// source, while an under-escaped one renders as something the author |
| +// did not write. |
| +func escapeMarkdown(s string) string { |
| + var b strings.Builder |
| + b.Grow(len(s)) |
| + for i, r := range s { |
| + switch r { |
| + case '\\', '`', '*', '_', '[', ']', '<', '>': |
| + b.WriteByte('\\') |
| + b.WriteRune(r) |
| + case '#', '+', '-': |
| + // Only special at the start of a line. Inside a sentence |
| + // they are ordinary punctuation, and escaping every hyphen |
| + // in a document would be unreadable. |
| + if i == 0 { |
| + b.WriteByte('\\') |
| + } |
| + b.WriteRune(r) |
| + case '.', ')': |
| + // "1." and "1)" start an ordered list, but only after |
| + // digits at the start of a line. |
| + if i > 0 && startsWithDigits(s[:i]) { |
| + b.WriteByte('\\') |
| + } |
| + b.WriteRune(r) |
| + default: |
| + b.WriteRune(r) |
| + } |
| + } |
| + return b.String() |
| +} |
| + |
| +// startsWithDigits reports whether s is entirely ASCII digits — the |
| +// "1" in a "1." that would start a list. |
| +func startsWithDigits(s string) bool { |
| + if s == "" { |
| + return false |
| + } |
| + for i := 0; i < len(s); i++ { |
| + if s[i] < '0' || s[i] > '9' { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
diff --git a/internal/export/markdown_test.go b/internal/export/markdown_test.go| new file mode 100644 |
| index 0000000..bda4489 |
| --- /dev/null |
| +++ b/internal/export/markdown_test.go |
| @@ -0,0 +1,217 @@ |
| +package export_test |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/export" |
| +) |
| + |
| +// mustParse builds a Document from the JSON shape the core emits, so |
| +// these tests exercise the same decoding path a request does rather |
| +// than hand-building the tree behind it. |
| +func mustParse(t *testing.T, title, js string) export.Document { |
| + t.Helper() |
| + doc, err := export.Parse(title, js) |
| + if err != nil { |
| + t.Fatalf("parse: %v", err) |
| + } |
| + return doc |
| +} |
| + |
| +func TestMarkdownRendersTheDocumentShapes(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":" then "}, |
| + {"type":"text","text":"italic","marks":[{"type":"em"}]}, |
| + {"type":"text","text":" and "}, |
| + {"type":"text","text":"code()","marks":[{"type":"code"}]} |
| + ]}, |
| + {"type":"bullet_list","content":[ |
| + {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"One"}]}]}, |
| + {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"Two"}]}]} |
| + ]}, |
| + {"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":"Quoted."}]}]} |
| + ]}` |
| + |
| + got := export.Markdown(mustParse(t, "Notes", js)) |
| + want := strings.Join([]string{ |
| + "# Notes", |
| + "", |
| + "## A heading", |
| + "", |
| + "Plain then **bold** then *italic* and `code()`", |
| + "", |
| + "- One", |
| + "- Two", |
| + "", |
| + "1. First", |
| + "2. Second", |
| + "", |
| + "> Quoted.", |
| + "", |
| + }, "\n") |
| + if got != want { |
| + t.Errorf("markdown mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want) |
| + } |
| +} |
| + |
| +// A nested list has to stay nested. The indent is measured from the |
| +// marker's own width, so this catches an off-by-one that would flatten |
| +// the child list into a sibling. |
| +func TestMarkdownNestsLists(t *testing.T) { |
| + js := `{"type":"doc","content":[ |
| + {"type":"bullet_list","content":[ |
| + {"type":"list_item","content":[ |
| + {"type":"paragraph","content":[{"type":"text","text":"Outer"}]}, |
| + {"type":"bullet_list","content":[ |
| + {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"Inner"}]}]} |
| + ]} |
| + ]} |
| + ]} |
| + ]}` |
| + got := export.Markdown(mustParse(t, "", js)) |
| + want := "- Outer\n\n - Inner\n" |
| + if got != want { |
| + t.Errorf("nested list\n--- got ---\n%q\n--- want ---\n%q", got, want) |
| + } |
| +} |
| + |
| +// A multi-paragraph blockquote must be quoted throughout, not only on |
| +// its first line. |
| +func TestMarkdownQuotesEveryLineOfABlockquote(t *testing.T) { |
| + js := `{"type":"doc","content":[ |
| + {"type":"blockquote","content":[ |
| + {"type":"paragraph","content":[{"type":"text","text":"One."}]}, |
| + {"type":"paragraph","content":[{"type":"text","text":"Two."}]} |
| + ]} |
| + ]}` |
| + got := export.Markdown(mustParse(t, "", js)) |
| + for _, line := range strings.Split(strings.TrimRight(got, "\n"), "\n") { |
| + if !strings.HasPrefix(line, ">") { |
| + t.Errorf("unquoted line in a blockquote: %q (in %q)", line, got) |
| + } |
| + } |
| +} |
| + |
| +// The thing that makes an export lossy in practice: a person's own |
| +// punctuation being read back as markup. |
| +func TestMarkdownEscapesTextThatWouldBecomeMarkup(t *testing.T) { |
| + cases := []struct { |
| + text string |
| + want string |
| + }{ |
| + {"2 * 3 * 4", `2 \* 3 \* 4`}, |
| + {"snake_case_name", `snake\_case\_name`}, |
| + {"# 1 priority", `\# 1 priority`}, |
| + {"a [link] shape", `a \[link\] shape`}, |
| + {"1. not a list", `1\. not a list`}, |
| + {"back\\slash", `back\\slash`}, |
| + {"<html>", `\<html\>`}, |
| + // A hyphen mid-sentence is ordinary punctuation and must NOT |
| + // be escaped — over-escaping every one of them is unreadable. |
| + {"well-known", "well-known"}, |
| + } |
| + for _, c := range cases { |
| + js := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":` + jsonString(c.text) + `}]}]}` |
| + got := strings.TrimSpace(export.Markdown(mustParse(t, "", js))) |
| + if got != c.want { |
| + t.Errorf("escaping %q: got %q, want %q", c.text, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// Code spans are the one place escaping must NOT happen, and the fence |
| +// has to grow past any backticks inside. |
| +func TestMarkdownCodeSpansFenceThemselves(t *testing.T) { |
| + js := `{"type":"doc","content":[{"type":"paragraph","content":[ |
| + {"type":"text","text":"a ` + "``" + ` b","marks":[{"type":"code"}]} |
| + ]}]}` |
| + got := strings.TrimSpace(export.Markdown(mustParse(t, "", js))) |
| + want := "```a `` b```" |
| + if got != want { |
| + t.Errorf("got %q, want %q", got, want) |
| + } |
| +} |
| + |
| +// "** bold **" is not emphasis in CommonMark. A run whose text ends in |
| +// a space — which is how a person types "bold " before the next word — |
| +// has to keep that space outside the delimiters. |
| +func TestMarkdownKeepsSpacesOutsideEmphasis(t *testing.T) { |
| + js := `{"type":"doc","content":[{"type":"paragraph","content":[ |
| + {"type":"text","text":"bold ","marks":[{"type":"strong"}]}, |
| + {"type":"text","text":"plain"} |
| + ]}]}` |
| + got := strings.TrimSpace(export.Markdown(mustParse(t, "", js))) |
| + if got != "**bold** plain" { |
| + t.Errorf("got %q, want %q", got, "**bold** plain") |
| + } |
| +} |
| + |
| +// Font and size marks are outside Markdown's subset by design — see |
| +// the package comment. What must not happen is the text going with |
| +// them. |
| +func TestMarkdownKeepsTextOfMarksItCannotExpress(t *testing.T) { |
| + js := `{"type":"doc","content":[{"type":"paragraph","content":[ |
| + {"type":"text","text":"in the sans face","marks":[{"type":"font_sans"},{"type":"size_lg"}]} |
| + ]}]}` |
| + got := strings.TrimSpace(export.Markdown(mustParse(t, "", js))) |
| + if got != "in the sans face" { |
| + t.Errorf("got %q, want the text unchanged", got) |
| + } |
| +} |
| + |
| +// The core carries unknown nodes through so a newer editor build |
| +// cannot lose a reader's text. That guarantee has to survive the |
| +// exporter too. |
| +func TestMarkdownKeepsTextInsideUnknownNodes(t *testing.T) { |
| + js := `{"type":"doc","content":[ |
| + {"type":"table","content":[{"type":"paragraph","content":[{"type":"text","text":"kept"}]}]} |
| + ]}` |
| + got := export.Markdown(mustParse(t, "", js)) |
| + if !strings.Contains(got, "kept") { |
| + t.Errorf("text inside an unknown node was lost: %q", got) |
| + } |
| +} |
| + |
| +func TestMarkdownOfAnEmptyDocumentIsJustItsTitle(t *testing.T) { |
| + got := export.Markdown(mustParse(t, "Untitled document", `{"type":"doc","content":[]}`)) |
| + if got != "# Untitled document\n" { |
| + t.Errorf("got %q", got) |
| + } |
| +} |
| + |
| +func TestParseRejectsSomethingThatIsNotADocument(t *testing.T) { |
| + if _, err := export.Parse("t", `{"type":"paragraph"}`); err == nil { |
| + t.Error("a non-doc root must be an error, not an empty export") |
| + } |
| + if _, err := export.Parse("t", `not json`); err == nil { |
| + t.Error("invalid JSON must be an error") |
| + } |
| +} |
| + |
| +// jsonString quotes s the way a JSON encoder would, for building the |
| +// fixtures above. |
| +func jsonString(s string) string { |
| + var b strings.Builder |
| + b.WriteByte('"') |
| + for _, r := range s { |
| + switch r { |
| + case '"': |
| + b.WriteString(`\"`) |
| + case '\\': |
| + b.WriteString(`\\`) |
| + default: |
| + b.WriteRune(r) |
| + } |
| + } |
| + b.WriteByte('"') |
| + return b.String() |
| +} |
diff --git a/internal/fonts/files/LICENSE-OFL-1.1.txt b/internal/fonts/files/LICENSE-OFL-1.1.txt| new file mode 100644 |
| index 0000000..84619fc |
| --- /dev/null |
| +++ b/internal/fonts/files/LICENSE-OFL-1.1.txt |
| @@ -0,0 +1,91 @@ |
| +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 |
| + |
| +PREAMBLE The goals of the Open Font License (OFL) are to stimulate |
| +worldwide development of collaborative font projects, to support the font |
| +creation efforts of academic and linguistic communities, and to provide |
| +a free and open framework in which fonts may be shared and improved in |
| +partnership with others. |
| + |
| +The OFL allows the licensed fonts to be used, studied, modified and |
| +redistributed freely as long as they are not sold by themselves. |
| +The fonts, including any derivative works, can be bundled, embedded, |
| +redistributed and/or sold with any software provided that any reserved |
| +names are not used by derivative works. The fonts and derivatives, |
| +however, cannot be released under any other type of license. The |
| +requirement for fonts to remain under this license does not apply to |
| +any document created using the fonts or their derivatives. |
| + |
| + |
| + |
| +DEFINITIONS |
| +"Font Software" refers to the set of files released by the Copyright |
| +Holder(s) under this license and clearly marked as such. |
| +This may include source files, build scripts and documentation. |
| + |
| +"Reserved Font Name" refers to any names specified as such after the |
| +copyright statement(s). |
| + |
| +"Original Version" refers to the collection of Font Software components |
| +as distributed by the Copyright Holder(s). |
| + |
| +"Modified Version" refers to any derivative made by adding to, deleting, |
| +or substituting ? in part or in whole ? |
| +any of the components of the Original Version, by changing formats or |
| +by porting the Font Software to a new environment. |
| + |
| +"Author" refers to any designer, engineer, programmer, technical writer |
| +or other person who contributed to the Font Software. |
| + |
| + |
| +PERMISSION & CONDITIONS |
| + |
| +Permission is hereby granted, free of charge, to any person obtaining a |
| +copy of the Font Software, to use, study, copy, merge, embed, modify, |
| +redistribute, and sell modified and unmodified copies of the Font |
| +Software, subject to the following conditions: |
| + |
| +1) Neither the Font Software nor any of its individual components,in |
| + Original or Modified Versions, may be sold by itself. |
| + |
| +2) Original or Modified Versions of the Font Software may be bundled, |
| + redistributed and/or sold with any software, provided that each copy |
| + contains the above copyright notice and this license. These can be |
| + included either as stand-alone text files, human-readable headers or |
| + in the appropriate machine-readable metadata fields within text or |
| + binary files as long as those fields can be easily viewed by the user. |
| + |
| +3) No Modified Version of the Font Software may use the Reserved Font |
| + Name(s) unless explicit written permission is granted by the |
| + corresponding Copyright Holder. This restriction only applies to the |
| + primary font name as presented to the users. |
| + |
| +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font |
| + Software shall not be used to promote, endorse or advertise any |
| + Modified Version, except to acknowledge the contribution(s) of the |
| + Copyright Holder(s) and the Author(s) or with their explicit written |
| + permission. |
| + |
| +5) The Font Software, modified or unmodified, in part or in whole, must |
| + be distributed entirely under this license, and must not be distributed |
| + under any other license. The requirement for fonts to remain under |
| + this license does not apply to any document created using the Font |
| + Software. |
| + |
| + |
| + |
| +TERMINATION |
| +This license becomes null and void if any of the above conditions are not met. |
| + |
| + |
| + |
| +DISCLAIMER |
| +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
| +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF |
| +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT |
| +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE |
| +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, |
| +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL |
| +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING |
| +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER |
| +DEALINGS IN THE FONT SOFTWARE. |
| + |
diff --git a/internal/fonts/files/LiberationMono-Bold.ttf b/internal/fonts/files/LiberationMono-Bold.ttf| new file mode 100644 |
| index 0000000..c490006 |
| Binary files /dev/null and b/internal/fonts/files/LiberationMono-Bold.ttf differ |
diff --git a/internal/fonts/files/LiberationMono-BoldItalic.ttf b/internal/fonts/files/LiberationMono-BoldItalic.ttf| new file mode 100644 |
| index 0000000..f107ec6 |
| Binary files /dev/null and b/internal/fonts/files/LiberationMono-BoldItalic.ttf differ |
diff --git a/internal/fonts/files/LiberationMono-Italic.ttf b/internal/fonts/files/LiberationMono-Italic.ttf| new file mode 100644 |
| index 0000000..c7fa413 |
| Binary files /dev/null and b/internal/fonts/files/LiberationMono-Italic.ttf differ |
diff --git a/internal/fonts/files/LiberationMono-Regular.ttf b/internal/fonts/files/LiberationMono-Regular.ttf| new file mode 100644 |
| index 0000000..fa81eaa |
| Binary files /dev/null and b/internal/fonts/files/LiberationMono-Regular.ttf differ |
diff --git a/internal/fonts/files/LiberationSans-Bold.ttf b/internal/fonts/files/LiberationSans-Bold.ttf| new file mode 100644 |
| index 0000000..31ddaef |
| Binary files /dev/null and b/internal/fonts/files/LiberationSans-Bold.ttf differ |
diff --git a/internal/fonts/files/LiberationSans-BoldItalic.ttf b/internal/fonts/files/LiberationSans-BoldItalic.ttf| new file mode 100644 |
| index 0000000..153950d |
| Binary files /dev/null and b/internal/fonts/files/LiberationSans-BoldItalic.ttf differ |
diff --git a/internal/fonts/files/LiberationSans-Italic.ttf b/internal/fonts/files/LiberationSans-Italic.ttf| new file mode 100644 |
| index 0000000..ebf48bc |
| Binary files /dev/null and b/internal/fonts/files/LiberationSans-Italic.ttf differ |
diff --git a/internal/fonts/files/LiberationSans-Regular.ttf b/internal/fonts/files/LiberationSans-Regular.ttf| new file mode 100644 |
| index 0000000..f524339 |
| Binary files /dev/null and b/internal/fonts/files/LiberationSans-Regular.ttf differ |
diff --git a/internal/fonts/files/LiberationSerif-Bold.ttf b/internal/fonts/files/LiberationSerif-Bold.ttf| new file mode 100644 |
| index 0000000..8dce980 |
| Binary files /dev/null and b/internal/fonts/files/LiberationSerif-Bold.ttf differ |
diff --git a/internal/fonts/files/LiberationSerif-BoldItalic.ttf b/internal/fonts/files/LiberationSerif-BoldItalic.ttf| new file mode 100644 |
| index 0000000..04c27fd |
| Binary files /dev/null and b/internal/fonts/files/LiberationSerif-BoldItalic.ttf differ |
diff --git a/internal/fonts/files/LiberationSerif-Italic.ttf b/internal/fonts/files/LiberationSerif-Italic.ttf| new file mode 100644 |
| index 0000000..01b9210 |
| Binary files /dev/null and b/internal/fonts/files/LiberationSerif-Italic.ttf differ |
diff --git a/internal/fonts/files/LiberationSerif-Regular.ttf b/internal/fonts/files/LiberationSerif-Regular.ttf| new file mode 100644 |
| index 0000000..d7622a3 |
| Binary files /dev/null and b/internal/fonts/files/LiberationSerif-Regular.ttf differ |
diff --git a/internal/fonts/fonts.go b/internal/fonts/fonts.go| new file mode 100644 |
| index 0000000..1050a8f |
| --- /dev/null |
| +++ b/internal/fonts/fonts.go |
| @@ -0,0 +1,256 @@ |
| +// Package fonts is CARLOS Docs' core font set: the same font programs |
| +// the browser renders a document with and the PDF exporter embeds in |
| +// one, so the two measure identically. |
| +// |
| +// That "identically" is the whole point, and it is spec §11.1's actual |
| +// requirement — deterministic layout is impossible while the screen |
| +// lays out with whatever face the reader's machine happens to call |
| +// "Georgia" and the exporter lays out with something else. A document |
| +// that paginates one way on screen and another way in its own PDF is |
| +// not a rendering difference, it is two documents. |
| +// |
| +// # Which faces, and why not the ones §11.1 names |
| +// |
| +// §11.1 names Carlito and Caladea. Those are the *metric-compatible |
| +// substitutions* for the two proprietary faces a DOCX import names |
| +// (Calibri and Cambria) — they have a job the moment DOCX import |
| +// exists to name Calibri, and no job before that, because nothing in |
| +// this app can currently produce a document that asks for Calibri. |
| +// What §11.1 asks for that this package does deliver is the curated, |
| +// self-hosted core set. |
| +// |
| +// Liberation is that set here: Serif, Sans and Mono, four faces each, |
| +// SIL OFL 1.1 (files/LICENSE-OFL-1.1.txt, redistributed with them as |
| +// that licence requires). It is the same idea one family over — |
| +// Liberation is the metric-compatible set for Times New Roman, Arial |
| +// and Courier New, exactly as Carlito and Caladea are for Calibri and |
| +// Cambria. When DOCX import lands, Carlito and Caladea join this |
| +// payload; nothing here forecloses that. |
| +// |
| +// # TrueType, not woff2 |
| +// |
| +// §11.1 says woff2 and is right to: these files are roughly twice the |
| +// bytes a woff2 would be. Converting them needs a compressor this |
| +// build does not have, and a browser fetches only the faces a page |
| +// actually uses, so a declared-but-unused face costs a reader nothing. |
| +// The conversion is a follow-up with a real trigger — the first time |
| +// page weight is measured and these show up in it — not a blocker for |
| +// having a core set at all. |
| +package fonts |
| + |
| +import ( |
| + "crypto/sha256" |
| + "embed" |
| + "encoding/hex" |
| + "fmt" |
| + "io/fs" |
| + "sort" |
| + "strings" |
| +) |
| + |
| +//go:embed files |
| +var files embed.FS |
| + |
| +// Family is one of the three faces a document can ask for. Serif is |
| +// the default — a document that has never been told otherwise is set |
| +// in it — which is why it is the zero value and why nothing in the |
| +// CRDT marks a serif run as serif (core/src/fragment.rs's MARKS). |
| +type Family uint8 |
| + |
| +const ( |
| + Serif Family = iota |
| + Sans |
| + Mono |
| +) |
| + |
| +// Key is the family's name in the vocabulary shared by the CRDT marks |
| +// (font_sans, font_mono), the CSS classes (docs-font-sans), and the |
| +// toolbar's own control. Serif's key exists for the toolbar to select |
| +// with even though no mark ever carries it. |
| +func (f Family) Key() string { |
| + switch f { |
| + case Sans: |
| + return "sans" |
| + case Mono: |
| + return "mono" |
| + default: |
| + return "serif" |
| + } |
| +} |
| + |
| +// CSSName is the family name the browser matches @font-face against. |
| +// Deliberately not "Liberation Serif": a reader with Liberation |
| +// installed locally would otherwise match their own copy, which may be |
| +// a different version with different metrics, and the PDF would stop |
| +// agreeing with the screen for exactly the readers who look most |
| +// likely to be fine. A private name can only ever match the file this |
| +// binary serves. |
| +func (f Family) CSSName() string { |
| + switch f { |
| + case Sans: |
| + return "Docs Sans" |
| + case Mono: |
| + return "Docs Mono" |
| + default: |
| + return "Docs Serif" |
| + } |
| +} |
| + |
| +// FamilyForKey resolves a key from the shared vocabulary above. The |
| +// false return is load-bearing: keys reach this from mark names in a |
| +// document, and a document can carry any bytes at all |
| +// (POST /d/{sqid}/updates), so an unrecognised key must be a miss the |
| +// caller decides about rather than a silent fallback to serif that |
| +// hides a mark this build does not understand. |
| +func FamilyForKey(key string) (Family, bool) { |
| + switch key { |
| + case "serif": |
| + return Serif, true |
| + case "sans": |
| + return Sans, true |
| + case "mono": |
| + return Mono, true |
| + } |
| + return 0, false |
| +} |
| + |
| +// Face is one font program: a family and a weight/slope, the bytes, |
| +// and the URL it is served at. |
| +type Face struct { |
| + Family Family |
| + Bold bool |
| + Italic bool |
| + |
| + // File is the name inside files/, and Path is the URL the app |
| + // serves it at — the same content-hashed, immutable-cacheable |
| + // shape internal/docs/assets.go uses for the editor bundle, and |
| + // safe for the same reason: the URL changes whenever the bytes do, |
| + // so a stale cache entry has nothing to collide with. |
| + File string |
| + Path string |
| + |
| + Data []byte |
| +} |
| + |
| +// PostScriptName is the name this face is registered under inside a |
| +// PDF. It is not a display name and nothing matches on it; it exists |
| +// because a PDF font dictionary requires one and a reader shows it in |
| +// document properties. |
| +func (f Face) PostScriptName() string { |
| + name := map[Family]string{Serif: "LiberationSerif", Sans: "LiberationSans", Mono: "LiberationMono"}[f.Family] |
| + switch { |
| + case f.Bold && f.Italic: |
| + return name + "-BoldItalic" |
| + case f.Bold: |
| + return name + "-Bold" |
| + case f.Italic: |
| + return name + "-Italic" |
| + } |
| + return name + "-Regular" |
| +} |
| + |
| +// faceFiles maps each of the twelve faces to its file. Written out |
| +// rather than assembled from the family name and a suffix so that a |
| +// missing or renamed file is a compile-time-visible line here and a |
| +// panic at init (see the init below), not a nil Data discovered by |
| +// whoever exports a bold-italic paragraph six months from now. |
| +var faceFiles = []struct { |
| + family Family |
| + bold, italic bool |
| + file string |
| +}{ |
| + {Serif, false, false, "LiberationSerif-Regular.ttf"}, |
| + {Serif, true, false, "LiberationSerif-Bold.ttf"}, |
| + {Serif, false, true, "LiberationSerif-Italic.ttf"}, |
| + {Serif, true, true, "LiberationSerif-BoldItalic.ttf"}, |
| + {Sans, false, false, "LiberationSans-Regular.ttf"}, |
| + {Sans, true, false, "LiberationSans-Bold.ttf"}, |
| + {Sans, false, true, "LiberationSans-Italic.ttf"}, |
| + {Sans, true, true, "LiberationSans-BoldItalic.ttf"}, |
| + {Mono, false, false, "LiberationMono-Regular.ttf"}, |
| + {Mono, true, false, "LiberationMono-Bold.ttf"}, |
| + {Mono, false, true, "LiberationMono-Italic.ttf"}, |
| + {Mono, true, true, "LiberationMono-BoldItalic.ttf"}, |
| +} |
| + |
| +var faces []Face |
| + |
| +// byPath indexes the served faces for Asset below. |
| +var byPath = map[string]Face{} |
| + |
| +// PathPrefix is where the app mounts this package's assets. Exported |
| +// so internal/docs routes on the same constant this package builds |
| +// Face.Path from, rather than on a second literal that could drift. |
| +const PathPrefix = "/static/fonts/" |
| + |
| +func init() { |
| + for _, ff := range faceFiles { |
| + data, err := fs.ReadFile(files, "files/"+ff.file) |
| + if err != nil { |
| + // A missing face is a build mistake, and a build mistake |
| + // that reaches a running instance shows up as a document |
| + // exporting without its bold text rather than as an error. |
| + // Refuse to start instead. |
| + panic(fmt.Sprintf("fonts: embedded face %q missing: %v", ff.file, err)) |
| + } |
| + sum := sha256.Sum256(data) |
| + hash := hex.EncodeToString(sum[:])[:16] |
| + name := strings.TrimSuffix(ff.file, ".ttf") |
| + f := Face{ |
| + Family: ff.family, |
| + Bold: ff.bold, |
| + Italic: ff.italic, |
| + File: ff.file, |
| + Path: PathPrefix + name + "." + hash + ".ttf", |
| + Data: data, |
| + } |
| + faces = append(faces, f) |
| + byPath[f.Path] = f |
| + } |
| + sort.Slice(faces, func(i, j int) bool { return faces[i].Path < faces[j].Path }) |
| +} |
| + |
| +// All returns every face, in a stable order. |
| +func All() []Face { |
| + out := make([]Face, len(faces)) |
| + copy(out, faces) |
| + return out |
| +} |
| + |
| +// Get returns the one face for a family and weight/slope. Every |
| +// combination exists, so there is no miss to report — that is what |
| +// faceFiles above being exhaustive buys. |
| +func Get(family Family, bold, italic bool) Face { |
| + for _, f := range faces { |
| + if f.Family == family && f.Bold == bold && f.Italic == italic { |
| + return f |
| + } |
| + } |
| + // Unreachable while faceFiles covers all twelve; init would have |
| + // panicked otherwise. Falling back to regular serif rather than |
| + // panicking here because this is called per text run during an |
| + // export, and losing a document's italics beats losing the export. |
| + return faces[0] |
| +} |
| + |
| +// Asset resolves a served URL path to its face. |
| +func Asset(path string) (Face, bool) { |
| + f, ok := byPath[path] |
| + return f, ok |
| +} |
| + |
| +// Licence is the SIL OFL 1.1 text these faces are redistributed |
| +// under. Served alongside them: the licence requires the text to |
| +// travel with the font software, and a font shipped inside a Go |
| +// binary and served over HTTP is still font software being |
| +// distributed. |
| +func Licence() string { |
| + b, err := fs.ReadFile(files, "files/LICENSE-OFL-1.1.txt") |
| + if err != nil { |
| + panic(fmt.Sprintf("fonts: embedded licence missing: %v", err)) |
| + } |
| + return string(b) |
| +} |
| + |
| +// LicencePath is where Licence is served. |
| +const LicencePath = PathPrefix + "LICENSE-OFL-1.1.txt" |
diff --git a/internal/fonts/fonts_test.go b/internal/fonts/fonts_test.go| new file mode 100644 |
| index 0000000..ba29138 |
| --- /dev/null |
| +++ b/internal/fonts/fonts_test.go |
| @@ -0,0 +1,230 @@ |
| +package fonts_test |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/fonts" |
| +) |
| + |
| +func TestEveryFaceParses(t *testing.T) { |
| + all := fonts.All() |
| + if len(all) != 12 { |
| + t.Fatalf("want 12 faces (3 families x 4 styles), got %d", len(all)) |
| + } |
| + for _, face := range all { |
| + font, err := face.Parsed() |
| + if err != nil { |
| + t.Fatalf("%s: %v", face.File, err) |
| + } |
| + if font.UnitsPerEm == 0 || font.NumGlyphs == 0 { |
| + t.Errorf("%s: unitsPerEm=%d numGlyphs=%d", face.File, font.UnitsPerEm, font.NumGlyphs) |
| + } |
| + if font.Ascender <= 0 || font.Descender >= 0 { |
| + t.Errorf("%s: implausible vertical metrics asc=%d desc=%d", face.File, font.Ascender, font.Descender) |
| + } |
| + // Every face must be able to set ordinary prose. A face whose |
| + // cmap this build failed to read still parses, so the check |
| + // that matters is that letters resolve to real glyphs. |
| + for _, r := range "The quick brown fox, 0123456789 — “quoted” café" { |
| + if font.GlyphIndex(r) == 0 { |
| + t.Errorf("%s: no glyph for %q", face.File, r) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// Advance widths are what every measurement in internal/pdf is built |
| +// on. A monospace face is the one where a wrong answer is unmissable: |
| +// every glyph must advance by the same amount. |
| +func TestMonoAdvancesAreUniform(t *testing.T) { |
| + font, err := fonts.Get(fonts.Mono, false, false).Parsed() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := font.Advance(font.GlyphIndex('m')) |
| + if want == 0 { |
| + t.Fatal("no advance for 'm'") |
| + } |
| + for _, r := range "il.MW@" { |
| + if got := font.Advance(font.GlyphIndex(r)); got != want { |
| + t.Errorf("mono advance for %q = %d, want %d", r, got, want) |
| + } |
| + } |
| +} |
| + |
| +// The proportional faces must NOT be uniform — the same assertion the |
| +// other way round, so a bug that returned one constant width for |
| +// everything cannot pass both tests. |
| +func TestProportionalAdvancesDiffer(t *testing.T) { |
| + font, err := fonts.Get(fonts.Serif, false, false).Parsed() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + narrow := font.Advance(font.GlyphIndex('i')) |
| + wide := font.Advance(font.GlyphIndex('W')) |
| + if narrow == 0 || wide == 0 || narrow >= wide { |
| + t.Errorf("serif advances: i=%d W=%d, want 0 < i < W", narrow, wide) |
| + } |
| +} |
| + |
| +func TestWidthEmScalesWithTheString(t *testing.T) { |
| + font, err := fonts.Get(fonts.Serif, false, false).Parsed() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + one, gids := font.WidthEm("n") |
| + two, _ := font.WidthEm("nn") |
| + if len(gids) != 1 { |
| + t.Errorf("WidthEm returned %d gids for one rune", len(gids)) |
| + } |
| + if one <= 0 { |
| + t.Fatalf("width of %q is %v", "n", one) |
| + } |
| + if diff := two - 2*one; diff > 1e-9 || diff < -1e-9 { |
| + t.Errorf("width(nn)=%v, want 2*width(n)=%v", two, 2*one) |
| + } |
| +} |
| + |
| +// The subset has to be a font a reader can actually use, so this |
| +// parses the output rather than checking that it got smaller. Glyph |
| +// ids must survive unchanged (the whole design — see truetype.go), the |
| +// kept glyphs must still have outlines, and a dropped glyph must be |
| +// empty rather than missing. |
| +func TestSubsetKeepsGlyphIdsAndOutlines(t *testing.T) { |
| + face := fonts.Get(fonts.Serif, false, false) |
| + font, err := face.Parsed() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + keep := map[uint16]bool{} |
| + const sample = "Hello" |
| + for _, r := range sample { |
| + keep[font.GlyphIndex(r)] = true |
| + } |
| + |
| + sub, err := font.Subset(keep) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(sub) >= len(face.Data) { |
| + t.Errorf("subset is %d bytes, original is %d — not a subset", len(sub), len(face.Data)) |
| + } |
| + |
| + reparsed, err := fonts.Parse(sub) |
| + if err != nil { |
| + t.Fatalf("the subset does not parse: %v", err) |
| + } |
| + if reparsed.NumGlyphs != font.NumGlyphs { |
| + t.Errorf("subset numGlyphs=%d, want %d unchanged", reparsed.NumGlyphs, font.NumGlyphs) |
| + } |
| + if reparsed.UnitsPerEm != font.UnitsPerEm { |
| + t.Errorf("subset unitsPerEm=%d, want %d", reparsed.UnitsPerEm, font.UnitsPerEm) |
| + } |
| + for _, r := range sample { |
| + gid := font.GlyphIndex(r) |
| + if got := reparsed.Advance(gid); got != font.Advance(gid) { |
| + t.Errorf("advance for %q changed: %d -> %d", r, font.Advance(gid), got) |
| + } |
| + if !glyphHasOutline(t, sub, gid) { |
| + t.Errorf("kept glyph for %q has no outline in the subset", r) |
| + } |
| + } |
| + |
| + // A glyph nobody asked for must be gone. 'Z' is not in "Hello". |
| + if gid := font.GlyphIndex('Z'); gid != 0 && glyphHasOutline(t, sub, gid) { |
| + t.Errorf("glyph for 'Z' survived a subset that did not ask for it") |
| + } |
| +} |
| + |
| +// A composite glyph's components must come along, or the accent |
| +// renders as nothing. |
| +func TestSubsetFollowsCompositeComponents(t *testing.T) { |
| + face := fonts.Get(fonts.Serif, false, false) |
| + font, err := face.Parsed() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + gid := font.GlyphIndex('é') |
| + if gid == 0 { |
| + t.Skip("no é in this face") |
| + } |
| + sub, err := font.Subset(map[uint16]bool{gid: true}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !glyphHasOutline(t, sub, gid) { |
| + t.Fatal("é itself is missing from its own subset") |
| + } |
| + // 'e' was never asked for; it must still be present, because é is |
| + // assembled from it. (If this face happens to draw é as a simple |
| + // glyph, the assertion still holds trivially — é has an outline |
| + // either way — so this cannot pass by accident on a face where |
| + // there is nothing to follow.) |
| + base := font.GlyphIndex('e') |
| + if isComposite(t, face.Data, font, gid) && !glyphHasOutline(t, sub, base) { |
| + t.Error("é is composite but its base glyph was dropped from the subset") |
| + } |
| +} |
| + |
| +// glyphHasOutline reports whether gid occupies bytes in the file's |
| +// glyf table — the actual question a reader asks when it draws. |
| +func glyphHasOutline(t *testing.T, file []byte, gid uint16) bool { |
| + t.Helper() |
| + f, err := fonts.Parse(file) |
| + if err != nil { |
| + t.Fatalf("parse: %v", err) |
| + } |
| + return f.GlyphLen(gid) > 0 |
| +} |
| + |
| +func isComposite(t *testing.T, file []byte, orig *fonts.Font, gid uint16) bool { |
| + t.Helper() |
| + f, err := fonts.Parse(file) |
| + if err != nil { |
| + t.Fatalf("parse: %v", err) |
| + } |
| + return f.GlyphIsComposite(gid) |
| +} |
| + |
| +// Every face is served at a content-hashed path, so the URL changes |
| +// whenever the bytes do and the year-long cache header is safe. |
| +func TestFacePathsAreUniqueAndHashed(t *testing.T) { |
| + seen := map[string]bool{} |
| + for _, f := range fonts.All() { |
| + if !strings.HasPrefix(f.Path, fonts.PathPrefix) || !strings.HasSuffix(f.Path, ".ttf") { |
| + t.Errorf("%s: unexpected path %q", f.File, f.Path) |
| + } |
| + if seen[f.Path] { |
| + t.Errorf("duplicate path %q", f.Path) |
| + } |
| + seen[f.Path] = true |
| + got, ok := fonts.Asset(f.Path) |
| + if !ok || got.File != f.File { |
| + t.Errorf("Asset(%q) did not round-trip to %s", f.Path, f.File) |
| + } |
| + } |
| +} |
| + |
| +func TestLicenceTravelsWithTheFonts(t *testing.T) { |
| + l := fonts.Licence() |
| + if !strings.Contains(l, "SIL OPEN FONT LICENSE Version 1.1") { |
| + t.Error("the OFL text is not what is being served") |
| + } |
| + if strings.Contains(l, "GNU General Public License") { |
| + t.Error("the Debian packaging licence leaked into the font licence file") |
| + } |
| +} |
| + |
| +func TestFamilyKeysRoundTrip(t *testing.T) { |
| + for _, fam := range []fonts.Family{fonts.Serif, fonts.Sans, fonts.Mono} { |
| + got, ok := fonts.FamilyForKey(fam.Key()) |
| + if !ok || got != fam { |
| + t.Errorf("%q did not round-trip", fam.Key()) |
| + } |
| + } |
| + if _, ok := fonts.FamilyForKey("comic"); ok { |
| + t.Error("an unknown family key must be a miss, not a silent serif") |
| + } |
| +} |
diff --git a/internal/fonts/truetype.go b/internal/fonts/truetype.go| new file mode 100644 |
| index 0000000..ac1897c |
| --- /dev/null |
| +++ b/internal/fonts/truetype.go |
| @@ -0,0 +1,658 @@ |
| +package fonts |
| + |
| +import ( |
| + "encoding/binary" |
| + "errors" |
| + "fmt" |
| + "sort" |
| + "sync" |
| +) |
| + |
| +// A minimal TrueType reader and subsetter — enough to measure text and |
| +// to embed a face in a PDF, and deliberately not one byte more. |
| +// |
| +// It reads the six tables a CIDFontType2 embedding actually requires |
| +// (head, hhea, maxp, hmtx, loca, glyf) plus cmap to find glyphs by |
| +// rune, and post/OS2 for the two descriptor fields a PDF reader wants. |
| +// It does not read GPOS, so it does not kern; text measured here is |
| +// the sum of advance widths, which is what an unkerned browser layout |
| +// of the same string also is. It does not read GSUB, so no ligatures |
| +// and no complex-script shaping — a document in a script that needs |
| +// shaping lays out wrongly here, which is the same limitation §11.1 |
| +// already records for the scripts outside the core set. |
| +// |
| +// # Why subsetting keeps the original glyph ids |
| +// |
| +// The usual subsetter renumbers glyphs densely and rewrites every |
| +// reference to them. This one keeps every glyph id exactly where it |
| +// was and simply empties the glyphs nothing uses: `loca` still has |
| +// numGlyphs+1 entries, but an unused glyph's two entries are equal, so |
| +// it occupies no bytes in `glyf`. |
| +// |
| +// That is a real subset — a document using eighty glyphs of Liberation |
| +// Serif embeds about 25KB rather than 385KB — and it removes the two |
| +// places renumbering goes wrong: composite glyphs referring to their |
| +// components by id (no remapping needed, because ids do not move), and |
| +// the PDF's CIDToGIDMap (Identity, because a CID *is* the glyph id). |
| +// What it costs is `hmtx` and `loca`, which stay proportional to the |
| +// font's full glyph count rather than the subset's. For a 2,500-glyph |
| +// face that is about 15KB — worth paying to not have a subsetter whose |
| +// failure mode is a document that renders with the wrong letters. |
| + |
| +var ( |
| + errShortTable = errors.New("fonts: truetype table is shorter than its own header claims") |
| + errNoTable = errors.New("fonts: truetype table missing") |
| +) |
| + |
| +// Font is a parsed face. |
| +type Font struct { |
| + data []byte |
| + tables map[string][]byte |
| + |
| + UnitsPerEm uint16 |
| + NumGlyphs uint16 |
| + |
| + // Ascender, Descender and LineGap are hhea's, in font units. |
| + // Descender is negative, as the table stores it. |
| + Ascender int16 |
| + Descender int16 |
| + LineGap int16 |
| + |
| + // CapHeight and ItalicAngle are for the PDF font descriptor. Both |
| + // have defaults that are wrong-but-harmless if the source tables |
| + // are absent (a reader uses them for substitution decisions it |
| + // will never have to make, because the font is embedded). |
| + CapHeight int16 |
| + ItalicAngle float64 |
| + BBox [4]int16 |
| + |
| + indexToLocFormat int16 |
| + numberOfHMetrics uint16 |
| + |
| + advances []uint16 // one per glyph id, font units |
| + cmap map[rune]uint16 |
| +} |
| + |
| +func be16(b []byte, off int) (uint16, bool) { |
| + if off < 0 || off+2 > len(b) { |
| + return 0, false |
| + } |
| + return binary.BigEndian.Uint16(b[off:]), true |
| +} |
| + |
| +func be32(b []byte, off int) (uint32, bool) { |
| + if off < 0 || off+4 > len(b) { |
| + return 0, false |
| + } |
| + return binary.BigEndian.Uint32(b[off:]), true |
| +} |
| + |
| +// Parse reads the tables this package needs out of a TrueType file. |
| +// |
| +// Every read is bounds-checked even though the only fonts reaching it |
| +// are the twelve embedded in this binary: a parser that is careful |
| +// only about untrusted input is a parser nobody may ever point at |
| +// untrusted input, and this one is one `//go:embed` away from being |
| +// handed a font a person uploaded. |
| +func Parse(data []byte) (*Font, error) { |
| + if len(data) < 12 { |
| + return nil, errShortTable |
| + } |
| + switch binary.BigEndian.Uint32(data) { |
| + case 0x00010000, 0x74727565: // 'true' |
| + default: |
| + return nil, fmt.Errorf("fonts: not a TrueType outline font (sfnt version %#x)", binary.BigEndian.Uint32(data)) |
| + } |
| + numTables := int(binary.BigEndian.Uint16(data[4:])) |
| + tables := make(map[string][]byte, numTables) |
| + for i := 0; i < numTables; i++ { |
| + rec := 12 + i*16 |
| + if rec+16 > len(data) { |
| + return nil, errShortTable |
| + } |
| + tag := string(data[rec : rec+4]) |
| + off := int(binary.BigEndian.Uint32(data[rec+8:])) |
| + length := int(binary.BigEndian.Uint32(data[rec+12:])) |
| + if off < 0 || length < 0 || off+length > len(data) { |
| + return nil, fmt.Errorf("fonts: table %q runs past the end of the file", tag) |
| + } |
| + tables[tag] = data[off : off+length] |
| + } |
| + |
| + f := &Font{data: data, tables: tables} |
| + |
| + head, ok := tables["head"] |
| + if !ok || len(head) < 54 { |
| + return nil, fmt.Errorf("%w: head", errNoTable) |
| + } |
| + f.UnitsPerEm = binary.BigEndian.Uint16(head[18:]) |
| + if f.UnitsPerEm == 0 { |
| + return nil, errors.New("fonts: head.unitsPerEm is zero") |
| + } |
| + for i := 0; i < 4; i++ { |
| + f.BBox[i] = int16(binary.BigEndian.Uint16(head[36+i*2:])) |
| + } |
| + f.indexToLocFormat = int16(binary.BigEndian.Uint16(head[50:])) |
| + |
| + hhea, ok := tables["hhea"] |
| + if !ok || len(hhea) < 36 { |
| + return nil, fmt.Errorf("%w: hhea", errNoTable) |
| + } |
| + f.Ascender = int16(binary.BigEndian.Uint16(hhea[4:])) |
| + f.Descender = int16(binary.BigEndian.Uint16(hhea[6:])) |
| + f.LineGap = int16(binary.BigEndian.Uint16(hhea[8:])) |
| + f.numberOfHMetrics = binary.BigEndian.Uint16(hhea[34:]) |
| + |
| + maxp, ok := tables["maxp"] |
| + if !ok || len(maxp) < 6 { |
| + return nil, fmt.Errorf("%w: maxp", errNoTable) |
| + } |
| + f.NumGlyphs = binary.BigEndian.Uint16(maxp[4:]) |
| + |
| + if os2, ok := tables["OS/2"]; ok && len(os2) >= 90 { |
| + f.CapHeight = int16(binary.BigEndian.Uint16(os2[88:])) |
| + } |
| + if f.CapHeight == 0 { |
| + // sCapHeight is only present from OS/2 version 2. The ascender |
| + // overstates cap height slightly; a PDF reader uses this for |
| + // substitution metrics it never needs for an embedded font. |
| + f.CapHeight = f.Ascender |
| + } |
| + if post, ok := tables["post"]; ok && len(post) >= 12 { |
| + // Fixed 16.16 signed. |
| + f.ItalicAngle = float64(int32(binary.BigEndian.Uint32(post[4:]))) / 65536.0 |
| + } |
| + |
| + if err := f.parseHmtx(); err != nil { |
| + return nil, err |
| + } |
| + if err := f.parseCmap(); err != nil { |
| + return nil, err |
| + } |
| + return f, nil |
| +} |
| + |
| +func (f *Font) parseHmtx() error { |
| + hmtx, ok := f.tables["hmtx"] |
| + if !ok { |
| + return fmt.Errorf("%w: hmtx", errNoTable) |
| + } |
| + n := int(f.numberOfHMetrics) |
| + if n == 0 || n > int(f.NumGlyphs) { |
| + return errors.New("fonts: hhea.numberOfHMetrics disagrees with maxp.numGlyphs") |
| + } |
| + f.advances = make([]uint16, f.NumGlyphs) |
| + var last uint16 |
| + for i := 0; i < int(f.NumGlyphs); i++ { |
| + if i < n { |
| + v, ok := be16(hmtx, i*4) |
| + if !ok { |
| + return errShortTable |
| + } |
| + last = v |
| + } |
| + // Past numberOfHMetrics every glyph repeats the last advance — |
| + // that is what the table's compressed tail means, not "no |
| + // advance". |
| + f.advances[i] = last |
| + } |
| + return nil |
| +} |
| + |
| +// parseCmap picks the best Unicode subtable it understands and expands |
| +// it into a map. Expanding eagerly rather than searching per lookup: |
| +// a face has a few thousand mapped runes, a document has thousands of |
| +// characters, and the map is built once per face per process. |
| +func (f *Font) parseCmap() error { |
| + cm, ok := f.tables["cmap"] |
| + if !ok || len(cm) < 4 { |
| + // Not an error. A font whose glyphs are addressed by id rather |
| + // than by rune needs no cmap and is required to carry none — |
| + // which is exactly what Subset produces, since a PDF |
| + // CIDFontType2 under Identity-H looks glyphs up by id. Parse |
| + // has to be able to read its own output back, which is how the |
| + // subsetter is tested at all. |
| + // |
| + // The risk this accepts is a *primary* face arriving without a |
| + // usable cmap and measuring every character as .notdef in |
| + // silence. That is caught where it can actually be judged: |
| + // fonts_test.go's TestEveryFaceParses asserts each of the |
| + // twelve embedded faces resolves ordinary prose to real glyph |
| + // ids, which a cmap-less face cannot do. |
| + f.cmap = map[rune]uint16{} |
| + return nil |
| + } |
| + n := int(binary.BigEndian.Uint16(cm[2:])) |
| + |
| + // Preference order: a full-repertoire subtable first, then a BMP |
| + // one. (3,10) and (0,4)/(0,6) are format 12; (3,1) and (0,3) are |
| + // format 4. |
| + best, bestScore := -1, -1 |
| + for i := 0; i < n; i++ { |
| + rec := 4 + i*8 |
| + plat, ok1 := be16(cm, rec) |
| + enc, ok2 := be16(cm, rec+2) |
| + off, ok3 := be32(cm, rec+4) |
| + if !ok1 || !ok2 || !ok3 || int(off) >= len(cm) { |
| + continue |
| + } |
| + score := -1 |
| + switch { |
| + case plat == 3 && enc == 10: |
| + score = 4 |
| + case plat == 0 && (enc == 4 || enc == 6): |
| + score = 3 |
| + case plat == 3 && enc == 1: |
| + score = 2 |
| + case plat == 0: |
| + score = 1 |
| + } |
| + if score > bestScore { |
| + best, bestScore = int(off), score |
| + } |
| + } |
| + if best < 0 { |
| + return errors.New("fonts: cmap has no Unicode subtable this build understands") |
| + } |
| + sub := cm[best:] |
| + format, ok := be16(sub, 0) |
| + if !ok { |
| + return errShortTable |
| + } |
| + f.cmap = make(map[rune]uint16, 1024) |
| + switch format { |
| + case 4: |
| + return f.parseCmap4(sub) |
| + case 12: |
| + return f.parseCmap12(sub) |
| + default: |
| + return fmt.Errorf("fonts: cmap subtable format %d is not supported", format) |
| + } |
| +} |
| + |
| +func (f *Font) parseCmap4(sub []byte) error { |
| + segX2, ok := be16(sub, 6) |
| + if !ok || segX2%2 != 0 { |
| + return errShortTable |
| + } |
| + seg := int(segX2 / 2) |
| + endBase := 14 |
| + startBase := endBase + seg*2 + 2 |
| + deltaBase := startBase + seg*2 |
| + rangeBase := deltaBase + seg*2 |
| + if rangeBase+seg*2 > len(sub) { |
| + return errShortTable |
| + } |
| + for i := 0; i < seg; i++ { |
| + end := binary.BigEndian.Uint16(sub[endBase+i*2:]) |
| + start := binary.BigEndian.Uint16(sub[startBase+i*2:]) |
| + delta := binary.BigEndian.Uint16(sub[deltaBase+i*2:]) |
| + ro := binary.BigEndian.Uint16(sub[rangeBase+i*2:]) |
| + if start > end { |
| + continue |
| + } |
| + for c := uint32(start); c <= uint32(end); c++ { |
| + if c == 0xFFFF { |
| + continue // the required terminating segment, not a character |
| + } |
| + var gid uint16 |
| + if ro == 0 { |
| + gid = uint16(c) + delta |
| + } else { |
| + // The idRangeOffset trick: the offset is measured from |
| + // the address of the idRangeOffset entry itself. |
| + at := rangeBase + i*2 + int(ro) + int(c-uint32(start))*2 |
| + g, ok := be16(sub, at) |
| + if !ok { |
| + continue |
| + } |
| + if g == 0 { |
| + continue |
| + } |
| + gid = g + delta |
| + } |
| + if gid != 0 && gid < f.NumGlyphs { |
| + f.cmap[rune(c)] = gid |
| + } |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func (f *Font) parseCmap12(sub []byte) error { |
| + nGroups, ok := be32(sub, 12) |
| + if !ok { |
| + return errShortTable |
| + } |
| + for i := uint32(0); i < nGroups; i++ { |
| + at := 16 + int(i)*12 |
| + start, ok1 := be32(sub, at) |
| + end, ok2 := be32(sub, at+4) |
| + gid, ok3 := be32(sub, at+8) |
| + if !ok1 || !ok2 || !ok3 || start > end { |
| + continue |
| + } |
| + // A pathological group spanning the whole of Unicode would |
| + // otherwise build a map with a million entries. |
| + if end-start > 0x10FFFF { |
| + continue |
| + } |
| + for c := start; c <= end; c++ { |
| + g := gid + (c - start) |
| + if g != 0 && g < uint32(f.NumGlyphs) { |
| + f.cmap[rune(c)] = uint16(g) |
| + } |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// glyphData returns the bytes of one glyph's outline, or nil for a |
| +// glyph with none (a space, or one this subsetter emptied). |
| +func (f *Font) glyphData(gid uint16) []byte { |
| + loca, err := f.locaOffsets() |
| + if err != nil { |
| + return nil |
| + } |
| + glyf, ok := f.tables["glyf"] |
| + if !ok || int(gid)+1 >= len(loca) { |
| + return nil |
| + } |
| + start, end := loca[gid], loca[gid+1] |
| + if start >= end || int(end) > len(glyf) { |
| + return nil |
| + } |
| + return glyf[start:end] |
| +} |
| + |
| +// GlyphLen is how many bytes glyph gid occupies — zero for a glyph |
| +// with no outline, which is what a subsetted-away glyph looks like. |
| +func (f *Font) GlyphLen(gid uint16) int { return len(f.glyphData(gid)) } |
| + |
| +// GlyphIsComposite reports whether gid is assembled from other glyphs. |
| +func (f *Font) GlyphIsComposite(gid uint16) bool { |
| + g := f.glyphData(gid) |
| + return len(g) >= 2 && int16(binary.BigEndian.Uint16(g)) < 0 |
| +} |
| + |
| +// GlyphIndex maps a rune to a glyph id, or 0 (.notdef) if the face has |
| +// no glyph for it. Callers decide what a 0 means: internal/pdf reports |
| +// it as a substitution in the export rather than silently drawing an |
| +// empty box, which is §11.2's degrade-loudly rule. |
| +func (f *Font) GlyphIndex(r rune) uint16 { return f.cmap[r] } |
| + |
| +// Advance is the glyph's advance width in font units. |
| +func (f *Font) Advance(gid uint16) uint16 { |
| + if int(gid) >= len(f.advances) { |
| + return 0 |
| + } |
| + return f.advances[gid] |
| +} |
| + |
| +// WidthEm is the advance of a run of text as a multiple of the em, so |
| +// a caller multiplies by a point size and gets points. Returns the |
| +// glyph ids it used, so the caller can accumulate a subset without |
| +// walking the string twice. |
| +func (f *Font) WidthEm(s string) (width float64, gids []uint16) { |
| + upm := float64(f.UnitsPerEm) |
| + for _, r := range s { |
| + gid := f.GlyphIndex(r) |
| + gids = append(gids, gid) |
| + width += float64(f.Advance(gid)) / upm |
| + } |
| + return width, gids |
| +} |
| + |
| +// locaOffsets reads loca into a plain slice of byte offsets into glyf. |
| +func (f *Font) locaOffsets() ([]uint32, error) { |
| + loca, ok := f.tables["loca"] |
| + if !ok { |
| + return nil, fmt.Errorf("%w: loca", errNoTable) |
| + } |
| + n := int(f.NumGlyphs) + 1 |
| + out := make([]uint32, n) |
| + if f.indexToLocFormat == 0 { |
| + if len(loca) < n*2 { |
| + return nil, errShortTable |
| + } |
| + for i := 0; i < n; i++ { |
| + // The short format stores offset/2. |
| + out[i] = uint32(binary.BigEndian.Uint16(loca[i*2:])) * 2 |
| + } |
| + return out, nil |
| + } |
| + if len(loca) < n*4 { |
| + return nil, errShortTable |
| + } |
| + for i := 0; i < n; i++ { |
| + out[i] = binary.BigEndian.Uint32(loca[i*4:]) |
| + } |
| + return out, nil |
| +} |
| + |
| +// componentsOf returns the glyph ids a composite glyph is built from. |
| +// A subset that keeps "é" but drops the "e" and the acute it is |
| +// assembled from renders as nothing at all, which is the failure this |
| +// exists to prevent. |
| +func componentsOf(glyph []byte) []uint16 { |
| + if len(glyph) < 10 { |
| + return nil |
| + } |
| + if int16(binary.BigEndian.Uint16(glyph)) >= 0 { |
| + return nil // simple glyph, no components |
| + } |
| + var out []uint16 |
| + at := 10 |
| + for { |
| + flags, ok1 := be16(glyph, at) |
| + gid, ok2 := be16(glyph, at+2) |
| + if !ok1 || !ok2 { |
| + return out |
| + } |
| + out = append(out, gid) |
| + at += 4 |
| + if flags&0x0001 != 0 { // ARG_1_AND_2_ARE_WORDS |
| + at += 4 |
| + } else { |
| + at += 2 |
| + } |
| + switch { |
| + case flags&0x0008 != 0: // WE_HAVE_A_SCALE |
| + at += 2 |
| + case flags&0x0040 != 0: // WE_HAVE_AN_X_AND_Y_SCALE |
| + at += 4 |
| + case flags&0x0080 != 0: // WE_HAVE_A_TWO_BY_TWO |
| + at += 8 |
| + } |
| + if flags&0x0020 == 0 { // MORE_COMPONENTS |
| + return out |
| + } |
| + } |
| +} |
| + |
| +// Subset returns a TrueType file containing only the glyphs in keep |
| +// (plus .notdef and, transitively, the components of any composite |
| +// glyph kept). Glyph ids are unchanged — see this file's header for |
| +// why that is the whole design. |
| +func (f *Font) Subset(keep map[uint16]bool) ([]byte, error) { |
| + loca, err := f.locaOffsets() |
| + if err != nil { |
| + return nil, err |
| + } |
| + glyf, ok := f.tables["glyf"] |
| + if !ok { |
| + return nil, fmt.Errorf("%w: glyf", errNoTable) |
| + } |
| + |
| + glyphAt := func(gid uint16) []byte { |
| + if int(gid)+1 >= len(loca) { |
| + return nil |
| + } |
| + start, end := loca[gid], loca[gid+1] |
| + if start >= end || int(end) > len(glyf) { |
| + return nil // an empty glyph, e.g. the space |
| + } |
| + return glyf[start:end] |
| + } |
| + |
| + // Close over composites. A worklist rather than recursion: the |
| + // component graph in a hostile font can be cyclic, and `seen` |
| + // terminates either way. |
| + seen := map[uint16]bool{0: true} // .notdef always ships |
| + work := []uint16{0} |
| + for gid := range keep { |
| + if !seen[gid] { |
| + seen[gid] = true |
| + work = append(work, gid) |
| + } |
| + } |
| + for len(work) > 0 { |
| + gid := work[len(work)-1] |
| + work = work[:len(work)-1] |
| + for _, c := range componentsOf(glyphAt(gid)) { |
| + if !seen[c] && int(c) < int(f.NumGlyphs) { |
| + seen[c] = true |
| + work = append(work, c) |
| + } |
| + } |
| + } |
| + |
| + // Rebuild glyf and loca. Every glyph keeps its id; the ones not in |
| + // `seen` get a zero-length entry. |
| + var newGlyf []byte |
| + newLoca := make([]uint32, int(f.NumGlyphs)+1) |
| + for gid := 0; gid < int(f.NumGlyphs); gid++ { |
| + newLoca[gid] = uint32(len(newGlyf)) |
| + if seen[uint16(gid)] { |
| + g := glyphAt(uint16(gid)) |
| + newGlyf = append(newGlyf, g...) |
| + for len(newGlyf)%4 != 0 { // glyphs are 4-byte aligned |
| + newGlyf = append(newGlyf, 0) |
| + } |
| + } |
| + } |
| + newLoca[f.NumGlyphs] = uint32(len(newGlyf)) |
| + |
| + // Long loca throughout: the short format cannot express an offset |
| + // above 131,070 and cannot express an odd one at all, and choosing |
| + // per-font would mean two code paths where one is barely exercised. |
| + locaBytes := make([]byte, len(newLoca)*4) |
| + for i, off := range newLoca { |
| + binary.BigEndian.PutUint32(locaBytes[i*4:], off) |
| + } |
| + |
| + head := append([]byte(nil), f.tables["head"]...) |
| + binary.BigEndian.PutUint16(head[50:], 1) // indexToLocFormat: long |
| + binary.BigEndian.PutUint32(head[8:], 0) // checkSumAdjustment, set below |
| + |
| + out := map[string][]byte{ |
| + "head": head, |
| + "hhea": f.tables["hhea"], |
| + "maxp": f.tables["maxp"], |
| + "hmtx": f.tables["hmtx"], |
| + "loca": locaBytes, |
| + "glyf": newGlyf, |
| + } |
| + for tag, b := range out { |
| + if b == nil { |
| + return nil, fmt.Errorf("%w: %s", errNoTable, tag) |
| + } |
| + } |
| + return buildSfnt(out), nil |
| +} |
| + |
| +// buildSfnt assembles a TrueType file from a tag→bytes map. |
| +func buildSfnt(tables map[string][]byte) []byte { |
| + tags := make([]string, 0, len(tables)) |
| + for t := range tables { |
| + tags = append(tags, t) |
| + } |
| + sort.Strings(tags) // the table directory must be in tag order |
| + |
| + n := len(tags) |
| + // searchRange/entrySelector/rangeShift are a binary-search hint a |
| + // modern reader ignores but a strict validator checks. |
| + entrySelector := 0 |
| + for 1<<(entrySelector+1) <= n { |
| + entrySelector++ |
| + } |
| + searchRange := 16 * (1 << entrySelector) |
| + rangeShift := 16*n - searchRange |
| + |
| + header := make([]byte, 12+16*n) |
| + binary.BigEndian.PutUint32(header, 0x00010000) |
| + binary.BigEndian.PutUint16(header[4:], uint16(n)) |
| + binary.BigEndian.PutUint16(header[6:], uint16(searchRange)) |
| + binary.BigEndian.PutUint16(header[8:], uint16(entrySelector)) |
| + binary.BigEndian.PutUint16(header[10:], uint16(rangeShift)) |
| + |
| + body := []byte{} |
| + offset := len(header) |
| + for i, tag := range tags { |
| + b := tables[tag] |
| + rec := 12 + i*16 |
| + copy(header[rec:], tag) |
| + binary.BigEndian.PutUint32(header[rec+4:], tableChecksum(b)) |
| + binary.BigEndian.PutUint32(header[rec+8:], uint32(offset+len(body))) |
| + binary.BigEndian.PutUint32(header[rec+12:], uint32(len(b))) |
| + body = append(body, b...) |
| + for len(body)%4 != 0 { |
| + body = append(body, 0) |
| + } |
| + } |
| + |
| + file := append(header, body...) |
| + |
| + // head.checkSumAdjustment: 0xB1B0AFBA minus the checksum of the |
| + // whole file computed with this field zeroed, which it is. |
| + for i, tag := range tags { |
| + if tag != "head" { |
| + continue |
| + } |
| + headOff := int(binary.BigEndian.Uint32(file[12+i*16+8:])) |
| + if headOff+12 <= len(file) { |
| + adj := 0xB1B0AFBA - tableChecksum(file) |
| + binary.BigEndian.PutUint32(file[headOff+8:], adj) |
| + } |
| + } |
| + return file |
| +} |
| + |
| +func tableChecksum(b []byte) uint32 { |
| + var sum uint32 |
| + for i := 0; i+4 <= len(b); i += 4 { |
| + sum += binary.BigEndian.Uint32(b[i:]) |
| + } |
| + // A table's length is padded to four bytes for checksum purposes |
| + // even when the bytes themselves are not present. |
| + if rem := len(b) % 4; rem != 0 { |
| + var tail [4]byte |
| + copy(tail[:], b[len(b)-rem:]) |
| + sum += binary.BigEndian.Uint32(tail[:]) |
| + } |
| + return sum |
| +} |
| + |
| +// --- parsed-face cache --- |
| + |
| +var ( |
| + parsedMu sync.Mutex |
| + parsed = map[string]*Font{} |
| +) |
| + |
| +// Parsed returns this face's parsed font, cached per process. Parsing |
| +// expands the cmap into a map of a few thousand entries, which is |
| +// cheap once and pointless twelve times a request. |
| +func (f Face) Parsed() (*Font, error) { |
| + parsedMu.Lock() |
| + defer parsedMu.Unlock() |
| + if font, ok := parsed[f.File]; ok { |
| + return font, nil |
| + } |
| + font, err := Parse(f.Data) |
| + if err != nil { |
| + return nil, fmt.Errorf("fonts: parse %s: %w", f.File, err) |
| + } |
| + parsed[f.File] = font |
| + return font, nil |
| +} |