diff --git a/README.md b/README.md index 757b7ad..1534a8b 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,11 @@ Go-idiomatic rather than a gem port. constant-alpha transparency via ExtGState. - **Text** — embeds fonts as **Type0 (Identity-H)** composite fonts with glyph **subsetting**, a per-glyph `/W` width array and a `/ToUnicode` CMap for - copy/paste. TrueType `glyf` → **FontFile2 / CIDFontType2**; CFF/OpenType → - **FontFile3 / CIDFontType0**. Char/word spacing, leading, render modes, a - simple wrapping helper, and an optional **shaped-text** API (GSUB/GPOS via - go-opentype) for Arabic/Indic/CJK. + copy/paste. TrueType `glyf` → **FontFile2 / CIDFontType2** (compact subset with + a `/CIDToGIDMap` stream); CFF/OpenType → **FontFile3 / CIDFontType0** with + **CFF charstring subsetting** (only the used glyphs' charstrings are embedded). + Char/word spacing, leading, render modes, a simple wrapping helper, and an + optional **shaped-text** API (GSUB/GPOS via go-opentype) for Arabic/Indic/CJK. - **Images** — JPEG embedded directly (DCTDecode); PNG and any `image.Image` rasterised as XObjects (FlateDecode) with an `/SMask` for alpha. - **Pages** — standard sizes (A3/A4/A5/Letter/Legal/Tabloid), portrait/landscape, @@ -85,19 +86,24 @@ default `Text` path stays a simple left-to-right cmap mapping. `GOWORK=off CGO_ENABLED=0 go test ./...` runs the suite at **exact 100% statement coverage**. Correctness is checked against an independent parser: generated documents are re-opened with [`rsc.io/pdf`](https://pkg.go.dev/rsc.io/pdf) -and their structure verified, and the embedded TrueType subset is re-parsed with -go-opentype to confirm it still contains the glyphs that were drawn. Tests are -deterministic and network-free: they use a synthesised TrueType font and a -bundled OFL OpenType/CFF font. +and their structure verified. The embedded TrueType subset is re-parsed with +go-opentype and each drawn glyph, resolved through the `/CIDToGIDMap`, is +confirmed contour-identical to the original; the embedded CFF subset is asserted +smaller than the whole `CFF` table and re-parsed so each kept glyph still renders +intact. Tests are deterministic and network-free: they use a synthesised +TrueType font, a synthesised CFF2 font and a bundled OFL OpenType/CFF font. ## Scope and limitations -- CFF/OpenType fonts embed their **whole `CFF ` table** (charstring subsetting is - not yet implemented); TrueType fonts are fully subsetted. -- `go-opentype` exposes no raw table bytes, units-per-em, glyf/loca arrays or a - subsetting export, so `pdfkit` reparses the sfnt container it is handed and - implements TrueType subsetting itself. -- Encryption, tagged/PDF-A, forms and annotations are out of scope for v0.1. +- Both outline flavours are **subsetted**: TrueType `glyf` fonts via + `go-opentype`'s `SubsetTrueType` (compact renumbering + a `/CIDToGIDMap` + stream) and CFF/OpenType fonts via `SubsetCFF` (charstring subsetting, + glyph numbering preserved). All subsetting and the font-descriptor metrics come + straight from `go-opentype`; `pdfkit` keeps no private sfnt re-parse. +- A **CID-keyed CFF** or a **CFF2 (variable)** font cannot be charstring-subsetted + by the preserve-numbering path, so it gracefully falls back to embedding the + whole `CFF`/`CFF2` table. +- Encryption, tagged/PDF-A, forms and annotations are out of scope for v0.2. ## License diff --git a/coverage_test.go b/coverage_test.go index 4fd168a..f6eef25 100644 --- a/coverage_test.go +++ b/coverage_test.go @@ -5,93 +5,134 @@ package pdfkit import ( + "os" "testing" "github.com/go-opentype/opentype" ) -// validHead returns a minimal 54-byte head with unitsPerEm 1000 and short loca. -func validHead() []byte { - b := make([]byte, 54) - u16w(b, 18, 1000) // unitsPerEm - return b -} - -// validMaxp returns a 6-byte maxp declaring n glyphs. -func validMaxp(n int) []byte { - b := make([]byte, 6) - u16w(b, 4, uint16(n)) - return b -} +// TestDescriptorCapHeightFallback covers both cap-height branches of +// buildDescriptor: a font with an OS/2 cap height uses it, one without falls back +// to the ascender. +func TestDescriptorCapHeightFallback(t *testing.T) { + // With OS/2: cap height 700 (scaled by 1000/1000 = 700). + withOS2, err := LoadFont(synthTTF(defaultSynth())) + if err != nil { + t.Fatal(err) + } + doc := New(Options{}) + p := doc.AddPage(A4) + p.SetFont(withOS2, 12) + if err := p.Text(72, 700, "H"); err != nil { + t.Fatal(err) + } + r := reopen(t, doc) + if got := firstFontDict(r).Key("DescendantFonts").Index(0). + Key("FontDescriptor").Key("CapHeight").Int64(); got != 700 { + t.Errorf("CapHeight with OS/2 = %d, want 700", got) + } -// validHhea returns a 36-byte hhea declaring numberOfHMetrics. -func validHhea(m int) []byte { - b := make([]byte, 36) - u16w(b, 34, uint16(m)) - return b + // Without OS/2 or post: cap height is zero upstream, so pdfkit substitutes the + // ascender (800). + noOS2, err := LoadFont(synthTTF(synthOpts{})) + if err != nil { + t.Fatal(err) + } + doc2 := New(Options{}) + p2 := doc2.AddPage(A4) + p2.SetFont(noOS2, 12) + if err := p2.Text(72, 700, "H"); err != nil { + t.Fatal(err) + } + r2 := reopen(t, doc2) + if got := firstFontDict(r2).Key("DescendantFonts").Index(0). + Key("FontDescriptor").Key("CapHeight").Int64(); got != 800 { + t.Errorf("CapHeight without OS/2 = %d, want ascender 800", got) + } } -func u16w(b []byte, i int, v uint16) { - b[i] = byte(v >> 8) - b[i+1] = byte(v) -} +// TestTrueTypeSubsetFallback drives the embedProgram fallback: an out-of-range +// glyph id makes SubsetTrueType error, so pdfkit embeds the whole font program +// with an Identity /CIDToGIDMap instead of a compact subset. +func TestTrueTypeSubsetFallback(t *testing.T) { + f, err := LoadFont(synthTTF(defaultSynth())) + if err != nil { + t.Fatal(err) + } + doc := New(Options{}) + p := doc.AddPage(A4) + p.SetFont(f, 12) + if err := p.Text(72, 700, "H"); err != nil { + t.Fatal(err) + } + // Force an out-of-range glyph into the used set so SubsetTrueType fails. + doc.use[f].mark(opentype.GlyphIndex(f.NumGlyphs()+50), nil) -// TestParseSFNTSubParserErrors drives each sub-parser error return inside -// parseSFNT by assembling a container with exactly one broken table. -func TestParseSFNTSubParserErrors(t *testing.T) { - cases := map[string]map[string][]byte{ - "bad head": {"head": make([]byte, 20)}, // >=12 so assembly can patch, <54 so parseHead fails - "bad maxp": {"head": validHead(), "maxp": make([]byte, 2)}, - "bad hhea": {"head": validHead(), "maxp": validMaxp(2), "hhea": make([]byte, 4)}, - "bad hmtx": {"head": validHead(), "maxp": validMaxp(2), "hhea": validHhea(2), "hmtx": make([]byte, 2)}, - "no loca": { - "head": validHead(), "maxp": validMaxp(2), "hhea": validHhea(2), - "hmtx": make([]byte, 8), - }, - } - for name, tables := range cases { - if _, err := parseSFNT(assembleSFNT(0x00010000, tables)); err == nil { - t.Errorf("%s: expected error", name) - } + r := reopen(t, doc) + df := firstFontDict(r).Key("DescendantFonts").Index(0) + // The fallback embeds the whole font, so the map is the Identity name again. + if got := df.Key("CIDToGIDMap").Name(); got != "Identity" { + t.Errorf("fallback CIDToGIDMap = %q, want Identity", got) + } + prog := readStream(t, df.Key("FontDescriptor").Key("FontFile2")) + if len(prog) != len(f.data) { + t.Errorf("fallback FontFile2 len = %d, want whole font %d", len(prog), len(f.data)) } } -// TestParseSFNTCFF2 covers the CFF2 outline-flavour branch: a container with a -// CFF2 table is treated as CFF (isCFF true) and needs no loca/glyf. -func TestParseSFNTCFF2(t *testing.T) { - tables := map[string][]byte{ - "head": validHead(), - "maxp": validMaxp(2), - "hhea": validHhea(2), - "hmtx": make([]byte, 8), - "CFF2": {1, 2, 3, 4}, - } - sf, err := parseSFNT(assembleSFNT(0x00010000, tables)) +// TestCFFSubsetFallbackWholeTable drives embedCFF's fallback via an out-of-range +// glyph id (SubsetCFF rejects it), which embeds the whole 'CFF ' table. +func TestCFFSubsetFallbackWholeTable(t *testing.T) { + otf, err := os.ReadFile("testdata/SourceSerif4-Regular.otf") if err != nil { t.Fatal(err) } - if !sf.isCFF { - t.Error("CFF2 font should be marked CFF") + f, err := LoadFont(otf) + if err != nil { + t.Fatal(err) + } + doc := New(Options{}) + p := doc.AddPage(A4) + p.SetFont(f, 12) + if err := p.Text(72, 700, "H"); err != nil { + t.Fatal(err) + } + doc.use[f].mark(opentype.GlyphIndex(f.NumGlyphs()+50), nil) + + r := reopen(t, doc) + ff := firstFontDict(r).Key("DescendantFonts").Index(0). + Key("FontDescriptor").Key("FontFile3") + whole, _ := f.ot.Table("CFF ") + if got := readStream(t, ff); len(got) != len(whole) { + t.Errorf("fallback FontFile3 len = %d, want whole CFF %d", len(got), len(whole)) } } -// TestSubsetOddGlyphPadding covers the 2-byte alignment padding of an -// odd-length glyph in subsetTrueType. -func TestSubsetOddGlyphPadding(t *testing.T) { - sf := &sfntFont{ - numGlyphs: 2, - unitsPerEm: 1000, - loca: []uint32{0, 3, 3}, // glyph 1 is three bytes long (odd) - tables: map[string][]byte{ - "head": validHead(), - "hhea": validHhea(2), - "maxp": validMaxp(2), - "hmtx": make([]byte, 8), - "glyf": {0, 0, 0}, // three bytes - }, - } - out := subsetTrueType(sf, []opentype.GlyphIndex{1}) - if len(out) == 0 { - t.Fatal("empty subset") +// TestCFF2WholeTableFallback loads a synthetic CFF2 (variable) font, which the +// preserve-numbering CFF subsetter cannot handle, and checks it is recognised as +// CFF and embedded whole via the 'CFF2' branch of wholeCFF. +func TestCFF2WholeTableFallback(t *testing.T) { + f, err := LoadFont(synthCFF2()) + if err != nil { + t.Fatalf("parse synthetic CFF2: %v", err) + } + if !f.IsCFF() { + t.Fatal("CFF2 font should report IsCFF") + } + doc := New(Options{}) + p := doc.AddPage(A4) + p.SetFont(f, 12) + if err := p.Text(72, 700, "A"); err != nil { + t.Fatal(err) + } + r := reopen(t, doc) + df := firstFontDict(r).Key("DescendantFonts").Index(0) + if got := df.Key("Subtype").Name(); got != "CIDFontType0" { + t.Errorf("CFF2 descendant Subtype = %q", got) + } + ff := df.Key("FontDescriptor").Key("FontFile3") + whole, _ := f.ot.Table("CFF2") + if got := readStream(t, ff); len(got) != len(whole) { + t.Errorf("CFF2 FontFile3 len = %d, want whole CFF2 %d", len(got), len(whole)) } } diff --git a/doc.go b/doc.go index 3423db4..3ab0228 100644 --- a/doc.go +++ b/doc.go @@ -31,11 +31,13 @@ // LoadFont parses a font blob once; a Font is immutable and may be shared. // SetFont selects it for a page, then Text draws a left-to-right run. TextShaped // runs the go-opentype shaper (GSUB/GPOS) for complex scripts. Every embedded -// font is written as a subset with Identity-H encoding, an Identity -// CIDToGIDMap, a per-glyph /W width array and a /ToUnicode CMap so copy and -// paste recover the original text. TrueType outlines embed as a subsetted -// FontFile2 / CIDFontType2; CFF/OpenType outlines embed as a FontFile3 / -// CIDFontType0. +// font is written as a subset with Identity-H encoding, a per-glyph /W width +// array and a /ToUnicode CMap so copy and paste recover the original text. +// TrueType outlines embed as a subsetted FontFile2 / CIDFontType2 with a +// /CIDToGIDMap stream (the subset renumbers glyphs, so the map sends each CID — +// the original glyph id — to its subset id); CFF/OpenType outlines embed as a +// charstring-subsetted FontFile3 / CIDFontType0 whose glyph numbering is +// preserved, so an Identity /CIDToGIDMap suffices. // // # Determinism // @@ -43,12 +45,14 @@ // /ID, so identical inputs produce byte-identical documents. Set Options.Now to // stamp creation and modification dates. // -// # Missing upstream primitives +// # Font embedding // -// go-opentype/opentype decodes a font fully but does not expose the raw table -// bytes, the units-per-em, the glyf/loca arrays or a subsetting export that a -// PDF embedder needs, so pdfkit reparses the sfnt container it is handed (see -// sfnt.go) and implements TrueType glyf subsetting itself (see subset.go). CFF -// charstring subsetting is not yet implemented: a CFF font embeds its whole -// 'CFF ' table. +// go-opentype/opentype supplies every primitive PDF embedding needs: the +// descriptor scalars (units-per-em, bounding box, ascent/descent, cap height, +// italic angle, flags, StemV), the by-glyph advances for the /W array, and the +// glyph subsetters. TrueType 'glyf' fonts are subsetted with +// Font.SubsetTrueType and CFF fonts with Font.SubsetCFF, so pdfkit keeps no +// private sfnt re-parse or subsetter of its own. A CID-keyed CFF or a CFF2 +// (variable) font, which the preserve-numbering CFF subsetter does not handle, +// gracefully falls back to embedding its whole 'CFF '/'CFF2' table. package pdfkit diff --git a/embed.go b/embed.go index 43086ce..8095c5a 100644 --- a/embed.go +++ b/embed.go @@ -6,24 +6,30 @@ package pdfkit import ( "crypto/sha1" + "encoding/binary" "github.com/go-opentype/opentype" ) // buildFont emits the composite (Type0) font and its descendant CIDFont, // descriptor, embedded font program and /ToUnicode CMap for f, returning the -// Type0 dictionary's object reference. The font is always embedded as a -// subset with Identity-H encoding and an Identity CIDToGIDMap, so a CID equals -// a glyph id. +// Type0 dictionary's object reference. The font is always embedded as a subset +// with Identity-H encoding. +// +// The two outline flavours are subsetted by go-opentype and differ only in how a +// content-stream glyph id (which is always the original glyph id) reaches the +// embedded program: a TrueType subset renumbers its glyphs compactly, so a +// /CIDToGIDMap stream maps original id -> subset id; a CFF subset preserves glyph +// numbering, so an Identity /CIDToGIDMap suffices. func (d *Document) buildFont(bd *builder, f *Font) objRef { use := d.use[f] gids := use.sortedGIDs() tag := subsetTag(f, gids) baseName := tag + "+" + f.baseName - fontFileRef, ffKey := d.embedProgram(bd, f, gids) + fontFileRef, ffKey, remap := d.embedProgram(bd, f, gids) descRef := d.buildDescriptor(bd, f, baseName, fontFileRef, ffKey) - cidRef := d.buildCIDFont(bd, f, baseName, descRef, gids) + cidRef := d.buildCIDFont(bd, f, baseName, descRef, gids, remap) toUniRef := bd.add(d.maybeFlateStream(buildToUnicode(use))) dict := newDict() @@ -37,49 +43,96 @@ func (d *Document) buildFont(bd *builder, f *Font) objRef { } // embedProgram writes the embedded font program stream and returns its -// reference plus the descriptor key (/FontFile2 for TrueType, /FontFile3 for -// CFF) that must point at it. -func (d *Document) embedProgram(bd *builder, f *Font, gids []opentype.GlyphIndex) (objRef, string) { - if f.sf.isCFF { - // CFF/OpenType: embed the CFF table as a CIDFontType0C program. pdfkit - // does not yet subset CFF charstrings, so the whole table is embedded. - dict := newDict() - dict.set("Subtype", pdfName("CIDFontType0C")) - s := d.maybeFlate(dict, f.sf.tables["CFF "]) - return bd.add(&pdfStream{dict: dict, data: s}), "FontFile3" +// reference, the descriptor key (/FontFile2 for TrueType, /FontFile3 for CFF) +// that must point at it, and — for a renumbering TrueType subset — the original +// glyph id -> subset glyph id remap the /CIDToGIDMap is built from. The remap is +// nil for CFF (its glyph numbering is preserved) and for a TrueType subset that +// somehow failed (which never happens for a valid 'glyf' font). +func (d *Document) embedProgram(bd *builder, f *Font, gids []opentype.GlyphIndex) (objRef, string, map[opentype.GlyphIndex]opentype.GlyphIndex) { + if f.isCFF { + return d.embedCFF(bd, f, gids), "FontFile3", nil + } + // TrueType: embed a compact subset and carry its glyph renumbering so the + // descendant font can map CID (= original glyph id) to the subset glyph id. + sub, remap, err := f.ot.SubsetTrueType(gids) + if err != nil { + // SubsetTrueType errors only for a non-'glyf' font or an out-of-range + // glyph id; a Font that reports !isCFF always has glyf, so this reduces + // to an out-of-range glyph. Fall back to the whole font program with an + // Identity map (original glyph numbering) — correct, just not minimal, + // and symmetric with the CFF whole-table fallback. + sub, remap = f.data, nil } - sub := subsetTrueType(f.sf, gids) dict := newDict() dict.set("Length1", pdfInt(len(sub))) s := d.maybeFlate(dict, sub) - return bd.add(&pdfStream{dict: dict, data: s}), "FontFile2" + return bd.add(&pdfStream{dict: dict, data: s}), "FontFile2", remap +} + +// embedCFF writes the CFF program stream as a CIDFontType0C /FontFile3 and +// returns its reference. It subsets the 'CFF ' charstrings via go-opentype, +// keeping only the used glyphs while preserving glyph numbering (so an Identity +// /CIDToGIDMap stays valid). A CID-keyed CFF or a CFF2 (variable) font cannot be +// charstring-subsetted by this preserve-numbering path, so it falls back to +// embedding the whole table unchanged — correct, just not minimal. +func (d *Document) embedCFF(bd *builder, f *Font, gids []opentype.GlyphIndex) objRef { + prog, err := f.ot.SubsetCFF(gids) + if err != nil { + prog = wholeCFF(f) + } + dict := newDict() + dict.set("Subtype", pdfName("CIDFontType0C")) + s := d.maybeFlate(dict, prog) + return bd.add(&pdfStream{dict: dict, data: s}) +} + +// wholeCFF returns the font's whole, unsubsetted CFF program: the 'CFF ' table +// when present, otherwise the 'CFF2' table. It is the graceful fallback for a +// CID-keyed CFF or a CFF2 font, which the preserve-numbering subsetter rejects. +func wholeCFF(f *Font) []byte { + if b, ok := f.ot.Table("CFF "); ok { + return b + } + b, _ := f.ot.Table("CFF2") + return b } // buildDescriptor writes the FontDescriptor, converting design metrics from -// font units to PDF glyph space (1000 units per em). +// font units to PDF glyph space (1000 units per em). Every scalar comes straight +// from go-opentype's descriptor accessors; a zero cap height (absent OS/2) falls +// back to the ascender, as a PDF consumer must. func (d *Document) buildDescriptor(bd *builder, f *Font, baseName string, fontFile objRef, ffKey string) objRef { - sc := 1000.0 / float64(f.sf.unitsPerEm) + upm := f.ot.UnitsPerEm() + sc := 1000.0 / float64(upm) scale := func(v int) pdfValue { return pdfInt(int(float64(v)*sc + 0.5)) } + xMin, yMin, xMax, yMax := f.ot.FontBBox() + capHeight := f.ot.CapHeight() + if capHeight == 0 { + capHeight = f.ot.Ascent() + } + desc := newDict() desc.set("Type", pdfName("FontDescriptor")) desc.set("FontName", pdfName(baseName)) - desc.set("Flags", pdfInt(f.sf.flags)) - desc.set("FontBBox", pdfArray{scale(f.sf.xMin), scale(f.sf.yMin), scale(f.sf.xMax), scale(f.sf.yMax)}) - desc.set("ItalicAngle", pdfReal(f.sf.italicAngle)) - desc.set("Ascent", scale(f.sf.ascender)) - desc.set("Descent", scale(f.sf.descender)) - desc.set("CapHeight", scale(f.sf.capHeight)) - desc.set("StemV", pdfInt(80)) + desc.set("Flags", pdfInt(f.ot.Flags())) + desc.set("FontBBox", pdfArray{scale(xMin), scale(yMin), scale(xMax), scale(yMax)}) + desc.set("ItalicAngle", pdfReal(f.ot.ItalicAngle())) + desc.set("Ascent", scale(f.ot.Ascent())) + desc.set("Descent", scale(f.ot.Descent())) + desc.set("CapHeight", scale(capHeight)) + desc.set("StemV", scale(f.ot.StemV())) desc.set(ffKey, fontFile) return bd.add(desc) } // buildCIDFont writes the descendant CIDFont, including the per-glyph width -// array and the default width. -func (d *Document) buildCIDFont(bd *builder, f *Font, baseName string, desc objRef, gids []opentype.GlyphIndex) objRef { +// array, the default width and the /CIDToGIDMap. When remap is nil the map is the +// Identity name (CFF, or a preserved-numbering program); otherwise it is a stream +// mapping each CID (original glyph id) to its subset glyph id. +func (d *Document) buildCIDFont(bd *builder, f *Font, baseName string, desc objRef, gids []opentype.GlyphIndex, remap map[opentype.GlyphIndex]opentype.GlyphIndex) objRef { subtype := "CIDFontType2" - if f.sf.isCFF { + if f.isCFF { subtype = "CIDFontType0" } sysInfo := newDict() @@ -93,13 +146,42 @@ func (d *Document) buildCIDFont(bd *builder, f *Font, baseName string, desc objR cid.set("BaseFont", pdfName(baseName)) cid.set("CIDSystemInfo", sysInfo) cid.set("FontDescriptor", desc) - cid.set("CIDToGIDMap", pdfName("Identity")) + if remap != nil { + cid.set("CIDToGIDMap", d.cidToGIDMap(bd, remap)) + } else { + cid.set("CIDToGIDMap", pdfName("Identity")) + } cid.set("DW", pdfInt(1000)) cid.set("W", d.widthArray(f, gids)) return bd.add(cid) } -// widthArray builds the /W array, one "cid [width]" pair per used glyph. +// cidToGIDMap builds the /CIDToGIDMap stream for a renumbering TrueType subset. A +// content stream addresses glyphs by CID = original glyph id (Identity-H), so the +// map is indexed by original id and yields the subset id, two big-endian bytes +// per CID. It is sized to cover every glyph the subset kept (the remap's largest +// original id), which includes every CID a content stream can reference; any gap +// left in the array maps to glyph 0 (.notdef). It returns the stream reference. +func (d *Document) cidToGIDMap(bd *builder, remap map[opentype.GlyphIndex]opentype.GlyphIndex) objRef { + maxOld := 0 + for old := range remap { + if int(old) > maxOld { + maxOld = int(old) + } + } + raw := make([]byte, (maxOld+1)*2) + for old, nw := range remap { + binary.BigEndian.PutUint16(raw[int(old)*2:], uint16(nw)) + } + dict := newDict() + data := d.maybeFlate(dict, raw) + return bd.add(&pdfStream{dict: dict, data: data}) +} + +// widthArray builds the /W array, one "cid [width]" pair per used glyph. The CID +// is the original glyph id (the /CIDToGIDMap, or a preserved numbering, resolves +// it to the embedded program's glyph), and the width is taken by that same +// original id from the go-opentype face. func (d *Document) widthArray(f *Font, gids []opentype.GlyphIndex) pdfArray { var w pdfArray for _, g := range gids { diff --git a/font.go b/font.go index 41db633..01fb480 100644 --- a/font.go +++ b/font.go @@ -5,6 +5,7 @@ package pdfkit import ( + "encoding/binary" "fmt" "github.com/go-opentype/opentype" @@ -15,46 +16,66 @@ import ( // glyph usage is tracked separately by the Document. Build one with LoadFont. type Font struct { ot *opentype.Font - sf *sfntFont + face *opentype.Face + data []byte // the whole sfnt, retained for the whole-font embed fallback baseName string + isCFF bool } // LoadFont parses a TrueType ('glyf') or OpenType/CFF ('OTTO'/CFF) font from // its raw bytes. The bytes are retained and must not be mutated afterwards. +// Parsing, glyph indexing, metrics, shaping and subsetting are all delegated to +// github.com/go-opentype/opentype; pdfkit keeps no private sfnt re-parse. func LoadFont(data []byte) (*Font, error) { - sf, err := parseSFNT(data) - if err != nil { - return nil, err - } ot, err := opentype.Parse(data) if err != nil { return nil, fmt.Errorf("pdfkit: parse font: %w", err) } - name := readPSName(sf) + // The face is sized to the em, so AdvanceIndexUnits returns advances in font + // units — exactly what a PDF /W width array wants — at the base (uninstanced) + // design. + face := ot.NewFace(ot.UnitsPerEm()) + + name := "" + if nameTable, ok := ot.Table("name"); ok { + name = readPSName(nameTable) + } if name == "" { name = "PDFKitFont" } - return &Font{ot: ot, sf: sf, baseName: name}, nil + return &Font{ot: ot, face: face, data: data, baseName: name, isCFF: fontIsCFF(ot)}, nil +} + +// fontIsCFF reports whether the font carries CFF or CFF2 (PostScript) outlines, +// as opposed to TrueType 'glyf' outlines. It is read from the table directory +// the font was parsed from. +func fontIsCFF(ot *opentype.Font) bool { + if _, ok := ot.Table("CFF "); ok { + return true + } + _, ok := ot.Table("CFF2") + return ok } // IsCFF reports whether the font carries CFF/OpenType outlines (embedded as a // CIDFontType0), as opposed to TrueType 'glyf' outlines (CIDFontType2). -func (f *Font) IsCFF() bool { return f.sf.isCFF } +func (f *Font) IsCFF() bool { return f.isCFF } // UnitsPerEm returns the font's design grid size. -func (f *Font) UnitsPerEm() int { return f.sf.unitsPerEm } +func (f *Font) UnitsPerEm() int { return f.ot.UnitsPerEm() } // NumGlyphs returns the number of glyphs in the font. -func (f *Font) NumGlyphs() int { return f.sf.numGlyphs } +func (f *Font) NumGlyphs() int { return f.ot.NumGlyphs() } // BaseName returns the font's PostScript name, used for the PDF /BaseFont. func (f *Font) BaseName() string { return f.baseName } // glyphWidth1000 returns glyph gid's advance width in PDF glyph space (1000 -// units per em). +// units per em). The advance comes from the go-opentype face by glyph index, so +// it tracks the instanced design a PDF /W array must report. func (f *Font) glyphWidth1000(gid opentype.GlyphIndex) int { - adv := f.ot.GlyphAdvance(gid) - return int(float64(adv)*1000/float64(f.sf.unitsPerEm) + 0.5) + adv := f.face.AdvanceIndexUnits(gid) + return int(adv*1000/float64(f.ot.UnitsPerEm()) + 0.5) } // fontUse records, for one document, which glyphs of a font are drawn and the @@ -99,12 +120,14 @@ func (u *fontUse) sortedGIDs() []opentype.GlyphIndex { return out } -// readPSName extracts nameID 6 (the PostScript name) from the name table, +// u16 reads a big-endian uint16 at b[i:]. +func u16(b []byte, i int) int { return int(binary.BigEndian.Uint16(b[i:])) } + +// readPSName extracts nameID 6 (the PostScript name) from a raw 'name' table, // returning "" when it is absent or unreadable. Windows records are UTF-16BE; // Macintosh records are single-byte; PostScript names are ASCII either way. -func readPSName(sf *sfntFont) string { - b, ok := sf.tables["name"] - if !ok || len(b) < 6 { +func readPSName(b []byte) string { + if len(b) < 6 { return "" } count := u16(b, 2) diff --git a/font_test.go b/font_test.go index 23c1431..a4fefa3 100644 --- a/font_test.go +++ b/font_test.go @@ -8,10 +8,10 @@ import "testing" func TestLoadFontErrors(t *testing.T) { if _, err := LoadFont([]byte("not a font")); err == nil { - t.Error("expected sfnt parse error") + t.Error("expected opentype parse error") } - // A valid sfnt container missing cmap: parseSFNT accepts it but opentype - // (which requires cmap) rejects it, covering the opentype-error branch. + // A valid sfnt container missing cmap: opentype (which requires a cmap) + // rejects it, covering LoadFont's error branch. if _, err := LoadFont(synthTTF(synthOpts{noCmap: true})); err == nil { t.Error("expected opentype parse error for missing cmap") } @@ -72,20 +72,19 @@ func TestFontUse(t *testing.T) { } func TestReadPSNameEdgeCases(t *testing.T) { - // No name table -> empty. - if s := readPSName(&sfntFont{tables: map[string][]byte{}}); s != "" { + // Empty / too-short table -> empty. + if s := readPSName(nil); s != "" { t.Errorf("no-name = %q", s) } - // Header too short. - if s := readPSName(&sfntFont{tables: map[string][]byte{"name": {0, 0}}}); s != "" { + if s := readPSName([]byte{0, 0}); s != "" { t.Errorf("short header = %q", s) } // Build a name table with: a nameID!=6 record, an out-of-range record, and // a platform-0 (Unicode) nameID-6 record. w := &bw{} - w.u16(0) // format - w.u16(3) // count (claims 3 records) + w.u16(0) // format + w.u16(3) // count (claims 3 records) w.u16(uint16(6 + 3*12)) // storage offset // record 0: nameID 1 (skipped) w.u16(3); w.u16(1); w.u16(0); w.u16(1); w.u16(4); w.u16(0) @@ -95,7 +94,7 @@ func TestReadPSNameEdgeCases(t *testing.T) { w.u16(1); w.u16(0); w.u16(0); w.u16(6); w.u16(0xffff); w.u16(0xffff) storage := []byte{0, 'U', 0, 'n', 0, 'i'} w.b = append(w.b, storage...) - if s := readPSName(&sfntFont{tables: map[string][]byte{"name": w.b}}); s != "Uni" { + if s := readPSName(w.b); s != "Uni" { t.Errorf("platform-0 name = %q, want Uni", s) } @@ -104,7 +103,7 @@ func TestReadPSNameEdgeCases(t *testing.T) { short.u16(0) short.u16(10) // 10 records but no record bytes follow short.u16(6) - if s := readPSName(&sfntFont{tables: map[string][]byte{"name": short.b}}); s != "" { + if s := readPSName(short.b); s != "" { t.Errorf("truncated records = %q", s) } } diff --git a/go.mod b/go.mod index b624870..882570b 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,6 @@ module github.com/go-pdfkit/pdfkit go 1.26.4 -require github.com/go-opentype/opentype v0.4.0 +require github.com/go-opentype/opentype v0.5.0 require rsc.io/pdf v0.1.1 diff --git a/go.sum b/go.sum index 7c32a3d..73a61a1 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,4 @@ -github.com/go-opentype/opentype v0.4.0 h1:os4hpek0Hwg32DjEaE6BgcWYihieUXvFk+cMcQCxpFU= -github.com/go-opentype/opentype v0.4.0/go.mod h1:AOixevJf7XQaH7+WG+OMIOZEbYPXfMqklVk26Y6YTUU= +github.com/go-opentype/opentype v0.5.0 h1:++VoqgbXgYACyTTNzUAivoxW9M3m2gxWMAn6bIY7DXg= +github.com/go-opentype/opentype v0.5.0/go.mod h1:AOixevJf7XQaH7+WG+OMIOZEbYPXfMqklVk26Y6YTUU= rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/oracle_test.go b/oracle_test.go index bc96dc1..58c991c 100644 --- a/oracle_test.go +++ b/oracle_test.go @@ -75,20 +75,39 @@ func TestOracleTrueType(t *testing.T) { if got := df.Key("Subtype").Name(); got != "CIDFontType2" { t.Errorf("descendant Subtype = %q", got) } - if got := df.Key("CIDToGIDMap").Name(); got != "Identity" { - t.Errorf("CIDToGIDMap = %q", got) + + // The subset renumbers glyphs, so /CIDToGIDMap is a stream mapping each CID + // (the original glyph id) to its subset glyph id. Read it back as the oracle's + // own view of the mapping. + c2g := readStream(t, df.Key("CIDToGIDMap")) + cidToGID := func(cid int) opentype.GlyphIndex { + if 2*cid+1 >= len(c2g) { + return 0 + } + return opentype.GlyphIndex(int(c2g[2*cid])<<8 | int(c2g[2*cid+1])) } - // The embedded, subsetted TrueType program must itself be a parseable font - // carrying the glyphs we used (H=1, i=2, A=3, plus A's components). + // The embedded, subsetted TrueType program must itself be a parseable font in + // which each used glyph, at its remapped id, is contour-identical to the + // original — proving the subset kept the outlines intact. prog := readStream(t, df.Key("FontDescriptor").Key("FontFile2")) sub, err := opentype.Parse(prog) if err != nil { t.Fatalf("re-parse embedded subset: %v", err) } + orig := mustLoadOT(t, synthTTF(defaultSynth())) for _, r := range "HiA" { - if gid, ok := sub.GlyphIndex(r); !ok || gid == 0 { - t.Errorf("subset missing glyph for %q", r) + oldGID, ok := orig.GlyphIndex(r) + if !ok || oldGID == 0 { + t.Fatalf("original missing glyph for %q", r) + } + newGID := cidToGID(int(oldGID)) + if newGID == 0 { + t.Errorf("CIDToGIDMap maps %q (cid %d) to .notdef", r, oldGID) + continue + } + if !sameGlyphRender(orig, oldGID, sub, newGID) { + t.Errorf("subset glyph for %q (old %d -> new %d) not contour-intact", r, oldGID, newGID) } } @@ -126,20 +145,101 @@ func TestOracleCFF(t *testing.T) { if got := df.Key("Subtype").Name(); got != "CIDFontType0" { t.Errorf("descendant Subtype = %q", got) } + // A CFF program keeps its glyph numbering, so an Identity /CIDToGIDMap suffices. + if got := df.Key("CIDToGIDMap").Name(); got != "Identity" { + t.Errorf("CFF CIDToGIDMap = %q, want Identity", got) + } // FontFile3 carries the CFF program; its subtype must mark it CIDFontType0C. ff := df.Key("FontDescriptor").Key("FontFile3") if got := ff.Key("Subtype").Name(); got != "CIDFontType0C" { t.Errorf("FontFile3 Subtype = %q", got) } - if len(readStream(t, ff)) == 0 { - t.Error("empty embedded CFF program") + prog := readStream(t, ff) + if len(prog) == 0 { + t.Fatal("empty embedded CFF program") + } + + // pdfkit now truly subsets CFF charstrings: the embedded 'CFF ' program must be + // smaller than the whole table it was cut from. + whole, ok := f.ot.Table("CFF ") + if !ok { + t.Fatal("original font has no CFF table") + } + if len(prog) >= len(whole) { + t.Errorf("CFF subset not smaller: embedded %d bytes, whole table %d", len(prog), len(whole)) + } + + // The subset must re-parse (wrapped back into an OTF) and, because CFF + // subsetting preserves glyph numbering, each used glyph must render identically + // to the original at its original id — proving the kept glyphs are intact. + subF := mustLoadOT(t, wrapCFFinOTF(t, f.ot, prog)) + for _, ru := range "Hello" { + gid, okg := f.ot.GlyphIndex(ru) + if !okg || gid == 0 { + t.Fatalf("original missing glyph for %q", ru) + } + if !sameGlyphRender(f.ot, gid, subF, gid) { + t.Errorf("subset CFF glyph for %q (gid %d) not contour-intact", ru, gid) + } } + tu := string(readStream(t, fd.Key("ToUnicode"))) if !strings.Contains(tu, "beginbfchar") { t.Error("ToUnicode has no bfchar section") } } +// mustLoadOT parses raw font bytes with go-opentype, failing the test on error. +func mustLoadOT(t *testing.T, data []byte) *opentype.Font { + t.Helper() + f, err := opentype.Parse(data) + if err != nil { + t.Fatalf("opentype.Parse: %v", err) + } + return f +} + +// wrapCFFinOTF rebuilds an OTTO sfnt from src's container tables with cff swapped +// in for the 'CFF ' table, so a bare subset CFF program can be re-parsed by +// go-opentype. src's other tables (head, hhea, maxp, hmtx, cmap, ...) are copied +// verbatim; the subset preserves glyph numbering, so they stay valid. +func wrapCFFinOTF(t *testing.T, src *opentype.Font, cff []byte) []byte { + t.Helper() + tables := map[string][]byte{"CFF ": cff} + for _, tag := range src.TableTags() { + if tag == "CFF " { + continue + } + b, _ := src.Table(tag) + tables[tag] = b + } + return assembleSFNT(0x4F54544F, tables) // OTTO +} + +// sameGlyphRender reports whether glyph a in font fa and glyph b in font fb +// rasterise to the same alpha mask at a common size — a contour-level equality +// check independent of glyph numbering. +func sameGlyphRender(fa *opentype.Font, a opentype.GlyphIndex, fb *opentype.Font, b opentype.GlyphIndex) bool { + const size = 64 + ba, ma, _, _, oka := fa.NewFace(size).GlyphMaskIndex(a, 0, 0) + bb, mb, _, _, okb := fb.NewFace(size).GlyphMaskIndex(b, 0, 0) + if oka != okb { + return false + } + if !oka { // both have no outline (e.g. a space): trivially equal + return true + } + if ba != bb || len(ma.Pix) != len(mb.Pix) { + return false + } + for i := range ma.Pix { + if ma.Pix[i] != mb.Pix[i] { + return false + } + } + return true +} + func TestOracleGraphicsAndImage(t *testing.T) { doc := New(Options{}) p := doc.AddPage(NewPageSize(300, 300)) diff --git a/sfnt.go b/sfnt.go deleted file mode 100644 index 3f7268f..0000000 --- a/sfnt.go +++ /dev/null @@ -1,223 +0,0 @@ -// Copyright (c) 2026 the go-pdfkit/pdfkit authors. All rights reserved. -// Use of this source code is governed by a BSD-3-Clause license that can be -// found in the LICENSE file at the root of this repository. - -package pdfkit - -import ( - "encoding/binary" - "fmt" -) - -// sfntFont is pdfkit's own light re-parse of the sfnt container behind a font -// blob. go-opentype/opentype decodes the same bytes for glyph indexing, -// metrics and shaping, but it does not expose the raw table data, the -// units-per-em, the glyf/loca arrays or a subsetting export that embedding a -// font into a PDF requires; pdfkit therefore reparses the container it is -// handed. See the package doc for the list of missing upstream primitives. -type sfntFont struct { - data []byte - tables map[string][]byte - - unitsPerEm int - numGlyphs int - indexToLocFormat int - numHMetrics int - ascender int - descender int - xMin, yMin int - xMax, yMax int - italicAngle float64 - capHeight int - flags int - isCFF bool - - loca []uint32 // byte offsets into glyf, length numGlyphs+1 (TrueType only) - advances []int // advance width per glyph, in font units -} - -// u16 reads a big-endian uint16 at b[i:]. -func u16(b []byte, i int) int { return int(binary.BigEndian.Uint16(b[i:])) } - -// s16 reads a big-endian int16 at b[i:]. -func s16(b []byte, i int) int { return int(int16(binary.BigEndian.Uint16(b[i:]))) } - -// u32 reads a big-endian uint32 at b[i:]. -func u32(b []byte, i int) uint32 { return binary.BigEndian.Uint32(b[i:]) } - -// parseSFNT decodes the container-level tables pdfkit needs for embedding. -func parseSFNT(data []byte) (*sfntFont, error) { - if len(data) < 12 { - return nil, fmt.Errorf("pdfkit: short sfnt header") - } - version := u32(data, 0) - switch version { - case 0x00010000, 0x74727565, 0x4F54544F: // TrueType, "true", "OTTO" - default: - return nil, fmt.Errorf("pdfkit: unrecognised sfnt version 0x%08x", version) - } - numTables := u16(data, 4) - if 12+numTables*16 > len(data) { - return nil, fmt.Errorf("pdfkit: truncated table directory") - } - tables := make(map[string][]byte, numTables) - for i := 0; i < numTables; i++ { - rec := data[12+i*16:] - tag := string(rec[0:4]) - off := int(u32(rec, 8)) - length := int(u32(rec, 12)) - if off < 0 || length < 0 || off+length > len(data) { - return nil, fmt.Errorf("pdfkit: table %q out of range", tag) - } - tables[tag] = data[off : off+length] - } - - f := &sfntFont{data: data, tables: tables} - if err := f.parseHead(); err != nil { - return nil, err - } - if err := f.parseMaxp(); err != nil { - return nil, err - } - if err := f.parseHhea(); err != nil { - return nil, err - } - if err := f.parseHmtx(); err != nil { - return nil, err - } - f.parseOptional() - - if _, ok := tables["CFF "]; ok { - f.isCFF = true - } else if _, ok := tables["CFF2"]; ok { - f.isCFF = true - } - if !f.isCFF { - if err := f.parseLoca(); err != nil { - return nil, err - } - } - return f, nil -} - -func (f *sfntFont) parseHead() error { - b, ok := f.tables["head"] - if !ok || len(b) < 54 { - return fmt.Errorf("pdfkit: missing or short head table") - } - f.unitsPerEm = u16(b, 18) - if f.unitsPerEm == 0 { - return fmt.Errorf("pdfkit: head unitsPerEm is zero") - } - f.xMin = s16(b, 36) - f.yMin = s16(b, 38) - f.xMax = s16(b, 40) - f.yMax = s16(b, 42) - f.indexToLocFormat = s16(b, 50) - return nil -} - -func (f *sfntFont) parseMaxp() error { - b, ok := f.tables["maxp"] - if !ok || len(b) < 6 { - return fmt.Errorf("pdfkit: missing or short maxp table") - } - f.numGlyphs = u16(b, 4) - if f.numGlyphs == 0 { - return fmt.Errorf("pdfkit: maxp numGlyphs is zero") - } - return nil -} - -func (f *sfntFont) parseHhea() error { - b, ok := f.tables["hhea"] - if !ok || len(b) < 36 { - return fmt.Errorf("pdfkit: missing or short hhea table") - } - f.ascender = s16(b, 4) - f.descender = s16(b, 6) - f.numHMetrics = u16(b, 34) - if f.numHMetrics == 0 { - return fmt.Errorf("pdfkit: hhea numberOfHMetrics is zero") - } - return nil -} - -func (f *sfntFont) parseHmtx() error { - b, ok := f.tables["hmtx"] - if !ok || len(b) < f.numHMetrics*4 { - return fmt.Errorf("pdfkit: missing or short hmtx table") - } - f.advances = make([]int, f.numGlyphs) - last := 0 - for i := 0; i < f.numGlyphs; i++ { - if i < f.numHMetrics { - last = u16(b, i*4) - } - f.advances[i] = last - } - return nil -} - -// parseOptional reads descriptor hints from post and OS/2 when present, filling -// in sensible defaults otherwise. These feed the PDF font descriptor. -func (f *sfntFont) parseOptional() { - f.flags = 32 // Nonsymbolic - if b, ok := f.tables["post"]; ok && len(b) >= 16 { - f.italicAngle = float64(int32(u32(b, 4))) / 65536 - if u32(b, 12) != 0 { // isFixedPitch - f.flags |= 1 - } - } - if f.italicAngle != 0 { - f.flags |= 64 - } - if b, ok := f.tables["OS/2"]; ok && len(b) >= 90 && u16(b, 0) >= 2 { - f.capHeight = s16(b, 88) - } - if f.capHeight == 0 { - f.capHeight = f.ascender - } -} - -func (f *sfntFont) parseLoca() error { - b, ok := f.tables["loca"] - if !ok { - return fmt.Errorf("pdfkit: missing loca table") - } - n := f.numGlyphs + 1 - f.loca = make([]uint32, n) - if f.indexToLocFormat == 0 { - if len(b) < n*2 { - return fmt.Errorf("pdfkit: short short-format loca table") - } - for i := 0; i < n; i++ { - f.loca[i] = uint32(u16(b, i*2)) * 2 - } - } else { - if len(b) < n*4 { - return fmt.Errorf("pdfkit: short long-format loca table") - } - for i := 0; i < n; i++ { - f.loca[i] = u32(b, i*4) - } - } - if _, ok := f.tables["glyf"]; !ok { - return fmt.Errorf("pdfkit: missing glyf table") - } - return nil -} - -// glyphData returns the glyf bytes for glyph gid, or an empty slice when the -// glyph has no outline (loca[gid]==loca[gid+1]). -func (f *sfntFont) glyphData(gid int) []byte { - if gid < 0 || gid+1 >= len(f.loca) { - return nil - } - start, end := f.loca[gid], f.loca[gid+1] - glyf := f.tables["glyf"] - if start >= end || int(end) > len(glyf) { - return nil - } - return glyf[start:end] -} diff --git a/sfnt_test.go b/sfnt_test.go deleted file mode 100644 index 1c28782..0000000 --- a/sfnt_test.go +++ /dev/null @@ -1,173 +0,0 @@ -// Copyright (c) 2026 the go-pdfkit/pdfkit authors. All rights reserved. -// Use of this source code is governed by a BSD-3-Clause license that can be -// found in the LICENSE file at the root of this repository. - -package pdfkit - -import ( - "encoding/binary" - "testing" -) - -func TestParseSFNTContainerErrors(t *testing.T) { - cases := map[string][]byte{ - "short header": {0, 0, 0}, - "bad version": {0xDE, 0xAD, 0xBE, 0xEF, 0, 0, 0, 0, 0, 0, 0, 0}, - } - for name, data := range cases { - if _, err := parseSFNT(data); err == nil { - t.Errorf("%s: expected error", name) - } - } - - // A directory that claims more tables than the bytes hold. - big := make([]byte, 12) - binary.BigEndian.PutUint32(big[0:], 0x00010000) - binary.BigEndian.PutUint16(big[4:], 100) - if _, err := parseSFNT(big); err == nil { - t.Error("truncated directory: expected error") - } - - // One record whose offset+length runs past the data. - bad := make([]byte, 12+16) - binary.BigEndian.PutUint32(bad[0:], 0x00010000) - binary.BigEndian.PutUint16(bad[4:], 1) - copy(bad[12:], "head") - binary.BigEndian.PutUint32(bad[12+8:], 12+16) // offset - binary.BigEndian.PutUint32(bad[12+12:], 9999) // length - if _, err := parseSFNT(bad); err == nil { - t.Error("out-of-range table: expected error") - } -} - -// tbl builds a single-table sfnt around one table so a sub-parser error can be -// provoked without hand-assembling a whole font. -func sfntWith(tables map[string][]byte) *sfntFont { - return &sfntFont{tables: tables} -} - -func TestParseHeadErrors(t *testing.T) { - if err := sfntWith(nil).parseHead(); err == nil { - t.Error("missing head") - } - h := make([]byte, 54) - binary.BigEndian.PutUint16(h[18:], 0) // unitsPerEm zero - if err := sfntWith(map[string][]byte{"head": h}).parseHead(); err == nil { - t.Error("zero unitsPerEm") - } -} - -func TestParseMaxpErrors(t *testing.T) { - if err := sfntWith(nil).parseMaxp(); err == nil { - t.Error("missing maxp") - } - m := make([]byte, 6) // numGlyphs zero - if err := sfntWith(map[string][]byte{"maxp": m}).parseMaxp(); err == nil { - t.Error("zero numGlyphs") - } -} - -func TestParseHheaErrors(t *testing.T) { - if err := sfntWith(nil).parseHhea(); err == nil { - t.Error("missing hhea") - } - h := make([]byte, 36) // numberOfHMetrics zero - if err := sfntWith(map[string][]byte{"hhea": h}).parseHhea(); err == nil { - t.Error("zero numberOfHMetrics") - } -} - -func TestParseHmtxError(t *testing.T) { - f := sfntWith(map[string][]byte{"hmtx": {0, 0}}) - f.numHMetrics = 4 - if err := f.parseHmtx(); err == nil { - t.Error("short hmtx") - } -} - -func TestParseLocaErrors(t *testing.T) { - // Missing loca table. - f := sfntWith(map[string][]byte{}) - f.numGlyphs = 2 - if err := f.parseLoca(); err == nil { - t.Error("missing loca") - } - // Short short-format loca. - f = sfntWith(map[string][]byte{"loca": {0, 0}}) - f.numGlyphs = 4 - f.indexToLocFormat = 0 - if err := f.parseLoca(); err == nil { - t.Error("short short-loca") - } - // Short long-format loca. - f = sfntWith(map[string][]byte{"loca": {0, 0, 0, 0}}) - f.numGlyphs = 4 - f.indexToLocFormat = 1 - if err := f.parseLoca(); err == nil { - t.Error("short long-loca") - } - // Well-formed loca but missing glyf. - loca := make([]byte, (3)*2) - f = sfntWith(map[string][]byte{"loca": loca}) - f.numGlyphs = 2 - f.indexToLocFormat = 0 - if err := f.parseLoca(); err == nil { - t.Error("missing glyf") - } -} - -func TestParseLocaLongFormatAndVariants(t *testing.T) { - // A long-format loca font round-trips through LoadFont and embeds. - f, err := LoadFont(synthTTF(synthOpts{longLoca: true, withName: true, withPost: true, withOS2: true, capHeight: 700})) - if err != nil { - t.Fatal(err) - } - if f.sf.indexToLocFormat != 1 { - t.Errorf("indexToLocFormat = %d, want 1", f.sf.indexToLocFormat) - } -} - -func TestParseOptionalVariants(t *testing.T) { - // Italic + fixed pitch post, no OS/2: capHeight falls back to the ascender. - f, err := LoadFont(synthTTF(synthOpts{italicAngle: -12, fixedPitch: true, withPost: true})) - if err != nil { - t.Fatal(err) - } - if f.sf.italicAngle != -12 { - t.Errorf("italicAngle = %v", f.sf.italicAngle) - } - if f.sf.flags&1 == 0 || f.sf.flags&64 == 0 { - t.Errorf("flags = %d, want fixed-pitch and italic bits", f.sf.flags) - } - if f.sf.capHeight != f.sf.ascender { - t.Errorf("capHeight = %d, want ascender %d", f.sf.capHeight, f.sf.ascender) - } - - // No post table at all: italic angle stays zero. - f2, err := LoadFont(synthTTF(synthOpts{withOS2: true, capHeight: 650})) - if err != nil { - t.Fatal(err) - } - if f2.sf.italicAngle != 0 || f2.sf.capHeight != 650 { - t.Errorf("no-post font: italic=%v cap=%d", f2.sf.italicAngle, f2.sf.capHeight) - } -} - -func TestGlyphDataBounds(t *testing.T) { - f, err := LoadFont(synthTTF(defaultSynth())) - if err != nil { - t.Fatal(err) - } - if d := f.sf.glyphData(-1); d != nil { - t.Error("negative gid should be nil") - } - if d := f.sf.glyphData(999); d != nil { - t.Error("out-of-range gid should be nil") - } - if d := f.sf.glyphData(0); d != nil { - t.Error(".notdef has no outline, want nil") - } - if d := f.sf.glyphData(1); d == nil { - t.Error("glyph 1 should have outline data") - } -} diff --git a/subset.go b/subset.go deleted file mode 100644 index 685d40b..0000000 --- a/subset.go +++ /dev/null @@ -1,194 +0,0 @@ -// Copyright (c) 2026 the go-pdfkit/pdfkit authors. All rights reserved. -// Use of this source code is governed by a BSD-3-Clause license that can be -// found in the LICENSE file at the root of this repository. - -package pdfkit - -import ( - "encoding/binary" - - "github.com/go-opentype/opentype" -) - -// TrueType composite-glyph flags relevant to walking component references. -const ( - argsAreWords = 0x0001 - moreComponents = 0x0020 - haveScale = 0x0008 - haveXYScale = 0x0040 - have2x2 = 0x0080 -) - -// subsetTrueType returns a minimal but valid TrueType font program containing -// only the glyphs in gids (plus every glyph a composite among them references, -// found transitively). Glyph numbering is preserved, so the PDF font can use an -// Identity CIDToGIDMap; unused glyphs become empty entries. This subsetter is -// implemented in pdfkit because go-opentype exposes no glyf/loca export. -func subsetTrueType(sf *sfntFont, gids []opentype.GlyphIndex) []byte { - needed := map[int]bool{0: true} - for _, g := range gids { - collectComponents(sf, int(g), needed) - } - - // Rebuild glyf and a long-format loca preserving glyph ids. - var glyf []byte - loca := make([]uint32, sf.numGlyphs+1) - for gid := 0; gid < sf.numGlyphs; gid++ { - loca[gid] = uint32(len(glyf)) - if needed[gid] { - d := sf.glyphData(gid) - glyf = append(glyf, d...) - for len(glyf)%2 != 0 { // 2-byte align each glyph - glyf = append(glyf, 0) - } - } - } - loca[sf.numGlyphs] = uint32(len(glyf)) - - locaBytes := make([]byte, len(loca)*4) - for i, off := range loca { - binary.BigEndian.PutUint32(locaBytes[i*4:], off) - } - - head := cloneBytes(sf.tables["head"]) - binary.BigEndian.PutUint32(head[8:], 0) // checkSumAdjustment: recomputed later - binary.BigEndian.PutUint16(head[50:], 1) // indexToLocFormat: long - - tables := map[string][]byte{ - "head": head, - "hhea": sf.tables["hhea"], - "maxp": sf.tables["maxp"], - "hmtx": sf.tables["hmtx"], - "loca": locaBytes, - "glyf": glyf, - } - // Carry over the cmap (so the subset is a self-contained, parseable font) and - // the TrueType instruction tables (so hinted glyphs still render as designed) - // when present. - for _, opt := range []string{"cmap", "cvt ", "fpgm", "prep"} { - if b, ok := sf.tables[opt]; ok { - tables[opt] = b - } - } - return assembleSFNT(0x00010000, tables) -} - -// collectComponents adds gid and, when it is a composite glyph, every component -// glyph it references (transitively) to the needed set. -func collectComponents(sf *sfntFont, gid int, needed map[int]bool) { - if gid < 0 || gid >= sf.numGlyphs || needed[gid] { - return - } - needed[gid] = true - d := sf.glyphData(gid) - if len(d) < 10 { - return - } - if int16(binary.BigEndian.Uint16(d)) >= 0 { - return // simple glyph, no components - } - p := 10 - for p+4 <= len(d) { - flags := binary.BigEndian.Uint16(d[p:]) - comp := int(binary.BigEndian.Uint16(d[p+2:])) - p += 4 - if flags&argsAreWords != 0 { - p += 4 - } else { - p += 2 - } - switch { - case flags&haveScale != 0: - p += 2 - case flags&haveXYScale != 0: - p += 4 - case flags&have2x2 != 0: - p += 8 - } - collectComponents(sf, comp, needed) - if flags&moreComponents == 0 { - break - } - } -} - -// cloneBytes returns a copy of b so in-place patches do not touch the original -// font data. -func cloneBytes(b []byte) []byte { - c := make([]byte, len(b)) - copy(c, b) - return c -} - -// assembleSFNT packs tables into an sfnt container with the given version, -// computing the directory, per-table checksums and the head checkSumAdjustment. -func assembleSFNT(version uint32, tables map[string][]byte) []byte { - tags := make([]string, 0, len(tables)) - for t := range tables { - tags = append(tags, t) - } - // Directory records must be sorted by tag. - for i := 1; i < len(tags); i++ { - for j := i; j > 0 && tags[j-1] > tags[j]; j-- { - tags[j-1], tags[j] = tags[j], tags[j-1] - } - } - - n := len(tags) - entrySelector := 0 - for (1 << (entrySelector + 1)) <= n { - entrySelector++ - } - searchRange := (1 << entrySelector) * 16 - rangeShift := n*16 - searchRange - - headerLen := 12 + 16*n - // Lay out padded table bodies after the directory. - offsets := make(map[string]int, n) - var body []byte - for _, t := range tags { - offsets[t] = headerLen + len(body) - body = append(body, tables[t]...) - for len(body)%4 != 0 { - body = append(body, 0) - } - } - - out := make([]byte, headerLen) - binary.BigEndian.PutUint32(out[0:], version) - binary.BigEndian.PutUint16(out[4:], uint16(n)) - binary.BigEndian.PutUint16(out[6:], uint16(searchRange)) - binary.BigEndian.PutUint16(out[8:], uint16(entrySelector)) - binary.BigEndian.PutUint16(out[10:], uint16(rangeShift)) - for i, t := range tags { - rec := 12 + i*16 - copy(out[rec:], t) - binary.BigEndian.PutUint32(out[rec+4:], tableChecksum(tables[t])) - binary.BigEndian.PutUint32(out[rec+8:], uint32(offsets[t])) - binary.BigEndian.PutUint32(out[rec+12:], uint32(len(tables[t]))) - } - out = append(out, body...) - - // Patch head.checkSumAdjustment = 0xB1B0AFBA - checksum(whole file). - if headOff, ok := offsets["head"]; ok { - adj := 0xB1B0AFBA - tableChecksum(out) - binary.BigEndian.PutUint32(out[headOff+8:], adj) - } - return out -} - -// tableChecksum is the sum (mod 2^32) of the data read as big-endian uint32s, -// zero-padded to a multiple of four bytes. -func tableChecksum(b []byte) uint32 { - var sum uint32 - var i int - for ; i+4 <= len(b); i += 4 { - sum += binary.BigEndian.Uint32(b[i:]) - } - if rem := len(b) - i; rem > 0 { - var tail [4]byte - copy(tail[:], b[i:]) - sum += binary.BigEndian.Uint32(tail[:]) - } - return sum -} diff --git a/subset_test.go b/subset_test.go deleted file mode 100644 index 528a905..0000000 --- a/subset_test.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) 2026 the go-pdfkit/pdfkit authors. All rights reserved. -// Use of this source code is governed by a BSD-3-Clause license that can be -// found in the LICENSE file at the root of this repository. - -package pdfkit - -import "testing" - -// craftGlyfFont builds a bare sfntFont whose glyphData is driven by the given -// glyph blobs, for testing composite-component walking in isolation. -func craftGlyfFont(glyphs [][]byte) *sfntFont { - f := &sfntFont{numGlyphs: len(glyphs), tables: map[string][]byte{}} - var glyf []byte - f.loca = make([]uint32, len(glyphs)+1) - for i, g := range glyphs { - f.loca[i] = uint32(len(glyf)) - glyf = append(glyf, g...) - for len(glyf)%2 != 0 { - glyf = append(glyf, 0) - } - } - f.loca[len(glyphs)] = uint32(len(glyf)) - f.tables["glyf"] = glyf - return f -} - -// compositeRef1 builds a one-component composite referencing glyph 1 with the -// given extra transform flag (0, haveScale, haveXYScale or have2x2). -func compositeRef1(extraFlag uint16) []byte { - w := &bw{} - w.i16(-1) // composite - w.i16(0) - w.i16(0) - w.i16(10) - w.i16(10) - w.u16(0x0002 | extraFlag) // ARGS_ARE_XY_VALUES + transform, no MORE, byte args - w.u16(1) // glyphIndex - w.u8(0) // arg1 (byte) - w.u8(0) // arg2 (byte) - switch extraFlag { - case haveScale: - w.i16(0x4000) // one F2Dot14 scale - case haveXYScale: - w.i16(0x4000) - w.i16(0x4000) - case have2x2: - w.i16(0x4000) - w.i16(0) - w.i16(0) - w.i16(0x4000) - } - return w.b -} - -func TestCollectComponents(t *testing.T) { - simple := simpleBox(0, 0, 10, 10) - for _, flag := range []uint16{0, haveScale, haveXYScale, have2x2} { - f := craftGlyfFont([][]byte{nil, simple, compositeRef1(flag)}) - need := map[int]bool{} - collectComponents(f, 2, need) - if !need[1] || !need[2] { - t.Errorf("flag %#x: components not collected: %v", flag, need) - } - } - - // Guard branches: out-of-range and already-visited return without effect. - f := craftGlyfFont([][]byte{nil, simpleBox(0, 0, 10, 10)}) - need := map[int]bool{} - collectComponents(f, -1, need) // negative - collectComponents(f, 99, need) // >= numGlyphs - collectComponents(f, 0, need) // empty glyph (len(d) < 10) - collectComponents(f, 1, need) // simple glyph returns early - before := len(need) - collectComponents(f, 1, need) // already visited: no change - if len(need) != before { - t.Error("already-visited glyph mutated the set") - } -} - -func TestAssembleSFNTNoHead(t *testing.T) { - // Exercises the branch where no head table is present (no checksum patch). - out := assembleSFNT(0x00010000, map[string][]byte{"test": {1, 2, 3, 4, 5}}) - if len(out) == 0 { - t.Fatal("empty sfnt") - } - // Directory should advertise one table. - if u16(out, 4) != 1 { - t.Errorf("numTables = %d", u16(out, 4)) - } -} - -func TestTableChecksumPadding(t *testing.T) { - // A length that is not a multiple of four exercises the tail padding. - if got := tableChecksum([]byte{0, 0, 0, 1, 2}); got != 1+0x02000000 { - t.Errorf("checksum = %#x", got) - } -} diff --git a/synth_test.go b/synth_test.go index 924328a..d83fbc5 100644 --- a/synth_test.go +++ b/synth_test.go @@ -6,6 +6,79 @@ package pdfkit import "encoding/binary" +// assembleSFNT packs tables into an sfnt container with the given version, +// computing the directory, per-table checksums and the head checkSumAdjustment. +// It is a test-only helper for synthesising fonts: the production code no longer +// assembles sfnt containers (go-opentype's subsetters do), so this lives with the +// synth builder rather than in the package. +func assembleSFNT(version uint32, tables map[string][]byte) []byte { + tags := make([]string, 0, len(tables)) + for t := range tables { + tags = append(tags, t) + } + for i := 1; i < len(tags); i++ { + for j := i; j > 0 && tags[j-1] > tags[j]; j-- { + tags[j-1], tags[j] = tags[j], tags[j-1] + } + } + + n := len(tags) + entrySelector := 0 + for (1 << (entrySelector + 1)) <= n { + entrySelector++ + } + searchRange := (1 << entrySelector) * 16 + rangeShift := n*16 - searchRange + + headerLen := 12 + 16*n + offsets := make(map[string]int, n) + var body []byte + for _, t := range tags { + offsets[t] = headerLen + len(body) + body = append(body, tables[t]...) + for len(body)%4 != 0 { + body = append(body, 0) + } + } + + out := make([]byte, headerLen) + binary.BigEndian.PutUint32(out[0:], version) + binary.BigEndian.PutUint16(out[4:], uint16(n)) + binary.BigEndian.PutUint16(out[6:], uint16(searchRange)) + binary.BigEndian.PutUint16(out[8:], uint16(entrySelector)) + binary.BigEndian.PutUint16(out[10:], uint16(rangeShift)) + for i, t := range tags { + rec := 12 + i*16 + copy(out[rec:], t) + binary.BigEndian.PutUint32(out[rec+4:], tableChecksum(tables[t])) + binary.BigEndian.PutUint32(out[rec+8:], uint32(offsets[t])) + binary.BigEndian.PutUint32(out[rec+12:], uint32(len(tables[t]))) + } + out = append(out, body...) + + if headOff, ok := offsets["head"]; ok { + adj := 0xB1B0AFBA - tableChecksum(out) + binary.BigEndian.PutUint32(out[headOff+8:], adj) + } + return out +} + +// tableChecksum is the sum (mod 2^32) of the data read as big-endian uint32s, +// zero-padded to a multiple of four bytes. +func tableChecksum(b []byte) uint32 { + var sum uint32 + var i int + for ; i+4 <= len(b); i += 4 { + sum += binary.BigEndian.Uint32(b[i:]) + } + if rem := len(b) - i; rem > 0 { + var tail [4]byte + copy(tail[:], b[i:]) + sum += binary.BigEndian.Uint32(tail[:]) + } + return sum +} + // bw is a minimal big-endian byte writer for synthesising font tables in tests. type bw struct{ b []byte } @@ -234,7 +307,7 @@ func buildName(ps string) []byte { t := &bw{} t.u16(0) // format t.u16(2) // count - t.u16(uint16(6+2*12)) + t.u16(uint16(6 + 2*12)) // record 0: Windows (platform 3, enc 1, lang 0x409) t.u16(3) t.u16(1) diff --git a/synthcff2_test.go b/synthcff2_test.go new file mode 100644 index 0000000..8eaa9c6 --- /dev/null +++ b/synthcff2_test.go @@ -0,0 +1,125 @@ +// Copyright (c) 2026 the go-pdfkit/pdfkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package pdfkit + +// This file synthesises a minimal, parseable CFF2 (variable) OpenType font so the +// tests can exercise the CFF-subset fallback: go-opentype's SubsetCFF rejects a +// CFF2 font (an instance must be baked first), so pdfkit falls back to embedding +// the whole 'CFF2' program. The font carries two empty charstrings, which is +// enough for opentype.Parse to accept it and for pdfkit to embed it; nothing +// renders it. + +// cff2Index encodes a CFF2 INDEX (a 32-bit count, unlike CFF's 16-bit) of items. +func cff2Index(items [][]byte) []byte { + w := &bw{} + if len(items) == 0 { + w.u32(0) + return w.b + } + total := 1 + offs := []int{1} + for _, it := range items { + total += len(it) + offs = append(offs, total) + } + offSize := 1 + for total > (1<<(8*offSize))-1 { + offSize++ + } + w.u32(uint32(len(items))) + w.u8(uint8(offSize)) + for _, o := range offs { + for k := offSize - 1; k >= 0; k-- { + w.u8(byte(o >> (8 * k))) + } + } + for _, it := range items { + w.b = append(w.b, it...) + } + return w.b +} + +// cff2DictLong encodes a DICT integer operand in the fixed-width 5-byte form. +func cff2DictLong(v int) []byte { + return []byte{29, byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)} +} + +// buildCFF2Table assembles a minimal CFF2 table: a 5-byte header, a Top DICT +// carrying only the CharStrings offset (operator 17), an empty Global Subr INDEX +// and the CharStrings INDEX. +func buildCFF2Table(charStrings [][]byte) []byte { + gsubr := cff2Index(nil) + cs := cff2Index(charStrings) + top := func(csOff int) []byte { return append(cff2DictLong(csOff), 17) } + topLen := len(top(0)) + csOff := 5 + topLen + len(gsubr) + td := top(csOff) + + out := []byte{2, 0, 5, byte(len(td) >> 8), byte(len(td))} // header + topDictLength + out = append(out, td...) + out = append(out, gsubr...) + out = append(out, cs...) + return out +} + +// synthCFF2 assembles a two-glyph CFF2 OpenType font (.notdef + 'A'), the minimal +// shape opentype.Parse accepts and pdfkit embeds through its whole-table fallback. +func synthCFF2() []byte { + cff2 := buildCFF2Table([][]byte{{}, {}}) // two empty charstrings + + head := &bw{} + head.u32(0x00010000) + head.u32(0) + head.u32(0) + head.u32(0x5F0F3CF5) + head.u16(0) + head.u16(1000) // unitsPerEm (offset 18) + head.u32(0) + head.u32(0) + head.u32(0) + head.u32(0) + head.i16(0) // xMin + head.i16(-200) // yMin + head.i16(700) // xMax + head.i16(800) // yMax + head.u16(0) + head.u16(8) + head.i16(2) + head.i16(0) // indexToLocFormat + head.i16(0) + + maxp := &bw{} + maxp.u32(0x00005000) + maxp.u16(2) + + hhea := &bw{} + hhea.u32(0x00010000) + hhea.i16(800) + hhea.i16(-200) + hhea.i16(0) + hhea.u16(1000) + for i := 0; i < 10; i++ { + hhea.i16(0) + } + hhea.i16(0) + hhea.u16(2) // numberOfHMetrics + + hmtx := &bw{} + hmtx.u16(500) + hmtx.i16(0) + hmtx.u16(500) + hmtx.i16(0) + + cmap := buildCmap12(map[rune]uint16{'A': 1}) + + return assembleSFNT(0x4F54544F, map[string][]byte{ // OTTO + "head": head.b, + "maxp": maxp.b, + "hhea": hhea.b, + "hmtx": hmtx.b, + "cmap": cmap, + "CFF2": cff2, + }) +} diff --git a/text.go b/text.go index 6365af3..4951b53 100644 --- a/text.go +++ b/text.go @@ -188,12 +188,12 @@ func (p *Page) TextShaped(x, y float64, s string, features ...string) error { use := p.doc.use[f] // A face sized to unitsPerEm has scale 1, so ShapePositioned reports offsets // and advances directly in font units. - face := f.ot.NewFace(f.sf.unitsPerEm) + face := f.ot.NewFace(f.ot.UnitsPerEm()) run := face.ShapePositioned(s, features...) runes := []rune(s) aligned := len(run) == len(runes) - scale := p.fontSize / float64(f.sf.unitsPerEm) + scale := p.fontSize / float64(f.ot.UnitsPerEm()) p.op("", "BT") p.emitTextState() pen := 0