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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ body:
id: gpdf-version
attributes:
label: gpdf Version
placeholder: "v1.0.11"
placeholder: "v1.0.13"
validations:
required: true

Expand Down
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
48 changes: 48 additions & 0 deletions _validation/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
159 changes: 116 additions & 43 deletions document/render/overlay.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package render

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"

Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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")
}
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
Loading
Loading