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
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
56 changes: 55 additions & 1 deletion widget.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
136 changes: 136 additions & 0 deletions widget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package pdfkit
import (
"bytes"
"errors"
"strings"
"testing"

"github.com/go-widgets/painter"
Expand Down Expand Up @@ -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{})
Expand Down
Loading