From 726d1be414718fb9c59c6fd50111ee77c249eacd Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 3 Aug 2026 13:11:41 +0200 Subject: [PATCH] Emit selectable Type0 text for TrueType-font widgets in AddWidgetVector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vectorPainter renders bitmap-font widget text as selectable PDF text via the painter Text primitive, but a widget tree whose active toolkit font is a TrueType/OpenType face (SetFont(NewTrueTypeFont(...))) had no way to reach it — the run either rasterised or fell back to the wrong font at the wrong size. Implement the new painter.FacePainter seam on vectorPainter: TextFace embeds the FACE's own sfnt bytes (so glyph shapes AND advances match the on-screen layout) and emits the run as real Type0 text at the face's pixel size, dropping to the baseline by the face ascent. Embedded faces are memoised per tree so each is subset once; a face whose bytes do not parse degrades to the plain-Text fallback font rather than vanishing. An rsc.io/pdf round-trip proves a TrueType-font widget label ("Hi") is now a Type0 / CIDFontType2 embedded face shown with Tj — with its glyph codes present and the /ToUnicode CMap mapping them back to "Hi" (real selectable text) — and that no image XObject is placed (not rasterised). Bumps go-widgets/toolkit to v0.79.0 and go-widgets/painter to v0.2.0 for the seam. 100% coverage retained. Co-Authored-By: Claude Opus 4.8 --- go.mod | 5 +- go.sum | 8 +-- widget.go | 56 +++++++++++++++++++- widget_test.go | 136 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index d127c90..5ae967e 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,13 @@ go 1.26.4 require github.com/go-opentype/opentype v0.5.0 require ( - github.com/go-widgets/painter v0.1.3 - github.com/go-widgets/toolkit v0.76.0 + github.com/go-widgets/painter v0.2.0 + github.com/go-widgets/toolkit v0.79.0 rsc.io/pdf v0.1.1 ) require ( github.com/go-opentype/bidi v0.2.0 // indirect + github.com/go-opentype/fonts v0.4.1 // indirect github.com/go-opentype/shape v0.3.2 // indirect ) diff --git a/go.sum b/go.sum index bf9d8ad..1d847b8 100644 --- a/go.sum +++ b/go.sum @@ -6,9 +6,9 @@ github.com/go-opentype/opentype v0.5.0 h1:++VoqgbXgYACyTTNzUAivoxW9M3m2gxWMAn6bI github.com/go-opentype/opentype v0.5.0/go.mod h1:AOixevJf7XQaH7+WG+OMIOZEbYPXfMqklVk26Y6YTUU= github.com/go-opentype/shape v0.3.2 h1:qaaUhg9m6yusm6MCfa07DhZ5Z0ZxOmGwLxHklVeLUUA= github.com/go-opentype/shape v0.3.2/go.mod h1:oVq3kFGx6kIHKrFXfxqnp8JUYR7k9NzeY+E17LYHhrU= -github.com/go-widgets/painter v0.1.3 h1:50oeDH76vMUlxQ8117wMFf5MGuAJlq5n2JeVS+QlpnM= -github.com/go-widgets/painter v0.1.3/go.mod h1:ccmlkH2UmcXQh6rt9Fu2eDt2RI0B5V0POaGmUKeW0EQ= -github.com/go-widgets/toolkit v0.76.0 h1:A9eJHmTPZpJeQJ+7e22NxLm9Hqa/JiQ4pwBWub0Fq2c= -github.com/go-widgets/toolkit v0.76.0/go.mod h1:AZJWQh4rQrFqwEuPjluV9eGnLqA+XnfrxCcvTs5C+LU= +github.com/go-widgets/painter v0.2.0 h1:C7anwlKLYnlTeCK90xKYvy0srxYXqhuvyDFismlf2Sc= +github.com/go-widgets/painter v0.2.0/go.mod h1:ccmlkH2UmcXQh6rt9Fu2eDt2RI0B5V0POaGmUKeW0EQ= +github.com/go-widgets/toolkit v0.79.0 h1:lZeQPeUYcGvD7++F8bFUN8in68nO98m0qpU3Nz+C0ws= +github.com/go-widgets/toolkit v0.79.0/go.mod h1:uBYcJJeBA2hqHiTlKaLsPZ9t/NsBA6DFtpp33i5l5/8= rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/widget.go b/widget.go index 2cabae8..434c78c 100644 --- a/widget.go +++ b/widget.go @@ -168,11 +168,65 @@ type vectorPainter struct { rh float64 // target rect height, in points sx, sy float64 // points per painter pixel (x and y) w, h int // canvas size, in painter pixels - font *Font // font used for text-show operators + font *Font // fallback font for the plain Text primitive (bitmap-font runs) + + // faceFonts memoises the *Font embedded for each painter.Face drawn through + // TextFace, so a TrueType-font widget tree embeds each face once and re-uses + // it. A face whose bytes fail to parse is cached as a nil entry so the parse + // is not retried on every run. + faceFonts map[painter.Face]*Font clipDepth int // balanced q/Q depth pushed by PushClip } +// faceFont returns the embedded *Font for face, loading (and memoising) it from +// the face's own sfnt bytes on first use. It returns nil when those bytes do not +// parse, so the caller can fall back to the painter's plain-Text font. +func (v *vectorPainter) faceFont(face painter.Face) *Font { + if f, ok := v.faceFonts[face]; ok { + return f + } + if v.faceFonts == nil { + v.faceFonts = map[painter.Face]*Font{} + } + f, err := LoadFont(face.FontData()) + if err != nil { + v.faceFonts[face] = nil + return nil + } + v.faceFonts[face] = f + return f +} + +// TextFace draws s as selectable PDF text in face — the painter.FacePainter +// seam a TrueType/OpenType toolkit font hands its run to. The face's own sfnt +// bytes are embedded (so glyph shapes AND advances match the on-screen layout) +// and the run is emitted at the face's pixel size, so a TrueType-font widget +// label becomes real, selectable Type0 text rather than a rasterised image. +// +// The painter positions text by its top-left corner and PDF by the baseline, so +// the origin drops by the face ascent. When the face bytes do not parse, it +// degrades to the plain Text primitive (the fallback font) so a broken face +// still yields some selectable text rather than nothing. +func (v *vectorPainter) TextFace(x, y int, s string, face painter.Face, ink painter.RGBA) { + if ink.A == 0 || s == "" { + return + } + f := v.faceFont(face) + if f == nil { + v.Text(x, y, s, ink) + return + } + v.page.Save() + v.applyAlpha(ink) + v.page.SetFillColor(RGB8(ink.R, ink.G, ink.B)) + v.page.SetFont(f, float64(face.SizePx())*v.sy) + baseline := v.fy(y + face.Ascent()) + // The font was just set, so Text cannot return the no-font error. + _ = v.page.Text(v.fx(x), baseline, s) + v.page.Restore() +} + // fx maps a painter x (pixels) to a PDF x (points). func (v *vectorPainter) fx(x int) float64 { return v.rx + float64(x)*v.sx } diff --git a/widget_test.go b/widget_test.go index aa68f73..647403f 100644 --- a/widget_test.go +++ b/widget_test.go @@ -7,6 +7,7 @@ package pdfkit import ( "bytes" "errors" + "strings" "testing" "github.com/go-widgets/painter" @@ -136,6 +137,141 @@ func TestAddWidgetVector(t *testing.T) { } } +// TestAddWidgetVectorTrueTypeSelectable is the end-to-end proof of the +// FacePainter seam: when the toolkit's active font is a real TrueType face +// (NewTrueTypeFont), a widget's label renders through AddWidgetVector as REAL, +// SELECTABLE Type0 text embedded in that face — not a rasterised image. It +// reuses the synthetic TrueType font for both the toolkit face and the fallback +// pdfkit Font, then reparses the output with the independent rsc.io/pdf reader. +func TestAddWidgetVectorTrueTypeSelectable(t *testing.T) { + ttf := synthTTF(defaultSynth()) // glyphs for 'H','i','A' + + // Make a TrueType face the whole toolkit UI's active font. Draw of any text + // now routes through truetypeFont.Draw -> painter.FacePainter.TextFace. + ttFace, err := toolkit.NewTrueTypeFont(ttf, 16) + if err != nil { + t.Fatalf("toolkit.NewTrueTypeFont: %v", err) + } + toolkit.SetFont(ttFace) + defer toolkit.SetFont(nil) // restore the bitmap default for the other tests + + fallback, err := LoadFont(ttf) // required by the AddWidgetVector API + if err != nil { + t.Fatal(err) + } + doc := New(Options{}) + p := doc.AddPage(A4) + opts := &WidgetOptions{Scale: 2, Font: fallback} + // buildScene draws the label "Hi" (and a button labelled "Hi"): both glyphs + // exist in the synthetic face. + if err := p.AddWidgetVector(buildScene(), Rect{X: 40, Y: 400, Width: 240, Height: 140}, opts); err != nil { + t.Fatalf("AddWidgetVector: %v", err) + } + r := reopen(t, doc) + + // The embedded face is a Type0 composite font with a TrueType (CIDFontType2) + // descendant — the signature of real, selectable text, not a bitmap. + fd := firstFontDict(r) + if got := fd.Key("Subtype").Name(); got != "Type0" { + t.Fatalf("font Subtype = %q, want Type0 (selectable text, not an image)", got) + } + if got := fd.Key("DescendantFonts").Index(0).Key("Subtype").Name(); got != "CIDFontType2" { + t.Errorf("descendant Subtype = %q, want CIDFontType2 (embedded TrueType)", got) + } + + content := contentBytes(t, r) + // A text-show operator must be present, and the run must NOT have been placed + // as an image XObject (no `Do`): that is the "real text, not a picture" proof. + if !bytes.Contains(content, []byte("Tj")) { + t.Error("vector content stream has no Tj: the label did not become text") + } + if bytes.Contains(content, []byte(" Do\n")) { + t.Error("vector content stream draws an XObject: text was rasterised, not shown as text") + } + if x := r.Page(1).V.Key("Resources").Key("XObject"); x.Kind() != pdf.Null { + t.Error("page has an XObject resource: the vector path should place no image") + } + + // The label glyph codes ('H'=GID 1, 'i'=GID 2 in the synthetic face) must be + // shown, and the /ToUnicode CMap must map them back to "Hi" so a reader can + // copy the real text out. + for _, code := range []string{"0001", "0002"} { + if !bytes.Contains(content, []byte(code)) { + t.Errorf("content stream missing glyph code <%s> for the label", code) + } + } + tu := string(readStream(t, fd.Key("ToUnicode"))) + for code, want := range map[string]string{"<0001> <0048>": "H", "<0002> <0069>": "i"} { + if !strings.Contains(tu, code) { + t.Errorf("ToUnicode missing %q (maps glyph to %q) — text would not be selectable as %q", code, want, want) + } + } +} + +// TestVectorPainterTextFaceFallback covers TextFace's degrade-to-fallback path: +// a face whose sfnt bytes do not parse cannot be embedded, so the run is emitted +// through the plain Text primitive (the painter's fallback font) instead of +// nothing. It also covers the empty-string / transparent-ink early return and +// the face-font memoisation (a second draw of the same face hits the cache). +func TestVectorPainterTextFaceFallback(t *testing.T) { + doc, vp := newTestVectorPainter(t) + ink := painter.RGB(10, 20, 30) + + // Early returns: no text and no ink both no-op. + vp.TextFace(0, 0, "", brokenFace{}, ink) + vp.TextFace(0, 0, "x", brokenFace{}, painter.RGBA{}) + + // A broken face parses to nothing, so TextFace falls back to v.Text (the + // fallback pdfkit font), which still yields selectable text. + vp.TextFace(4, 12, "Hi", brokenFace{}, ink) + // Drawing the same broken face again exercises the memoised nil entry. + vp.TextFace(4, 24, "Hi", brokenFace{}, ink) + + r := reopen(t, doc) + if !bytes.Contains(contentBytes(t, r), []byte("Tj")) { + t.Error("fallback TextFace produced no text-show operator") + } +} + +// brokenFace is a painter.Face whose FontData does not parse as a font, so +// pdfkit cannot embed it and must fall back to the painter's own font. +type brokenFace struct{} + +func (brokenFace) FontData() []byte { return []byte("not a font") } +func (brokenFace) SizePx() int { return 12 } +func (brokenFace) Ascent() int { return 10 } + +// TestVectorPainterTextFaceEmbedsFace drives TextFace with a parseable face +// directly (bypassing the toolkit) and confirms the face itself is embedded as +// a Type0 font and cached across calls. +func TestVectorPainterTextFaceEmbedsFace(t *testing.T) { + doc, vp := newTestVectorPainter(t) + face := &fakeFace{data: synthTTF(defaultSynth()), size: 16, ascent: 13} + ink := painter.RGB(0, 0, 0) + + vp.TextFace(10, 20, "Hi", face, ink) // loads + embeds + vp.TextFace(10, 40, "iH", face, ink) // cache hit (same face pointer) + + if len(vp.faceFonts) != 1 { + t.Fatalf("faceFonts cached %d fonts, want 1 (memoised)", len(vp.faceFonts)) + } + r := reopen(t, doc) + if got := firstFontDict(r).Key("Subtype").Name(); got != "Type0" { + t.Errorf("embedded face Subtype = %q, want Type0", got) + } +} + +// fakeFace is a minimal painter.Face backed by explicit bytes/size/ascent. +type fakeFace struct { + data []byte + size int + ascent int +} + +func (f *fakeFace) FontData() []byte { return f.data } +func (f *fakeFace) SizePx() int { return f.size } +func (f *fakeFace) Ascent() int { return f.ascent } + // TestAddWidgetVectorNoFont covers the missing-font error branch. func TestAddWidgetVectorNoFont(t *testing.T) { doc := New(Options{})