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.