Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 20 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
181 changes: 111 additions & 70 deletions coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
28 changes: 16 additions & 12 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,24 +31,28 @@
// 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
//
// With the zero Options the output contains no timestamps and a content-derived
// /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
Loading
Loading