From fa4485595cc6a5a153ec044649038f7f26eddc41 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 25 Aug 2026 23:37:37 +0200 Subject: [PATCH] Draw a stand-in for a font the file does not carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PDF need not carry the font its text is set in. Fourteen faces every reader is expected to have of its own, and in practice a file names any font at all and hopes. Of the 95 818 corpus pages that show text, 40 437 name a font the file does not carry and 38 663 carry none whatever. Drawing nothing for those is two pages in five with no text on them. Arimo, Tinos and Cousine are metric-compatible with Helvetica, Times and Courier — the same advances, glyph for glyph — so a stand-in gives the text the width it was meant to have even where the document gives no widths of its own, which one of the standard fourteen never does. For any other name the descriptor's flags say whether it wanted a serif, a sans or a typewriter. Only one weight of each is carried, so a bold is drawn by stroking the outline as well as filling it and an italic by leaning it over — what a typesetter calls a faux bold and a faux italic, and what every reader does with a face it has not got in the weight asked for. Symbol, the dingbats, composite fonts and Type 3 fonts are left alone. A composite font is addressed by glyph number and a stand-in's numbers are its own, so drawing one would put arbitrary letters on the page; and no face here carries Symbol's glyphs. Something is not better than nothing when the something is the wrong alphabet. Measured on the sample by hashing every page's pixels: 334 of 4 057 changed, 333 of them gaining ink. What this cannot do is invent a font's shapes: a page set in a face nobody has is a page set in something else, and it says so nowhere — which is what every reader does, and better than a blank page by the width of the text on it. --- README.md | 11 +- font.go | 26 +++++ fontodd_test.go | 12 +- go.mod | 4 +- go.sum | 10 +- substitute.go | 160 +++++++++++++++++++++++++++ substitute_test.go | 269 +++++++++++++++++++++++++++++++++++++++++++++ text.go | 34 +++++- 8 files changed, 512 insertions(+), 14 deletions(-) create mode 100644 substitute.go create mode 100644 substitute_test.go diff --git a/README.md b/README.md index d240674..7a3aea8 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,15 @@ font addressed by character identifier, or — for a Type 3 font, whose glyphs are little content streams — by running them. A simple font is addressed by the name its encoding gives a code, and falls back on the program's own built-in encoding when the document says nothing. A font -whose program cannot be read still advances the pen by its stated widths, -so what follows the text stays where it belongs. +whose program the file does not carry at all — two pages of the corpus in +five — is drawn in a **stand-in**: Arimo, Tinos and Cousine are +metric-compatible with Helvetica, Times and Courier, so the text comes out +the width it was meant to be even where the document gives no widths of its +own. A bold is made by stroking the outline as well as filling it and an +italic by leaning it over, since only one weight of each stand-in is carried. +Symbol and the dingbats are left alone: no face here has their glyphs, and +something is not better than nothing when the something is the wrong +alphabet. Gradients and patterns: the **axial** and **radial** shadings that carry nearly every gradient anyone draws, painted on their own with `sh` or used diff --git a/font.go b/font.go index 9f43e19..a19a120 100644 --- a/font.go +++ b/font.go @@ -21,6 +21,29 @@ type pdfFont struct { // fontMatrix is a Type 3 font's glyph space, as a transform. fontMatrix geometry.Matrix + + // substituted says the face is a stand-in rather than the font the + // document named, which is what happens when the document carries no + // font at all. + substituted bool + // embolden and slant say the document named a bold or an italic face and + // the stand-in is neither, so one is made of the other. + embolden, slant bool +} + +// advance is how far the pen moves for one code, in text space. +// +// It is what the document says, when the document says anything. A font it +// does not carry may say nothing at all — one of the fourteen standard faces +// is written with no widths, since every reader is expected to know Times and +// Helvetica and Courier by heart — and then the stand-in's own advance is the +// answer, which is why the stand-ins are the three faces metric-compatible +// with those. +func (f *pdfFont) advance(code int) float64 { + if f.HasWidth(code) || !f.substituted || f.face == nil { + return f.Width(code) + } + return float64(f.face.AdvanceIndex(f.glyphIndex(code))) / f.perEm } // glyph is the outline of one code, in text space — a thousandth of the point @@ -91,6 +114,9 @@ func (r *renderer) loadFont(dict reader.Dict) *pdfFont { m := f.FontMatrix() f.fontMatrix = geometry.Matrix{Xx: m[0], Xy: m[1], Yx: m[2], Yy: m[3], X0: m[4], Y0: m[5]} r.attachProgram(f) + if f.program == nil { + r.attachStandIn(f) + } return f } diff --git a/fontodd_test.go b/fontodd_test.go index ac96895..f5e599f 100644 --- a/fontodd_test.go +++ b/fontodd_test.go @@ -141,6 +141,9 @@ func TestWidthsInShapesNobodyShouldWrite(t *testing.T) { } } +// TestFontProgramsThatCannotBeRead checks that a font the document does not +// carry, or carries in a form nothing here can read, is drawn in a stand-in +// rather than left off the page. func TestFontProgramsThatCannotBeRead(t *testing.T) { content := "BT /F 20 Tf 10 20 Td (A) Tj ET" cases := []struct { @@ -182,9 +185,12 @@ func TestFontProgramsThatCannotBeRead(t *testing.T) { } return font }) - // No outlines, so nothing is drawn — but nothing breaks either. - if inked(draw(t, d, Options{})) != 0 { - t.Errorf("%s: something was drawn", c.name) + // A font whose program cannot be read is drawn in a stand-in, which + // is what every reader does: two pages of the corpus in five carry + // no font at all, and drawing nothing for those would leave them + // with no text on them. + if inked(draw(t, d, Options{})) == 0 { + t.Errorf("%s: nothing was drawn", c.name) } } } diff --git a/go.mod b/go.mod index 0091a28..54aa2d4 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,9 @@ go 1.26.4 require ( github.com/go-gfx/gfx v0.10.0 - github.com/go-opentype/fonts v0.8.0 + github.com/go-opentype/fonts v0.9.0 github.com/go-opentype/opentype v0.9.0 github.com/go-pdfkit/reader v0.4.0 ) -require github.com/go-pdfkit/pdffont v0.1.0 // indirect +require github.com/go-pdfkit/pdffont v0.2.0 diff --git a/go.sum b/go.sum index 6fcbc19..5bdda7e 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,10 @@ github.com/go-gfx/gfx v0.10.0 h1:3AqOO8TZph6/U8+ejJxYkCZ+wzddxAbZ7fxi6TvGop4= github.com/go-gfx/gfx v0.10.0/go.mod h1:bFt/MWyYWRU3Ic9IaB8XOC9KLMMHRRmahMk4FaIGK7g= -github.com/go-opentype/fonts v0.8.0 h1:77i3VPIH90GbstzNb21mk+an4WvEOe2idC6W+J0n0fw= -github.com/go-opentype/fonts v0.8.0/go.mod h1:C6yQL2apHItfEZ5hztpsHF0S5mlX/hklLlq/Z5fRG/g= +github.com/go-opentype/fonts v0.9.0 h1:slB6OB3riLyUPrOxqXe0s6/AzdenF1TDvCN8N87hhQk= +github.com/go-opentype/fonts v0.9.0/go.mod h1:C6yQL2apHItfEZ5hztpsHF0S5mlX/hklLlq/Z5fRG/g= github.com/go-opentype/opentype v0.9.0 h1:GFgcJ3nwTDp4NJr5O+Paw7lhZx5Jv/R+noZwvhYDlkM= github.com/go-opentype/opentype v0.9.0/go.mod h1:AOixevJf7XQaH7+WG+OMIOZEbYPXfMqklVk26Y6YTUU= -github.com/go-pdfkit/pdffont v0.1.0 h1:mThWvPn3LLETup2adm9xMcNZWECP2Wq3eTSyTW4O3I0= -github.com/go-pdfkit/pdffont v0.1.0/go.mod h1:y4vo5DgT95e57C3XxIWfA/xss+x6RwZwyj6KWdJc86s= -github.com/go-pdfkit/reader v0.3.0 h1:/r7aCt6jdOdQ7hYrnnMLxc2NuCjPfROEEy73w1O94eM= -github.com/go-pdfkit/reader v0.3.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8= +github.com/go-pdfkit/pdffont v0.2.0 h1:yAp/oR5Z2kkqs4r0GWMalZMC7rc7XSCZXgwIypbpMWM= +github.com/go-pdfkit/pdffont v0.2.0/go.mod h1:y4vo5DgT95e57C3XxIWfA/xss+x6RwZwyj6KWdJc86s= github.com/go-pdfkit/reader v0.4.0 h1:qPbNZSO+Xl+4NBvQoV1PYt7HlqHaEAQ1tUc6/IL8JAU= github.com/go-pdfkit/reader v0.4.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8= diff --git a/substitute.go b/substitute.go new file mode 100644 index 0000000..8d596a9 --- /dev/null +++ b/substitute.go @@ -0,0 +1,160 @@ +package render + +import ( + "strings" + "sync" + + "github.com/go-opentype/fonts/arimo" + "github.com/go-opentype/fonts/cousine" + "github.com/go-opentype/fonts/tinos" + "github.com/go-opentype/opentype" + "github.com/go-pdfkit/pdffont" + "github.com/go-pdfkit/reader" +) + +// A PDF need not carry the font its text is set in. Fourteen faces — four +// weights each of Helvetica, Times and Courier, plus Symbol and +// ZapfDingbats — every reader is expected to have of its own, and in practice +// a file names any font at all and hopes. +// +// Of 95 818 corpus pages that show text, 40 437 name a font the file does not +// carry and 38 663 carry none at all. Drawing nothing for those is not an +// option: it is two pages in five with no text on them. +// +// So a stand-in is drawn. Arimo, Tinos and Cousine are metric-compatible with +// Helvetica, Times and Courier — the same advances, glyph for glyph — so text +// set in one of the standard faces comes out the width it was meant to be even +// where the document gives no widths of its own. For any other name the +// descriptor's own flags say whether it wanted a serif, a sans or a typewriter, +// and one of the three is drawn in its place. +// +// What this cannot do is invent a font's shapes. A page set in a face nobody +// has is a page set in something else, and it says so nowhere: that is what +// every reader does, and it is better than a blank page by the width of the +// text on it. +type substitute struct { + once sync.Once + ttf []byte + font *opentype.Font + err error +} + +// The three stand-ins, each read once and shared. +var ( + sansStandIn = &substitute{ttf: arimo.TTF} + serifStandIn = &substitute{ttf: tinos.TTF} + monoStandIn = &substitute{ttf: cousine.TTF} +) + +// get parses the stand-in, once. +func (s *substitute) get() (*opentype.Font, error) { + s.once.Do(func() { s.font, s.err = opentype.Parse(s.ttf) }) + return s.font, s.err +} + +// The flags a font descriptor carries that say what a face looks like. +const ( + flagFixedPitch = 1 << 0 + flagSerif = 1 << 1 + flagItalic = 1 << 6 +) + +// standIn picks the face to draw a font that carries no program of its own. +func (r *renderer) standIn(f *pdffont.Font) *substitute { + name := strings.ToLower(baseFontName(r.doc, f)) + switch { + case strings.Contains(name, "courier") || strings.Contains(name, "mono"): + return monoStandIn + case strings.Contains(name, "times") || strings.Contains(name, "roman") || + strings.Contains(name, "serif") || strings.Contains(name, "georgia") || + strings.Contains(name, "garamond") || strings.Contains(name, "book"): + // "DejaVu Sans" holds "sans" and would be caught below; the serif + // names are checked first because "DejaVu Serif" holds neither + // "times" nor "roman". + return serifStandIn + case strings.Contains(name, "helvetica") || strings.Contains(name, "arial") || + strings.Contains(name, "sans"): + return sansStandIn + } + // Nothing in the name said: the descriptor's flags are what is left. + flags, _ := reader.ToInt(resolve(r.doc, f.Descriptor().Get("Flags"))) + switch { + case flags&flagFixedPitch != 0: + return monoStandIn + case flags&flagSerif != 0: + return serifStandIn + } + return sansStandIn +} + +// baseFontName is what the document calls the font, without the six-letter tag +// a subsetted one carries in front of it. +func baseFontName(d *reader.Document, f *pdffont.Font) string { + name, _ := reader.ToName(resolve(d, f.Dict().Get("BaseFont"))) + s := string(name) + if len(s) > 7 && s[6] == '+' { + return s[7:] + } + return s +} + +// attachStandIn gives a font with no program of its own a face to be drawn +// with, and leaves it alone when there is nothing sensible to draw: a +// composite font is addressed by glyph number, and a stand-in's glyph numbers +// are its own, so drawing one would put arbitrary letters on the page. +// +// Symbol and ZapfDingbats are left alone for the same reason: no face here +// carries their glyphs, and something is not better than nothing when the +// something is the wrong alphabet. +func (r *renderer) attachStandIn(f *pdfFont) { + if f.Kind() == pdffont.Composite || f.Kind() == pdffont.Type3 { + return + } + switch strings.ToLower(baseFontName(r.doc, f.Font)) { + case "symbol", "zapfdingbats", "dingbats": + return + } + program, err := r.standIn(f.Font).get() + if err != nil { + return + } + f.program = program + f.perEm = float64(program.UnitsPerEm()) + f.face = program.NewFace(program.UnitsPerEm()) + f.substituted = true + f.embolden, f.slant = r.wantsBoldItalic(f.Font) +} + +// wantsBoldItalic reads whether the font the document named was a bold or an +// italic one. Only one weight of each stand-in is carried, so a bold is drawn +// by stroking the outline as well as filling it and an italic by leaning it +// over — which is what a typesetter calls a faux bold and a faux italic, and +// what every reader does with a face it has not got in the weight asked for. +func (r *renderer) wantsBoldItalic(f *pdffont.Font) (embolden, slant bool) { + name := strings.ToLower(baseFontName(r.doc, f)) + embolden = strings.Contains(name, "bold") || strings.Contains(name, "black") || + strings.Contains(name, "heavy") || strings.Contains(name, "semibold") + slant = strings.Contains(name, "italic") || strings.Contains(name, "oblique") + if !slant { + flags, _ := reader.ToInt(resolve(r.doc, f.Descriptor().Get("Flags"))) + slant = flags&flagItalic != 0 + } + if !embolden { + // A stem width past about a hundred and sixty thousandths is a bold + // face; the descriptor is the only place a document says so in a + // number rather than in a name. + if w, ok := reader.ToFloat(resolve(r.doc, f.Descriptor().Get("StemV"))); ok && w >= 120 { + embolden = true + } + } + return embolden, slant +} + +// faux is how far a faux bold is stroked, as a fraction of the em. It is what +// a typesetter would reach for: enough to read as bold beside the same face +// unemboldened, not so much that the counters fill in. +const fauxBoldWidth = 0.024 + +// fauxSlant is how far a faux italic leans, as a tangent: about twelve +// degrees, which is where most italics sit. +const fauxSlant = 0.21 diff --git a/substitute_test.go b/substitute_test.go new file mode 100644 index 0000000..b703438 --- /dev/null +++ b/substitute_test.go @@ -0,0 +1,269 @@ +package render + +import ( + "testing" + + "github.com/go-gfx/gfx/raster" + "github.com/go-pdfkit/reader" +) + +// pageNamingAFont builds a page whose font the file does not carry, which is +// how two pages of the corpus in five are written. +func pageNamingAFont(t *testing.T, content string, font reader.Dict) *reader.Document { + t.Helper() + return pageWithFontDict(t, content, func(w *reader.Writer) reader.Dict { + out := reader.Dict{"Type": reader.Name("Font"), "Subtype": reader.Name("Type1")} + for k, v := range font { + out[k] = v + } + if desc, ok := out["FontDescriptor"]; ok { + if d, ok := desc.(reader.Dict); ok { + out["FontDescriptor"] = w.Add(d) + } + } + return out + }) +} + +func TestAFontTheFileDoesNotCarryIsStillDrawn(t *testing.T) { + // Of 95 818 corpus pages that show text, 40 437 name a font the file does + // not carry and 38 663 carry none at all. Drawing nothing for those would + // be two pages in five with no text on them. + d := pageNamingAFont(t, "BT /F 20 Tf 5 20 Td (Hello) Tj ET", + reader.Dict{"BaseFont": reader.Name("Helvetica")}) + img := draw(t, d, Options{}) + if inked(img) == 0 { + t.Fatal("a page naming Helvetica drew nothing") + } +} + +func TestTheStandInAFontNameAsksFor(t *testing.T) { + cases := []struct { + name string + base string + want *substitute + }{ + {"Helvetica", "Helvetica", sansStandIn}, + {"a subsetted Helvetica", "ABCDEF+Helvetica-Bold", sansStandIn}, + {"Arial", "ArialMT", sansStandIn}, + {"anything sans", "DejaVuSans-Oblique", sansStandIn}, + {"Times", "Times-Roman", serifStandIn}, + {"Times New Roman", "TimesNewRomanPSMT", serifStandIn}, + {"anything serif", "DejaVuSerif", serifStandIn}, + {"Georgia", "Georgia", serifStandIn}, + {"Garamond", "AGaramondPro", serifStandIn}, + {"a book face", "Bookman", serifStandIn}, + {"Courier", "Courier-Bold", monoStandIn}, + {"anything mono", "RobotoMono", monoStandIn}, + {"a name that says nothing", "Wingbat", sansStandIn}, + {"no name at all", "", sansStandIn}, + } + for _, c := range cases { + r, f := fontOf(t, reader.Dict{"BaseFont": reader.Name(c.base)}) + if got := r.standIn(f.Font); got != c.want { + t.Errorf("%s: the wrong stand-in", c.name) + } + } + // With nothing in the name, the descriptor's flags decide. + for _, c := range []struct { + name string + flags int64 + want *substitute + }{ + {"fixed pitch", flagFixedPitch, monoStandIn}, + {"serif", flagSerif, serifStandIn}, + {"neither", 0, sansStandIn}, + } { + r, f := fontOf(t, reader.Dict{"BaseFont": reader.Name("Wingbat"), + "FontDescriptor": reader.Dict{"Flags": reader.Integer(c.flags)}}) + if got := r.standIn(f.Font); got != c.want { + t.Errorf("%s: the wrong stand-in", c.name) + } + } +} + +func TestWhatIsNotGivenAStandIn(t *testing.T) { + // A composite font is addressed by glyph number and a stand-in's numbers + // are its own, so drawing one would put arbitrary letters on the page. + // Symbol and the dingbats are left alone for the same reason: no face + // here carries their glyphs. + for _, c := range []struct { + name string + font reader.Dict + }{ + {"Symbol", reader.Dict{"BaseFont": reader.Name("Symbol")}}, + {"ZapfDingbats", reader.Dict{"BaseFont": reader.Name("ZapfDingbats")}}, + {"a subsetted Symbol", reader.Dict{"BaseFont": reader.Name("ABCDEF+Symbol")}}, + } { + d := pageNamingAFont(t, "BT /F 20 Tf 5 20 Td (Hello) Tj ET", c.font) + if inked(draw(t, d, Options{})) != 0 { + t.Errorf("%s: something was drawn", c.name) + } + } + // A composite font, likewise. + d := pageWithFontDict(t, "BT /F 20 Tf 5 20 Td (\x00A) Tj ET", func(w *reader.Writer) reader.Dict { + kid := w.Add(reader.Dict{"Subtype": reader.Name("CIDFontType2")}) + return reader.Dict{"Subtype": reader.Name("Type0"), + "Encoding": reader.Name("Identity-H"), "DescendantFonts": reader.Array{kid}} + }) + if inked(draw(t, d, Options{})) != 0 { + t.Error("a composite font with no program drew something") + } + // And a Type 3 font, whose glyphs are its own drawings. + d = pageWithFontDict(t, "BT /F 20 Tf 5 20 Td (a) Tj ET", func(w *reader.Writer) reader.Dict { + return reader.Dict{"Subtype": reader.Name("Type3"), + "FontMatrix": reader.Array{reader.Real(0.001), reader.Integer(0), reader.Integer(0), + reader.Real(0.001), reader.Integer(0), reader.Integer(0)}, + "CharProcs": reader.Dict{}} + }) + if inked(draw(t, d, Options{})) != 0 { + t.Error("a Type 3 font with no drawings drew something") + } +} + +func TestABoldOrItalicFaceMadeOfARegularOne(t *testing.T) { + // Only one weight of each stand-in is carried, so a bold is drawn by + // stroking the outline as well as filling it and an italic by leaning it. + plain := inked(draw(t, pageNamingAFont(t, "BT /F 30 Tf 5 20 Td (HH) Tj ET", + reader.Dict{"BaseFont": reader.Name("Helvetica")}), Options{})) + bold := inked(draw(t, pageNamingAFont(t, "BT /F 30 Tf 5 20 Td (HH) Tj ET", + reader.Dict{"BaseFont": reader.Name("Helvetica-Bold")}), Options{})) + if bold <= plain { + t.Errorf("bold drew %d pixels and regular %d", bold, plain) + } + // An italic covers different pixels without covering many more. + italic := draw(t, pageNamingAFont(t, "BT /F 30 Tf 5 20 Td (HH) Tj ET", + reader.Dict{"BaseFont": reader.Name("Helvetica-Oblique")}), Options{}) + upright := draw(t, pageNamingAFont(t, "BT /F 30 Tf 5 20 Td (HH) Tj ET", + reader.Dict{"BaseFont": reader.Name("Helvetica")}), Options{}) + same := true + for y := 0; y < italic.H && same; y++ { + for x := 0; x < italic.W; x++ { + if italic.At(x, y) != upright.At(x, y) { + same = false + break + } + } + } + if same { + t.Error("an italic came out upright") + } +} + +func TestEveryWayADocumentSaysBoldOrItalic(t *testing.T) { + cases := []struct { + name string + font reader.Dict + bold, italic bool + }{ + {"nothing at all", reader.Dict{"BaseFont": reader.Name("Helvetica")}, false, false}, + {"bold in the name", reader.Dict{"BaseFont": reader.Name("Helvetica-Bold")}, true, false}, + {"black in the name", reader.Dict{"BaseFont": reader.Name("Roboto-Black")}, true, false}, + {"heavy in the name", reader.Dict{"BaseFont": reader.Name("Something-Heavy")}, true, false}, + {"semibold in the name", reader.Dict{"BaseFont": reader.Name("Open-Semibold")}, true, false}, + {"italic in the name", reader.Dict{"BaseFont": reader.Name("Times-Italic")}, false, true}, + {"oblique in the name", reader.Dict{"BaseFont": reader.Name("Helvetica-Oblique")}, false, true}, + {"both in the name", reader.Dict{"BaseFont": reader.Name("Times-BoldItalic")}, true, true}, + {"italic in the flags", reader.Dict{"BaseFont": reader.Name("Plain"), + "FontDescriptor": reader.Dict{"Flags": reader.Integer(flagItalic)}}, false, true}, + {"a stem width that is bold", reader.Dict{"BaseFont": reader.Name("Plain"), + "FontDescriptor": reader.Dict{"StemV": reader.Integer(160)}}, true, false}, + {"a stem width that is not", reader.Dict{"BaseFont": reader.Name("Plain"), + "FontDescriptor": reader.Dict{"StemV": reader.Integer(80)}}, false, false}, + {"a stem width that is not a number", reader.Dict{"BaseFont": reader.Name("Plain"), + "FontDescriptor": reader.Dict{"StemV": reader.Name("thick")}}, false, false}, + } + for _, c := range cases { + r, f := fontOf(t, c.font) + bold, italic := r.wantsBoldItalic(f.Font) + if bold != c.bold || italic != c.italic { + t.Errorf("%s: bold=%v italic=%v, want %v and %v", c.name, bold, italic, c.bold, c.italic) + } + } +} + +func TestAStandInGivesTheWidthsTheDocumentDoesNot(t *testing.T) { + // One of the fourteen standard faces is written with no widths at all. + // Half an em for every letter reads as a typewriter, which is not what + // the page says — so the stand-in's own advances are used, and they are + // the standard face's because the stand-ins are metric-compatible with + // them. + d := pageNamingAFont(t, "BT /F 20 Tf 2 20 Td (iiiii) Tj ET", + reader.Dict{"BaseFont": reader.Name("Times-Roman")}) + narrow := rightmostInk(draw(t, d, Options{})) + d = pageNamingAFont(t, "BT /F 20 Tf 2 20 Td (mmmmm) Tj ET", + reader.Dict{"BaseFont": reader.Name("Times-Roman")}) + wide := rightmostInk(draw(t, d, Options{})) + if wide <= narrow { + t.Errorf("five m's reach %d and five i's reach %d", wide, narrow) + } + // A document that does say how wide its letters are is believed. + widths := make(reader.Array, 0, 224) + for i := 32; i < 256; i++ { + widths = append(widths, reader.Integer(1000)) + } + d = pageNamingAFont(t, "BT /F 20 Tf 2 20 Td (iiiii) Tj ET", + reader.Dict{"BaseFont": reader.Name("Times-Roman"), + "FirstChar": reader.Integer(32), "LastChar": reader.Integer(255), + "Widths": widths}) + if told := rightmostInk(draw(t, d, Options{})); told <= narrow { + t.Errorf("widths of a whole em reached %d, against %d without them", told, narrow) + } +} + +// rightmostInk is the last column of the image that has anything on it. +func rightmostInk(img *raster.Image) int { + for x := img.W - 1; x >= 0; x-- { + for y := 0; y < img.H; y++ { + if !isWhite(img, x, y) { + return x + } + } + } + return -1 +} + +// fontOf builds a renderer over a document holding one font dictionary and +// hands back both, for a test about how a font is read rather than drawn. +func fontOf(t *testing.T, font reader.Dict) (*renderer, *pdfFont) { + t.Helper() + d := pageNamingAFont(t, "BT /F 12 Tf 5 20 Td (a) Tj ET", font) + p, err := d.Page(1) + if err != nil { + t.Fatal(err) + } + res, _ := d.GetDict(p, "Resources") + fonts, _ := d.GetDict(res, "Font") + dict, ok := d.GetDict(fonts, "F") + if !ok { + t.Fatal("the font is not there") + } + r := &renderer{doc: d, fonts: map[int]*pdfFont{}} + return r, r.loadFont(dict) +} + +func TestWhenAStandInCannotBeRead(t *testing.T) { + // The bundled faces parse — that is what their own package's tests are + // for — but a font that cannot be read leaves the document's font with no + // outlines rather than half a face. + was := sansStandIn + t.Cleanup(func() { sansStandIn = was }) + sansStandIn = &substitute{ttf: []byte("not a font at all")} + + d := pageNamingAFont(t, "BT /F 20 Tf 5 20 Td (Hello) Tj ET", + reader.Dict{"BaseFont": reader.Name("Helvetica")}) + if inked(draw(t, d, Options{})) != 0 { + t.Error("something was drawn from a stand-in that is not a font") + } +} + +func TestTextSetAtANegativeSize(t *testing.T) { + // A size below nothing turns the text over rather than being refused, and + // a bold one has no outline to thicken because the width would be below + // nothing too. + d := pageNamingAFont(t, "BT /F -20 Tf 50 40 Td (Hello) Tj ET", + reader.Dict{"BaseFont": reader.Name("Helvetica-Bold")}) + if _, err := Page(d, 1, Options{}); err != nil { + t.Fatal(err) + } +} diff --git a/text.go b/text.go index f01677d..bd87f82 100644 --- a/text.go +++ b/text.go @@ -6,6 +6,7 @@ import ( "github.com/go-opentype/opentype" "github.com/go-pdfkit/pdffont" "github.com/go-pdfkit/reader" + "math" ) // A textState is everything a show operator reads besides the string itself. @@ -156,7 +157,7 @@ func (r *renderer) show(g *gstate, s []byte, resources reader.Dict) { if g.text.mode != modeInvisible { r.drawGlyph(g, f, code, resources) } - advance := (f.Width(code)*g.text.size + g.text.charSpace) * g.text.scale + advance := (f.advance(code)*g.text.size + g.text.charSpace) * g.text.scale if single && code == ' ' { advance += g.text.wordSpace * g.text.scale } @@ -185,6 +186,11 @@ func (r *renderer) drawGlyph(g *gstate, f *pdfFont, code int, resources reader.D return } m := r.glyphMatrix(g).Mul(geometry.Scale(1/f.perEm, 1/f.perEm)) + if f.slant { + // Leaning the glyph rather than the line, so that the pen goes on + // where it was going. + m = m.Mul(geometry.Matrix{Xx: 1, Yy: 1, Yx: fauxSlant}) + } path := vector.NewPath() at := func(p opentype.Point) geometry.Point { return m.TransformPoint(geometry.Point{X: p.X, Y: p.Y}) @@ -205,6 +211,32 @@ func (r *renderer) drawGlyph(g *gstate, f *pdfFont, code int, resources reader.D } } r.paintGlyph(g, path) + if f.embolden { + // A face carried in one weight is made bold by drawing its outline as + // well as filling it. + r.thicken(g, path) + } +} + +// thicken strokes a glyph's outline in the colour it was filled with, which is +// what makes a face carried in one weight read as bold. +func (r *renderer) thicken(g *gstate, path *vector.Path) { + width := fauxBoldWidth * g.text.size * emScale(g) + if width <= 0 { + return + } + style := vector.StrokeStyle{Width: width, Cap: vector.RoundCap, Join: vector.RoundJoin} + if cov, ox, oy, w, h, ok := r.rz.StrokeWith(path, style, r.img.W, r.img.H); ok { + r.paint(g, cov, ox, oy, w, h, g.fill, g.fillAlpha) + } +} + +// emScale is how many device pixels an em of text space comes to, which is +// what a width given as a fraction of the em has to be multiplied by. +func emScale(g *gstate) float64 { + x := g.ctm.Xx*g.ctm.Xx + g.ctm.Xy*g.ctm.Xy + y := g.ctm.Yx*g.ctm.Yx + g.ctm.Yy*g.ctm.Yy + return math.Sqrt(math.Sqrt(x * y)) } // paintGlyph fills or strokes a glyph, in whichever way the text state says.