diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a0199a2..06ec589 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -63,7 +63,7 @@ body: id: gpdf-version attributes: label: gpdf Version - placeholder: "v1.0.11" + placeholder: "v1.0.13" validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 258b1da..7906b65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.0.13] - 2026-09-04 + +### Fixed +- Overlay text drawn in a font registered via `WithFont` is now embedded as a Type0/Identity-H composite font, so non-Latin scripts (Arabic, CJK, Cyrillic, …) render correctly instead of as `?` (#37) + - `document/render/overlay.go`: `renderText` emitted the raw UTF-8 bytes as a literal string (`(…) Tj`) while `WriteOverlayToModifier` registered the font as a bare `/Subtype /TrueType` dict with no `/Encoding`, `/Widths` or `/FirstChar`. Viewers read those bytes as single-byte codes and substituted a glyph for each one. Overlay text in a registered TrueType font is now encoded as big-endian glyph IDs (`<…> Tj`), matching what the normal render path has always done. + - `document/render/overlay.go`: new `OverlayFontRegistry` allocates one font object per family across every `Overlay` call, and `ExistingDocument.Save` flushes it once. A font used on many pages is therefore embedded a single time and subsetted over all glyphs the document actually uses, instead of once per page. + - `document/render/overlay.go`: bold/italic variants with no dedicated registered face now fall back to the base family's embedded font rather than silently degrading to Helvetica, mirroring `PDFRenderer.resolveTextFont`. + - `document/render/pdftarget.go`: the Type0/CIDFont writer (`writeType0Font`, `writeFontDescriptor`, `writeCIDFont`, `writeToUnicodeCMap`, `writeCompressedStream`, `subsetFontData`) is now written against an `objectSink` interface so it serves both `pdf.Writer` (new documents) and `pdf.Modifier` (incremental updates) instead of being duplicated. + - `document/render/overlay_test.go`, `template/overlay_test.go`: regression coverage for glyph-ID encoding, the variant fallback, the emitted `/Type0` + `/Identity-H` + `/FontFile2` structure, and single embedding across pages + - `_validation/validation_test.go`: `TestOverlayEmbeddedFont` validates an overlay-with-embedded-font PDF through pdfcpu +- `ParseSignatureInfo` no longer truncates a CMS blob whose DER ends in a zero byte, which made ECDSA signature verification fail intermittently + - `signature/verify.go`: `extractContentsHex` stripped the fixed-width `/Contents` placeholder padding with `strings.TrimRight(hex, "0")`, so a signature whose own final byte was `0x00` came back one byte short and failed to parse as `asn1: syntax error: data truncated`. The outer DER tag-length header is now read to slice off exactly the signature, whatever it ends with. An all-zero placeholder is also rejected with a clear message instead of reaching the ASN.1 parser. + - This affected ECDSA in practice — its DER length and trailing byte vary per signature, so roughly 1 in 256 signatures hit it, which showed up as a flaky `TestSign_WithTimestamp_ECDSA` in CI. RSA was unaffected because its signature length is fixed. + - `signature/sign_test.go`: deterministic coverage for a payload ending in `0x00`, long-form DER lengths, and malformed contents + +## [1.0.12] - 2026-09-03 + ### Fixed - Reader now supports compressed object streams (`/ObjStm`) and predictor-encoded streams, so PDFs written with a cross-reference stream — the default for most modern producers — can be opened, overlaid and merged instead of failing with `pdf: object N not found in xref` (#35) - `pdf/objstm.go`: new `/ObjStm` reader — decodes the stream body, parses the `N` object-number/offset header pairs up to `/First`, and resolves objects from it. Decoded streams are cached so sibling objects do not re-inflate the same body, and each object is parsed from its own bounded slice of the body. @@ -196,7 +213,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Reed-Solomon coefficient order in QR code encoder - binary.Write return value handling for errcheck lint -[Unreleased]: https://github.com/gpdf-dev/gpdf/compare/v1.0.11...HEAD +[Unreleased]: https://github.com/gpdf-dev/gpdf/compare/v1.0.13...HEAD +[1.0.13]: https://github.com/gpdf-dev/gpdf/compare/v1.0.12...v1.0.13 +[1.0.12]: https://github.com/gpdf-dev/gpdf/compare/v1.0.11...v1.0.12 [1.0.11]: https://github.com/gpdf-dev/gpdf/compare/v1.0.10...v1.0.11 [1.0.10]: https://github.com/gpdf-dev/gpdf/compare/v1.0.9...v1.0.10 [1.0.9]: https://github.com/gpdf-dev/gpdf/compare/v1.0.8...v1.0.9 diff --git a/README.md b/README.md index 16dc9a3..383e324 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/gpdf-dev/gpdf.svg)](https://pkg.go.dev/github.com/gpdf-dev/gpdf) [![CI](https://github.com/gpdf-dev/gpdf/actions/workflows/check-code.yml/badge.svg)](https://github.com/gpdf-dev/gpdf/actions/workflows/check-code.yml) -![coverage](https://img.shields.io/badge/coverage-84.2%25-green) +![coverage](https://img.shields.io/badge/coverage-84.3%25-green) [![Go Report Card](https://goreportcard.com/badge/github.com/gpdf-dev/gpdf)](https://goreportcard.com/report/github.com/gpdf-dev/gpdf) [![Go Version](https://img.shields.io/badge/Go-%3E%3D1.22-blue)](https://go.dev/) [![Website](https://img.shields.io/badge/Website-gpdf.dev-blue)](https://gpdf.dev/) diff --git a/_validation/validation_test.go b/_validation/validation_test.go index fc10d1a..05a1d1c 100644 --- a/_validation/validation_test.go +++ b/_validation/validation_test.go @@ -383,6 +383,54 @@ func TestPoppler_PdfToText(t *testing.T) { } } +// --------------------------------------------------------------------------- +// Overlay with an embedded font — see gpdf#37 +// --------------------------------------------------------------------------- + +// TestOverlayEmbeddedFont checks that overlay text drawn in a font registered +// via WithFont produces a valid PDF. The font is embedded as a Type0 composite +// font through an incremental update; before the fix the overlay wrote raw +// UTF-8 bytes against a bare /TrueType dict and every non-ASCII character +// rendered as "?". +func TestOverlayEmbeddedFont(t *testing.T) { + fontPath := filepath.Join("..", "..", "NotoSansJP-Regular.ttf") + fontData, err := os.ReadFile(fontPath) + if err != nil { + t.Skipf("font fixture not found: %s", fontPath) + } + + plain, err := genHelloWorld() + if err != nil { + t.Fatalf("generate: %v", err) + } + + doc, err := gpdf.Open(plain, template.WithFont("NotoSansJP", fontData)) + if err != nil { + t.Fatalf("open PDF: %v", err) + } + err = doc.Overlay(0, func(p *template.PageBuilder) { + p.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text("日本語オーバーレイ", template.FontFamily("NotoSansJP")) + }) + }) + }) + if err != nil { + t.Fatalf("overlay: %v", err) + } + out, err := doc.Save() + if err != nil { + t.Fatalf("save: %v", err) + } + + if err := pdfcpuapi.Validate(bytes.NewReader(out), nil); err != nil { + t.Errorf("pdfcpu validate overlay with embedded font: %v", err) + } + if !bytes.Contains(out, []byte("/Identity-H")) { + t.Error("overlay font should be embedded as a Type0/Identity-H font") + } +} + // --------------------------------------------------------------------------- // Object stream (/ObjStm) round-trip — see gpdf#35 // --------------------------------------------------------------------------- diff --git a/document/render/overlay.go b/document/render/overlay.go index 7d76b9a..292eca7 100644 --- a/document/render/overlay.go +++ b/document/render/overlay.go @@ -2,6 +2,7 @@ package render import ( "crypto/sha256" + "encoding/hex" "fmt" "strings" @@ -26,7 +27,8 @@ type OverlayResult struct { type fontObject struct { ResName string Family string - Data []byte // nil for standard fonts + Data []byte // nil for standard (non-embedded) fonts + TTF *font.TrueTypeFont // nil for standard fonts } type imageObject struct { @@ -155,7 +157,7 @@ func (r *OverlayRenderer) renderText(text string, pos document.Point, style docu fontSize = 12 } - fontName := resolvePDFFontName(style.FontFamily, style.FontWeight, style.FontStyle) + fontName, ttf := r.resolveTextFont(style) resName := r.ensureFont(fontName) pdfY := r.pageHeight - pos.Y - fontSize @@ -172,7 +174,15 @@ func (r *OverlayRenderer) renderText(text string, pos document.Point, style docu fmt.Fprintf(&buf, "%g Tc\n", style.LetterSpacing) } fmt.Fprintf(&buf, "%g %g Td\n", pos.X, pdfY) - fmt.Fprintf(&buf, "(%s) Tj\n", escapeStringPDF(text)) + if ttf != nil { + // Embedded TrueType fonts are written as Type0/Identity-H composite + // fonts, so text is encoded as big-endian glyph IDs. Emitting the raw + // UTF-8 bytes here instead makes viewers render every non-ASCII + // character as "?" (issue #37). + fmt.Fprintf(&buf, "<%s> Tj\n", hex.EncodeToString(ttf.Encode(text))) + } else { + fmt.Fprintf(&buf, "(%s) Tj\n", escapeStringPDF(text)) + } if style.LetterSpacing != 0 { buf.WriteString("0 Tc\n") } @@ -237,6 +247,22 @@ func (r *OverlayRenderer) renderImage(src document.ImageSource, pos document.Poi r.content = append(r.content, buf.String()...) } +// resolveTextFont picks the PDF font name for a style and returns the +// registered TrueType font behind it, if any. Like the main renderer it falls +// back from the variant name (e.g. "MyFont-Bold") to the base family so that +// a single registered face still gets used — and embedded — for bold/italic +// text instead of silently degrading to Helvetica. +func (r *OverlayRenderer) resolveTextFont(style document.Style) (string, *font.TrueTypeFont) { + fontName := resolvePDFFontName(style.FontFamily, style.FontWeight, style.FontStyle) + if ttf, ok := r.fonts[fontName]; ok { + return fontName, ttf + } + if ttf, ok := r.fonts[style.FontFamily]; ok { + return style.FontFamily, ttf + } + return fontName, nil +} + func (r *OverlayRenderer) ensureFont(family string) string { if family == "" { family = "Helvetica" @@ -249,15 +275,21 @@ func (r *OverlayRenderer) ensureFont(family string) string { resName := fmt.Sprintf("OvF%d", r.fontCount) r.fontMap[family] = resName + ttf := r.fonts[family] + var data []byte if r.fontDataMap != nil { data = r.fontDataMap[family] } + if len(data) == 0 && ttf != nil { + data = ttf.Data() + } r.fontObjects[family] = fontObject{ ResName: resName, Family: family, Data: data, + TTF: ttf, } return resName @@ -356,9 +388,89 @@ func RenderOverlayContent( return renderer.RenderOverlay(layouts[0].Children) } +// modifierSink adapts a [pdf.Modifier] to the objectSink interface used by +// the shared composite-font writer in pdftarget.go. +type modifierSink struct{ m *pdf.Modifier } + +func (s modifierSink) AllocObject() pdf.ObjectRef { return s.m.AllocObject() } + +func (s modifierSink) WriteObject(ref pdf.ObjectRef, obj pdf.Object) error { + s.m.SetObject(ref, obj) + return nil +} + +// OverlayFontRegistry assigns one PDF font object per font family across all +// overlay operations on a document. Object references are handed out eagerly +// so pages can reference them, while the font objects themselves are written +// by Flush — after every page has been rendered, so the embedded subset covers +// all glyphs used anywhere in the document and is embedded exactly once. +type OverlayFontRegistry struct { + refs map[string]pdf.ObjectRef + fonts map[string]fontObject +} + +// NewOverlayFontRegistry creates an empty registry. +func NewOverlayFontRegistry() *OverlayFontRegistry { + return &OverlayFontRegistry{ + refs: make(map[string]pdf.ObjectRef), + fonts: make(map[string]fontObject), + } +} + +// ref returns the object reference for a font, allocating it on first use. +func (reg *OverlayFontRegistry) ref(m *pdf.Modifier, fo fontObject) pdf.ObjectRef { + if ref, ok := reg.refs[fo.Family]; ok { + return ref + } + ref := m.AllocObject() + reg.refs[fo.Family] = ref + reg.fonts[fo.Family] = fo + return ref +} + +// Flush writes the font objects for every family registered so far. +func (reg *OverlayFontRegistry) Flush(m *pdf.Modifier) error { + sink := modifierSink{m: m} + for family, fo := range reg.fonts { + ref := reg.refs[family] + if fo.TTF != nil { + if err := writeType0Font(sink, family, ref, fo.TTF, fo.Data); err != nil { + return fmt.Errorf("write overlay font %q: %w", family, err) + } + continue + } + // Standard Type1 font — no embedded data. + m.SetObject(ref, pdf.Dict{ + pdf.Name("Type"): pdf.Name("Font"), + pdf.Name("Subtype"): pdf.Name("Type1"), + pdf.Name("BaseFont"): pdf.Name(family), + }) + } + clear(reg.fonts) + return nil +} + // WriteOverlayToModifier registers overlay fonts and images with the modifier, // and returns the content bytes and a resource dict with the correct ObjectRefs. +// Fonts are embedded immediately; use [WriteOverlayToModifierWithFonts] with a +// shared [OverlayFontRegistry] when overlaying several pages of one document so +// that each font is embedded only once. func WriteOverlayToModifier(result *OverlayResult, m *pdf.Modifier) ([]byte, *pdf.Dict, error) { + reg := NewOverlayFontRegistry() + content, resources, err := WriteOverlayToModifierWithFonts(result, m, reg) + if err != nil { + return nil, nil, err + } + if err := reg.Flush(m); err != nil { + return nil, nil, err + } + return content, resources, nil +} + +// WriteOverlayToModifierWithFonts is [WriteOverlayToModifier] with an explicit +// font registry. The caller is responsible for calling reg.Flush before the +// modified PDF is written out. +func WriteOverlayToModifierWithFonts(result *OverlayResult, m *pdf.Modifier, reg *OverlayFontRegistry) ([]byte, *pdf.Dict, error) { if result == nil || len(result.Content) == 0 { return nil, nil, nil } @@ -369,46 +481,7 @@ func WriteOverlayToModifier(result *OverlayResult, m *pdf.Modifier) ([]byte, *pd if len(result.FontObjects) > 0 { fontDict := make(pdf.Dict) for _, fo := range result.FontObjects { - fontRef := m.AllocObject() - - if len(fo.Data) > 0 { - // TrueType font with embedded data. - fontFileRef := m.AllocObject() - compressed, err := pdf.CompressFlate(fo.Data) - if err != nil { - return nil, nil, fmt.Errorf("compress font: %w", err) - } - m.SetObject(fontFileRef, pdf.Stream{ - Dict: pdf.Dict{ - pdf.Name("Length1"): pdf.Integer(len(fo.Data)), - pdf.Name("Filter"): pdf.Name("FlateDecode"), - }, - Content: compressed, - }) - - descRef := m.AllocObject() - m.SetObject(descRef, pdf.Dict{ - pdf.Name("Type"): pdf.Name("FontDescriptor"), - pdf.Name("FontName"): pdf.Name(fo.Family), - pdf.Name("FontFile2"): fontFileRef, - }) - - m.SetObject(fontRef, pdf.Dict{ - pdf.Name("Type"): pdf.Name("Font"), - pdf.Name("Subtype"): pdf.Name("TrueType"), - pdf.Name("BaseFont"): pdf.Name(fo.Family), - pdf.Name("FontDescriptor"): descRef, - }) - } else { - // Standard Type1 font. - m.SetObject(fontRef, pdf.Dict{ - pdf.Name("Type"): pdf.Name("Font"), - pdf.Name("Subtype"): pdf.Name("Type1"), - pdf.Name("BaseFont"): pdf.Name(fo.Family), - }) - } - - fontDict[pdf.Name(fo.ResName)] = fontRef + fontDict[pdf.Name(fo.ResName)] = reg.ref(m, fo) } resources[pdf.Name("Font")] = fontDict } diff --git a/document/render/overlay_test.go b/document/render/overlay_test.go index 23aaea7..03f60f4 100644 --- a/document/render/overlay_test.go +++ b/document/render/overlay_test.go @@ -11,6 +11,7 @@ import ( "github.com/gpdf-dev/gpdf/document" "github.com/gpdf-dev/gpdf/document/layout" "github.com/gpdf-dev/gpdf/pdf" + "github.com/gpdf-dev/gpdf/pdf/font" ) // --------------------------------------------------------------------------- @@ -835,14 +836,16 @@ func TestWriteOverlayToModifier_StandardFont(t *testing.T) { } func TestWriteOverlayToModifier_TrueTypeFont(t *testing.T) { + ttf, rawData := buildArabicTestFont(t) m := newTestModifier(t) result := &OverlayResult{ - Content: []byte("BT /OvF1 12 Tf <0048> Tj ET"), + Content: []byte("BT /OvF1 12 Tf <0001> Tj ET"), FontObjects: map[string]fontObject{ "NotoSansJP": { ResName: "OvF1", Family: "NotoSansJP", - Data: []byte{0x00, 0x01, 0x02, 0x03}, // dummy font data + Data: rawData, + TTF: ttf, }, }, ImageObjects: map[string]imageObject{}, @@ -973,3 +976,185 @@ func TestWriteOverlayToModifier_FontsAndImages(t *testing.T) { t.Error("should have XObject in resources") } } + +// --------------------------------------------------------------------------- +// Embedded TrueType fonts in overlays (issue #37) +// --------------------------------------------------------------------------- + +// buildArabicTestFont builds an in-memory TrueType font covering a few +// non-ASCII runes, so overlay encoding can be tested without a font file. +func buildArabicTestFont(t *testing.T) (*font.TrueTypeFont, []byte) { + t.Helper() + runeMap := map[rune]uint16{ + 'ا': 1, + 'ب': 2, + 'ت': 3, + ' ': 4, + } + widths := []uint16{0, 300, 500, 500, 250} + data := buildTestTTFData(5, widths, runeMap) + + ttf, err := font.ParseTrueType(data) + if err != nil { + t.Fatalf("ParseTrueType: %v", err) + } + return ttf, data +} + +func overlayTextNodes(text string, family string) []layout.PlacedNode { + style := document.DefaultStyle() + style.FontSize = 12 + style.FontFamily = family + return []layout.PlacedNode{ + { + Node: &document.Text{Content: text, TextStyle: style}, + Position: document.Point{X: 72, Y: 100}, + Size: document.Size{Width: 200, Height: 14.4}, + }, + } +} + +// Overlay text in a registered TrueType font must be written as Identity-H +// glyph IDs. Emitting raw UTF-8 bytes made every non-ASCII character render +// as "?" (issue #37). +func TestOverlayRenderText_EmbeddedTTFEncodesGlyphIDs(t *testing.T) { + ttf, rawData := buildArabicTestFont(t) + r := NewOverlayRenderer(595, 842, + map[string]*font.TrueTypeFont{"NotoNaskhArabic": ttf}, + map[string][]byte{"NotoNaskhArabic": rawData}, + ) + + result, err := r.RenderOverlay(overlayTextNodes("اب", "NotoNaskhArabic")) + if err != nil { + t.Fatalf("RenderOverlay: %v", err) + } + + content := string(result.Content) + if !strings.Contains(content, "<00010002> Tj") { + t.Errorf("expected Identity-H glyph IDs, got:\n%s", content) + } + if strings.Contains(content, "(") { + t.Errorf("embedded TTF text must not be emitted as a literal string:\n%s", content) + } + + fo, ok := result.FontObjects["NotoNaskhArabic"] + if !ok { + t.Fatal("font object should be recorded for the registered family") + } + if fo.TTF != ttf { + t.Error("font object should carry the parsed TrueType font") + } +} + +// A bold/italic variant with no dedicated face registered must still use the +// base family's embedded font rather than falling back to Helvetica. +func TestOverlayRenderText_VariantFallsBackToBaseFamily(t *testing.T) { + ttf, rawData := buildArabicTestFont(t) + r := NewOverlayRenderer(595, 842, + map[string]*font.TrueTypeFont{"NotoNaskhArabic": ttf}, + map[string][]byte{"NotoNaskhArabic": rawData}, + ) + + nodes := overlayTextNodes("اب", "NotoNaskhArabic") + textNode := nodes[0].Node.(*document.Text) + textNode.TextStyle.FontWeight = document.WeightBold + + result, err := r.RenderOverlay(nodes) + if err != nil { + t.Fatalf("RenderOverlay: %v", err) + } + if !strings.Contains(string(result.Content), "<00010002> Tj") { + t.Errorf("bold variant should reuse the base family font:\n%s", result.Content) + } + if _, ok := result.FontObjects["NotoNaskhArabic"]; !ok { + t.Error("font object should be keyed by the base family") + } +} + +func TestWriteOverlayToModifier_EmbedsType0Font(t *testing.T) { + ttf, rawData := buildArabicTestFont(t) + r := NewOverlayRenderer(595, 842, + map[string]*font.TrueTypeFont{"NotoNaskhArabic": ttf}, + map[string][]byte{"NotoNaskhArabic": rawData}, + ) + result, err := r.RenderOverlay(overlayTextNodes("اب", "NotoNaskhArabic")) + if err != nil { + t.Fatalf("RenderOverlay: %v", err) + } + + m := newTestModifier(t) + if _, _, err := WriteOverlayToModifier(result, m); err != nil { + t.Fatalf("WriteOverlayToModifier: %v", err) + } + + out, err := m.Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + for _, want := range []string{"/Type0", "/Identity-H", "/CIDFontType2", "/FontFile2", "/ToUnicode"} { + if !bytes.Contains(out, []byte(want)) { + t.Errorf("output PDF should contain %s", want) + } + } +} + +// A font used by several pages must be allocated and embedded exactly once. +func TestOverlayFontRegistry_SharesFontAcrossPages(t *testing.T) { + ttf, rawData := buildArabicTestFont(t) + fonts := map[string]*font.TrueTypeFont{"NotoNaskhArabic": ttf} + fontData := map[string][]byte{"NotoNaskhArabic": rawData} + + m := newTestModifier(t) + reg := NewOverlayFontRegistry() + + var refs []pdf.ObjectRef + for _, text := range []string{"اب", "تا"} { + r := NewOverlayRenderer(595, 842, fonts, fontData) + result, err := r.RenderOverlay(overlayTextNodes(text, "NotoNaskhArabic")) + if err != nil { + t.Fatalf("RenderOverlay: %v", err) + } + _, res, err := WriteOverlayToModifierWithFonts(result, m, reg) + if err != nil { + t.Fatalf("WriteOverlayToModifierWithFonts: %v", err) + } + fontDict, ok := (*res)[pdf.Name("Font")].(pdf.Dict) + if !ok { + t.Fatal("resources should contain a Font dict") + } + for _, v := range fontDict { + ref, ok := v.(pdf.ObjectRef) + if !ok { + t.Fatalf("font resource should be an object ref, got %T", v) + } + refs = append(refs, ref) + } + } + + if len(refs) != 2 || refs[0] != refs[1] { + t.Errorf("both pages should reference the same font object, got %v", refs) + } + + if err := reg.Flush(m); err != nil { + t.Fatalf("Flush: %v", err) + } + out, err := m.Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + if n := bytes.Count(out, []byte("/FontFile2")); n != 1 { + t.Errorf("font should be embedded once, found %d FontFile2 entries", n) + } + + // Flush is idempotent: a second call must not re-embed the font. + if err := reg.Flush(m); err != nil { + t.Fatalf("second Flush: %v", err) + } + out2, err := m.Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + if n := bytes.Count(out2, []byte("/FontFile2")); n != 1 { + t.Errorf("second Flush should not re-embed the font, found %d entries", n) + } +} diff --git a/document/render/pdftarget.go b/document/render/pdftarget.go index e987e8e..440a55a 100644 --- a/document/render/pdftarget.go +++ b/document/render/pdftarget.go @@ -635,7 +635,7 @@ func (r *PDFRenderer) ensureFont(family string) (string, error) { // after all text has been encoded (so we know which glyphs to subset). rawData := r.ttFontData[family] r.writer.OnBeforeClose(func(pw *pdf.Writer) error { - return r.writeType0Font(pw, family, ref, ttf, rawData) + return writeType0Font(pw, family, ref, ttf, rawData) }) return resName, nil } @@ -651,22 +651,34 @@ func (r *PDFRenderer) ensureFont(family string) (string, error) { return resName, nil } +// objectSink is the subset of the PDF object-writing API shared by +// [pdf.Writer] (new documents) and [pdf.Modifier] (incremental updates to an +// existing document). It lets the composite-font writer below serve both the +// normal render path and the overlay path. +type objectSink interface { + AllocObject() pdf.ObjectRef + WriteObject(ref pdf.ObjectRef, obj pdf.Object) error +} + // writeType0Font writes the complete Type0 composite font structure required -// for CJK and other non-WinAnsi text. The structure is: +// for CJK, Arabic and other non-WinAnsi text. The structure is: // // Type0 Font → DescendantFonts → CIDFont (CIDFontType2) // ├── FontDescriptor → FontFile2 (subsetted TTF) // ├── DW (default width) // └── W (per-glyph widths) // → ToUnicode CMap stream -func (r *PDFRenderer) writeType0Font(pw *pdf.Writer, family string, fontRef pdf.ObjectRef, ttf *font.TrueTypeFont, rawData []byte) error { +// +// It must be called after all text using the font has been encoded, so that +// the subset covers every glyph actually referenced. +func writeType0Font(sink objectSink, family string, fontRef pdf.ObjectRef, ttf *font.TrueTypeFont, rawData []byte) error { metrics := ttf.Metrics() // Subset the font to include only used glyphs. - subsetData := r.subsetFontData(ttf, rawData) + subsetData := subsetFontData(ttf, rawData) // Write FontFile2 (embedded font stream). - fontFileRef, err := writeCompressedStream(pw, subsetData, pdf.Dict{ + fontFileRef, err := writeCompressedStream(sink, subsetData, pdf.Dict{ pdf.Name("Length1"): pdf.Integer(len(subsetData)), }) if err != nil { @@ -674,7 +686,7 @@ func (r *PDFRenderer) writeType0Font(pw *pdf.Writer, family string, fontRef pdf. } // Write FontDescriptor. - descRef, err := r.writeFontDescriptor(pw, family, metrics, fontFileRef) + descRef, err := writeFontDescriptor(sink, family, metrics, fontFileRef) if err != nil { return err } @@ -688,17 +700,17 @@ func (r *PDFRenderer) writeType0Font(pw *pdf.Writer, family string, fontRef pdf. dw = spaceW * 1000 / metrics.UnitsPerEm } - cidFontRef, err := r.writeCIDFont(pw, family, descRef, dw, wArray) + cidFontRef, err := writeCIDFont(sink, family, descRef, dw, wArray) if err != nil { return err } - toUnicodeRef, err := writeToUnicodeCMap(pw, runeToGID) + toUnicodeRef, err := writeToUnicodeCMap(sink, runeToGID) if err != nil { return err } - return pw.WriteObject(fontRef, pdf.Dict{ + return sink.WriteObject(fontRef, pdf.Dict{ pdf.Name("Type"): pdf.Name("Font"), pdf.Name("Subtype"): pdf.Name("Type0"), pdf.Name("BaseFont"): pdf.Name(family), @@ -708,7 +720,7 @@ func (r *PDFRenderer) writeType0Font(pw *pdf.Writer, family string, fontRef pdf. }) } -func (r *PDFRenderer) subsetFontData(ttf *font.TrueTypeFont, rawData []byte) []byte { +func subsetFontData(ttf *font.TrueTypeFont, rawData []byte) []byte { usedRunes := ttf.UsedRunes() runes := make([]rune, 0, len(usedRunes)) for r := range usedRunes { @@ -722,20 +734,20 @@ func (r *PDFRenderer) subsetFontData(ttf *font.TrueTypeFont, rawData []byte) []b return subsetData } -func writeCompressedStream(pw *pdf.Writer, data []byte, extraDict pdf.Dict) (pdf.ObjectRef, error) { - ref := pw.AllocObject() +func writeCompressedStream(sink objectSink, data []byte, extraDict pdf.Dict) (pdf.ObjectRef, error) { + ref := sink.AllocObject() dict := extraDict content := data if compressed, err := pdf.CompressFlate(data); err == nil { dict[pdf.Name("Filter")] = pdf.Name("FlateDecode") content = compressed } - return ref, pw.WriteObject(ref, pdf.Stream{Dict: dict, Content: content}) + return ref, sink.WriteObject(ref, pdf.Stream{Dict: dict, Content: content}) } -func (r *PDFRenderer) writeFontDescriptor(pw *pdf.Writer, family string, metrics font.Metrics, fontFileRef pdf.ObjectRef) (pdf.ObjectRef, error) { - descRef := pw.AllocObject() - return descRef, pw.WriteObject(descRef, pdf.Dict{ +func writeFontDescriptor(sink objectSink, family string, metrics font.Metrics, fontFileRef pdf.ObjectRef) (pdf.ObjectRef, error) { + descRef := sink.AllocObject() + return descRef, sink.WriteObject(descRef, pdf.Dict{ pdf.Name("Type"): pdf.Name("FontDescriptor"), pdf.Name("FontName"): pdf.Name(family), pdf.Name("Flags"): pdf.Integer(4), // Symbolic @@ -781,8 +793,8 @@ func buildGlyphWidthArray(ttf *font.TrueTypeFont, runeToGID map[rune]uint16, uni return wArray } -func (r *PDFRenderer) writeCIDFont(pw *pdf.Writer, family string, descRef pdf.ObjectRef, dw int, wArray pdf.Array) (pdf.ObjectRef, error) { - cidFontRef := pw.AllocObject() +func writeCIDFont(sink objectSink, family string, descRef pdf.ObjectRef, dw int, wArray pdf.Array) (pdf.ObjectRef, error) { + cidFontRef := sink.AllocObject() cidFontDict := pdf.Dict{ pdf.Name("Type"): pdf.Name("Font"), pdf.Name("Subtype"): pdf.Name("CIDFontType2"), @@ -799,12 +811,12 @@ func (r *PDFRenderer) writeCIDFont(pw *pdf.Writer, family string, descRef pdf.Ob if len(wArray) > 0 { cidFontDict[pdf.Name("W")] = wArray } - return cidFontRef, pw.WriteObject(cidFontRef, cidFontDict) + return cidFontRef, sink.WriteObject(cidFontRef, cidFontDict) } -func writeToUnicodeCMap(pw *pdf.Writer, runeToGID map[rune]uint16) (pdf.ObjectRef, error) { +func writeToUnicodeCMap(sink objectSink, runeToGID map[rune]uint16) (pdf.ObjectRef, error) { data := font.GenerateToUnicodeCMap(runeToGID) - return writeCompressedStream(pw, data, pdf.Dict{}) + return writeCompressedStream(sink, data, pdf.Dict{}) } // ensureImage ensures an image is registered and returns its resource name. diff --git a/document/render/pdftarget_extra_test.go b/document/render/pdftarget_extra_test.go index 3fbae7a..a061c47 100644 --- a/document/render/pdftarget_extra_test.go +++ b/document/render/pdftarget_extra_test.go @@ -532,7 +532,7 @@ func TestSubsetFontData(t *testing.T) { // Encode a few characters to populate usedRunes. ttf.Encode("ABC") - subsetData := r.subsetFontData(ttf, rawData) + subsetData := subsetFontData(ttf, rawData) if len(subsetData) == 0 { t.Error("subset data should not be empty") } @@ -556,9 +556,8 @@ func TestSubsetFontData_NoUsedRunes(t *testing.T) { t.Fatal(err) } - r, _ := newTestRenderer(t) // No characters encoded, usedRunes is empty. - subsetData := r.subsetFontData(ttf, rawData) + subsetData := subsetFontData(ttf, rawData) if subsetData == nil { t.Error("subset data should not be nil") } @@ -584,11 +583,10 @@ func TestWriteFontDescriptor(t *testing.T) { var buf bytes.Buffer w := pdf.NewWriter(&buf) - r := NewPDFRenderer(w) metrics := ttf.Metrics() fontFileRef := w.AllocObject() - descRef, err := r.writeFontDescriptor(w, "TestFont", metrics, fontFileRef) + descRef, err := writeFontDescriptor(w, "TestFont", metrics, fontFileRef) if err != nil { t.Fatalf("writeFontDescriptor error: %v", err) } @@ -604,12 +602,11 @@ func TestWriteFontDescriptor(t *testing.T) { func TestWriteCIDFont(t *testing.T) { var buf bytes.Buffer w := pdf.NewWriter(&buf) - r := NewPDFRenderer(w) descRef := w.AllocObject() wArray := pdf.Array{pdf.Integer(1), pdf.Array{pdf.Integer(500)}} - cidRef, err := r.writeCIDFont(w, "TestFont", descRef, 1000, wArray) + cidRef, err := writeCIDFont(w, "TestFont", descRef, 1000, wArray) if err != nil { t.Fatalf("writeCIDFont error: %v", err) } @@ -621,11 +618,10 @@ func TestWriteCIDFont(t *testing.T) { func TestWriteCIDFont_EmptyWidths(t *testing.T) { var buf bytes.Buffer w := pdf.NewWriter(&buf) - r := NewPDFRenderer(w) descRef := w.AllocObject() - cidRef, err := r.writeCIDFont(w, "TestFont", descRef, 1000, nil) + cidRef, err := writeCIDFont(w, "TestFont", descRef, 1000, nil) if err != nil { t.Fatalf("writeCIDFont error: %v", err) } diff --git a/document/render/ttfont_test.go b/document/render/ttfont_test.go index 0e36104..495e0b8 100644 --- a/document/render/ttfont_test.go +++ b/document/render/ttfont_test.go @@ -525,7 +525,7 @@ func TestSubsetFontData_InMemory(t *testing.T) { // Encode characters to populate usedRunes. ttf.Encode("ABC") - subsetData := r.subsetFontData(ttf, rawData) + subsetData := subsetFontData(ttf, rawData) if len(subsetData) == 0 { t.Error("subset data should not be empty") } @@ -533,9 +533,8 @@ func TestSubsetFontData_InMemory(t *testing.T) { func TestSubsetFontData_InMemory_NoUsedRunes(t *testing.T) { ttf, rawData := buildAndParseTTF(t) - r, _ := newTestRenderer(t) // No characters encoded. - subsetData := r.subsetFontData(ttf, rawData) + subsetData := subsetFontData(ttf, rawData) if subsetData == nil { t.Error("subset data should not be nil (fallback to raw data)") } @@ -600,11 +599,10 @@ func TestWriteFontDescriptor_InMemory(t *testing.T) { var buf bytes.Buffer w := pdf.NewWriter(&buf) - r := NewPDFRenderer(w) metrics := ttf.Metrics() fontFileRef := w.AllocObject() - descRef, err := r.writeFontDescriptor(w, "TestFont", metrics, fontFileRef) + descRef, err := writeFontDescriptor(w, "TestFont", metrics, fontFileRef) if err != nil { t.Fatalf("writeFontDescriptor error: %v", err) } diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index b01e6bb..7f695fb 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -3,4 +3,4 @@ package buildinfo // Version is the library version. It is the single source of truth used by // the public gpdf.Version constant and the default PDF Producer metadata. -const Version = "1.0.12" +const Version = "1.0.13" diff --git a/signature/sign_test.go b/signature/sign_test.go index c9c1ee7..c77b37c 100644 --- a/signature/sign_test.go +++ b/signature/sign_test.go @@ -2,6 +2,7 @@ package signature import ( "bytes" + "encoding/hex" "strings" "testing" "time" @@ -231,3 +232,59 @@ func TestGenerateTestECCertificate(t *testing.T) { t.Error("private key is nil") } } + +// The /Contents placeholder is fixed-width and zero-padded, but the padding +// cannot be stripped textually: a CMS blob whose own DER ends in 0x00 loses +// that byte and fails to parse as "data truncated". ECDSA hits this roughly +// once every 256 signatures because its DER length and final byte vary per +// signature, which made TestSign_WithTimestamp_ECDSA flaky in CI. +func TestExtractContentsHex_PayloadEndingInZeroByte(t *testing.T) { + // A DER SEQUENCE holding one OCTET STRING that ends in 0x00. + der := []byte{0x30, 0x06, 0x04, 0x04, 0xDE, 0xAD, 0xBE, 0x00} + + hexStr := strings.ToUpper(hex.EncodeToString(der)) + padded := hexStr + strings.Repeat("0", 40) // placeholder zero padding + pdfLike := "/Contents <" + padded + ">" + + got, err := extractContentsHex(pdfLike) + if err != nil { + t.Fatalf("extractContentsHex: %v", err) + } + if !bytes.Equal(got, der) { + t.Errorf("got % X, want % X", got, der) + } +} + +func TestExtractContentsHex_LongFormLength(t *testing.T) { + // Long-form length: SEQUENCE with a 200-byte OCTET STRING payload of zeros. + payload := make([]byte, 200) + der := append([]byte{0x30, 0x81, 0xCA, 0x04, 0x81, 0xC7}, payload[:199]...) + + padded := strings.ToUpper(hex.EncodeToString(der)) + strings.Repeat("0", 64) + got, err := extractContentsHex("/Contents <" + padded + ">") + if err != nil { + t.Fatalf("extractContentsHex: %v", err) + } + if len(got) != len(der) { + t.Errorf("got %d bytes, want %d", len(got), len(der)) + } +} + +func TestExtractContentsHex_Malformed(t *testing.T) { + tests := []struct { + name string + contents string + }{ + {"all padding", strings.Repeat("0", 32)}, + {"too short", "30"}, + {"length exceeds contents", "3082FFFF" + strings.Repeat("0", 16)}, + {"indefinite length", "3080" + strings.Repeat("0", 16)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := extractContentsHex("/Contents <" + tt.contents + ">"); err == nil { + t.Error("expected an error") + } + }) + } +} diff --git a/signature/verify.go b/signature/verify.go index 1bf60ca..844402c 100644 --- a/signature/verify.go +++ b/signature/verify.go @@ -12,7 +12,6 @@ import ( "fmt" "regexp" "strconv" - "strings" ) // SignatureInfo holds parsed signature information from a signed PDF. @@ -348,11 +347,68 @@ func extractContentsHex(s string) ([]byte, error) { if m == nil { return nil, fmt.Errorf("contents hex string not found") } - hexStr := strings.TrimRight(m[1], "0") // remove trailing zero padding + hexStr := m[1] if len(hexStr)%2 != 0 { hexStr += "0" } - return hex.DecodeString(hexStr) + raw, err := hex.DecodeString(hexStr) + if err != nil { + return nil, err + } + // The /Contents placeholder is fixed-width and zero-padded on the right. + // Trimming those zeros textually would also eat the CMS blob's own trailing + // zero bytes — a DER structure ending in 0x00 came back one byte short and + // failed to parse as "data truncated". Read the outer DER length instead so + // exactly the signature is returned, whatever it ends with. + return trimDERPadding(raw) +} + +// derSequenceTag is the ASN.1 universal tag for a constructed SEQUENCE. +const derSequenceTag = 0x30 + +// trimDERPadding returns the leading DER object in data, discarding whatever +// follows it. It reads the tag-length header of the outer structure rather +// than looking at the content, so trailing zero padding is dropped without +// depending on the payload's own bytes. +func trimDERPadding(data []byte) ([]byte, error) { + if len(data) < 2 { + return nil, fmt.Errorf("contents too short for a DER object: %d bytes", len(data)) + } + // CMS ContentInfo is always a SEQUENCE. Checking the tag also rejects an + // untouched all-zero placeholder with a clear message instead of handing + // two zero bytes to the ASN.1 parser. + if data[0] != derSequenceTag { + return nil, fmt.Errorf("contents do not start with a DER SEQUENCE (tag 0x%02X)", data[0]) + } + + lengthByte := data[1] + if lengthByte < 0x80 { + // Short form: the length byte is the content length. + return sliceDER(data, 2, int(lengthByte)) + } + + numLenBytes := int(lengthByte & 0x7F) + if numLenBytes == 0 { + // Indefinite length is forbidden in DER. + return nil, fmt.Errorf("contents use indefinite DER length") + } + if numLenBytes > 4 || len(data) < 2+numLenBytes { + return nil, fmt.Errorf("invalid DER length header in contents") + } + + contentLen := 0 + for _, b := range data[2 : 2+numLenBytes] { + contentLen = contentLen<<8 | int(b) + } + return sliceDER(data, 2+numLenBytes, contentLen) +} + +func sliceDER(data []byte, headerLen, contentLen int) ([]byte, error) { + total := headerLen + contentLen + if contentLen < 0 || total > len(data) { + return nil, fmt.Errorf("DER object of %d bytes exceeds contents of %d bytes", total, len(data)) + } + return data[:total], nil } func constantTimeEqual(a, b []byte) bool { diff --git a/template/overlay.go b/template/overlay.go index 89b2e24..c432edd 100644 --- a/template/overlay.go +++ b/template/overlay.go @@ -16,6 +16,7 @@ type ExistingDocument struct { modifier *pdf.Modifier fonts map[string]*font.TrueTypeFont fontDataMap map[string][]byte + fontReg *render.OverlayFontRegistry config Config } @@ -41,6 +42,7 @@ func OpenExisting(data []byte, opts ...Option) (*ExistingDocument, error) { modifier: pdf.NewModifier(reader), fonts: make(map[string]*font.TrueTypeFont), fontDataMap: make(map[string][]byte), + fontReg: render.NewOverlayFontRegistry(), config: cfg, } @@ -108,7 +110,7 @@ func (d *ExistingDocument) Overlay(pageIndex int, fn func(p *PageBuilder)) error } // Write overlay resources to modifier and get content + resource dict. - content, resources, err := render.WriteOverlayToModifier(result, d.modifier) + content, resources, err := render.WriteOverlayToModifierWithFonts(result, d.modifier, d.fontReg) if err != nil { return fmt.Errorf("gpdf: write overlay: %w", err) } @@ -141,7 +143,12 @@ func (d *ExistingDocument) FlattenForms() error { return d.modifier.FlattenForms() } -// Save generates the modified PDF as a byte slice. +// Save generates the modified PDF as a byte slice. Fonts used by overlays are +// embedded here rather than per page, so a font referenced from many pages is +// subsetted once over every glyph the document actually uses. func (d *ExistingDocument) Save() ([]byte, error) { + if err := d.fontReg.Flush(d.modifier); err != nil { + return nil, fmt.Errorf("gpdf: embed overlay fonts: %w", err) + } return d.modifier.Bytes() } diff --git a/template/overlay_test.go b/template/overlay_test.go index 0029e8e..4ab5565 100644 --- a/template/overlay_test.go +++ b/template/overlay_test.go @@ -2,6 +2,8 @@ package template import ( "bytes" + "os" + "path/filepath" "testing" "github.com/gpdf-dev/gpdf/document" @@ -276,3 +278,48 @@ func TestSave_NoModifications(t *testing.T) { t.Error("no-modification save should produce identical output") } } + +// Overlay text drawn in a font registered via WithFont must be embedded as a +// Type0/Identity-H composite font. Before the fix the overlay renderer emitted +// raw UTF-8 bytes against a bare /TrueType dict, so viewers showed "?" for +// every non-ASCII character (issue #37). +func TestOverlay_EmbeddedFontRendersNonASCII(t *testing.T) { + // Same Noto fixture as the CJK example tests; skip where it is absent. + path := filepath.Join("..", "..", "NotoSansJP-Regular.ttf") + fontData, err := os.ReadFile(path) + if err != nil { + t.Skipf("font fixture not found: %s", path) + } + + doc, err := OpenExisting(generateTestPDF(t, 2), WithFont("NotoSansJP", fontData)) + if err != nil { + t.Fatalf("OpenExisting: %v", err) + } + + for _, text := range []string{"日本語のテキスト", "ページ二枚目"} { + if err := doc.Overlay(0, func(p *PageBuilder) { + p.AutoRow(func(r *RowBuilder) { + r.Col(12, func(c *ColBuilder) { + c.Text(text, FontFamily("NotoSansJP"), FontSize(18)) + }) + }) + }); err != nil { + t.Fatalf("Overlay: %v", err) + } + } + + out, err := doc.Save() + if err != nil { + t.Fatalf("Save: %v", err) + } + + for _, want := range []string{"/Type0", "/Identity-H", "/FontFile2"} { + if !bytes.Contains(out, []byte(want)) { + t.Errorf("output PDF should contain %s", want) + } + } + // The font is shared by both overlays, so it must be embedded only once. + if n := bytes.Count(out, []byte("/FontFile2")); n != 1 { + t.Errorf("font should be embedded once, found %d FontFile2 entries", n) + } +}