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{})