From 1412634db1179f3d2f990a9b3eb4ffcc2e7c7c13 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 26 Aug 2026 17:01:28 +0200 Subject: [PATCH 1/5] Read a page's text without unpacking its pictures Runs, Text and Images all walk the page the same way, and the walk undid every picture's filters whatever the caller had asked for. A page's images have nothing to do with its text, and undoing their filters is the most expensive thing on the page. One arXiv figure holds 378 MB of image once decompressed. Asking that page what it said cost 1 094 MB and 1.33 seconds, all of it spent inflating pictures the answer does not hold. extract.Runs on teaser.pdf 1.33 s, 1094 MB -> 1.11 ms, 5 MB Where a picture lands is still worked out either way, since that costs a matrix multiply; only the bytes are left alone. Images is unchanged and still unpacks them. The text is unchanged too, and that is checked rather than assumed: every page's runs with their positions, sizes, fonts and flags, the assembled text, and every image's dimensions, placement, filter and content hash, over 21 311 files. The one file that differed differed from itself, from a defect in the reader that this campaign fixed separately. Co-Authored-By: Claude Opus 5 --- extract.go | 14 ++++- image.go | 17 ++++-- run.go | 3 + textwithoutpictures_test.go | 118 ++++++++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 textwithoutpictures_test.go diff --git a/extract.go b/extract.go index 5adb2ac..64951f1 100644 --- a/extract.go +++ b/extract.go @@ -53,7 +53,7 @@ func Text(d *reader.Document, page int) (string, error) { // Runs is every piece of text on the page, in the order it was drawn. func Runs(d *reader.Document, page int) ([]Run, error) { - e, err := walk(d, page) + e, err := walk(d, page, false) if err != nil { return nil, err } @@ -69,7 +69,15 @@ func Runs(d *reader.Document, page int) ([]Run, error) { // stays an error — a conversion that silently produces an empty document from // an unreadable page is worse than one that says it could not read it, and // go-pdfkit/latex relies on being told. -func walk(d *reader.Document, page int) (*extractor, error) { +// +// wantPictures says whether the caller is going to look at the pictures. A +// page's images have nothing to do with its text, and undoing their filters is +// the most expensive thing on the page: one arXiv figure holds 378 MB of image +// once decompressed, so asking that page what it *said* used to cost 1 094 MB +// and 1.3 seconds spent inflating pictures the answer throws away. Where they +// land is still noted either way, since that costs a matrix multiply; only the +// bytes are left alone. +func walk(d *reader.Document, page int, wantPictures bool) (*extractor, error) { dec, err := d.PageContentDecoded(page) if err != nil { return nil, err @@ -80,7 +88,7 @@ func walk(d *reader.Document, page int) (*extractor, error) { content := dec.Data p, _ := d.Page(page) resources, _ := d.GetDict(p, "Resources") - e := &extractor{doc: d, fonts: map[int]*pdffont.Font{}} + e := &extractor{doc: d, fonts: map[int]*pdffont.Font{}, wantPictures: wantPictures} e.run(content, resources, initialState(d, p), 0) return e, nil } diff --git a/image.go b/image.go index b4511d7..7886f73 100644 --- a/image.go +++ b/image.go @@ -42,7 +42,7 @@ type Image struct { // same picture placed twice comes back twice, since where it lands is part of // what is being asked for. func Images(d *reader.Document, page int) ([]Image, error) { - e, err := walk(d, page) + e, err := walk(d, page, true) if err != nil { return nil, err } @@ -117,11 +117,16 @@ func (e *extractor) noteImage(g *state, name string, dict reader.Dict, raw []byt Dict: dict, Inline: inline, } - data, filter, err := reader.Decode(dict, raw, e.doc.Resolver()) - if err == nil { - img.Data, img.Filter = data, filter - } else { - img.Data = raw + // A caller after the text is not after the pixels, and undoing a + // picture's filters to hand it nothing is the most expensive thing a page + // can be made to do. + if e.wantPictures { + data, filter, err := reader.Decode(dict, raw, e.doc.Resolver()) + if err == nil { + img.Data, img.Filter = data, filter + } else { + img.Data = raw + } } e.images = append(e.images, img) } diff --git a/run.go b/run.go index d54ceee..0f93384 100644 --- a/run.go +++ b/run.go @@ -21,6 +21,9 @@ type extractor struct { // belong to the page rather than to the graphics state. tm, tlm matrix images []Image + // wantPictures says whether anybody is going to read Image.Data. When + // nobody is, the filters are left undone. + wantPictures bool } // run walks a content stream. diff --git a/textwithoutpictures_test.go b/textwithoutpictures_test.go new file mode 100644 index 0000000..b99595a --- /dev/null +++ b/textwithoutpictures_test.go @@ -0,0 +1,118 @@ +package extract + +import ( + "bytes" + "compress/zlib" + "runtime" + "testing" + + "github.com/go-pdfkit/reader" +) + +// bigImage is a picture whose decompressed form is eight megabytes: a +// thousand by two thousand grey samples, deflated from a run of zeros so the +// stream itself is a few kilobytes. That gap between the stream and what it +// unpacks to is the whole of the measurement below. +func bigImage(w *reader.Writer) reader.Object { + const width, height = 1000, 2000 + var buf bytes.Buffer + z := zlib.NewWriter(&buf) + _, _ = z.Write(make([]byte, width*height*4)) + _ = z.Close() + return w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(width), "Height": reader.Integer(height * 4), + "ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(8), + "Filter": reader.Name("FlateDecode"), + }, Raw: buf.Bytes()}) +} + +// allocatedBy is how many bytes one call put on the heap. +func allocatedBy(f func()) uint64 { + var before, after runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&before) + f() + runtime.ReadMemStats(&after) + return after.TotalAlloc - before.TotalAlloc +} + +// TestTextDoesNotUnpackThePictures is the regression. +// +// Runs, Text and Images all walk the page the same way, and the walk used to +// undo every picture's filters whatever the caller had asked for. A page +// holding one arXiv figure carries 378 MB of image once decompressed, so +// asking that page what it *said* cost 1 094 MB and 1.33 seconds spent +// inflating pictures the answer throws away. +// +// The assertion is on memory rather than time because memory is the thing +// that does not depend on how busy the machine is. The margin is wide: the +// picture is eight megabytes decompressed and the text is a few dozen bytes, +// so anything under a megabyte means the filters were left alone and anything +// over eight means they were not. +func TestTextDoesNotUnpackThePictures(t *testing.T) { + d := pageWith(t, "q 50 0 0 40 20 30 cm /Im1 Do Q BT /F1 12 Tf 10 700 Td (hello) Tj ET", + func(w *reader.Writer, res reader.Dict) { + res["XObject"] = reader.Dict{"Im1": bigImage(w)} + }) + + var runs []Run + forText := allocatedBy(func() { + var err error + runs, err = Runs(d, 1) + if err != nil { + t.Fatal(err) + } + }) + if len(runs) == 0 { + t.Fatal("the page says nothing, so the measurement means nothing") + } + if forText > 1<<20 { + t.Errorf("reading the text allocated %d bytes: the pictures were unpacked for an answer that does not hold them", forText) + } + + var images []Image + forImages := allocatedBy(func() { + var err error + images, err = Images(d, 1) + if err != nil { + t.Fatal(err) + } + }) + if len(images) != 1 { + t.Fatalf("%d pictures", len(images)) + } + // The other half: asking for the pictures must still unpack them. + if n := len(images[0].Data); n != 8_000_000 { + t.Fatalf("the picture came back as %d bytes, want 8000000 — asking for the pictures must still unpack them", n) + } + if forImages < 8<<20 { + t.Errorf("reading the pictures allocated only %d bytes; the measurement is not measuring what it thinks", forImages) + } +} + +// TestTextStillKnowsWhereThePicturesAre checks what was deliberately kept: +// where a picture lands costs a matrix multiply, so it is still worked out +// even when nobody asked for the pixels. Only the bytes are left alone. +func TestTextStillKnowsWhereThePicturesAre(t *testing.T) { + d := pageWith(t, "q 50 0 0 40 20 30 cm /Im1 Do Q", func(w *reader.Writer, res reader.Dict) { + res["XObject"] = reader.Dict{"Im1": greyImage(w)} + }) + e, err := walk(d, 1, false) + if err != nil { + t.Fatal(err) + } + if len(e.images) != 1 { + t.Fatalf("%d pictures noted", len(e.images)) + } + im := e.images[0] + if im.Name != "Im1" || im.Width != 2 || im.Height != 2 { + t.Errorf("the picture is %+v", im) + } + if im.X != 20 || im.Y != 30 || im.DrawnWidth != 50 || im.DrawnHeight != 40 { + t.Errorf("it lands at (%v,%v) %vx%v", im.X, im.Y, im.DrawnWidth, im.DrawnHeight) + } + if im.Data != nil { + t.Errorf("its bytes were unpacked anyway: %d of them", len(im.Data)) + } +} From 40c8ae24b345bcbd984d8922219a51b6876f164d Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 26 Aug 2026 17:27:10 +0200 Subject: [PATCH 2/5] Take a page's operations one at a time rather than all at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walk only ever goes forwards, so nothing needs the list of operations that reader.Operations builds. Keeping it costs a struct and a slice header for every operator on the page, and pages are not always small. 2608.06617/neglected_curvature.pdf is 13 MB and holds one page of 84.8 MB of content stream and 4 579 973 operations. Reading its text allocated 2 180 MB; taking the operations one at a time through the scanner the reader already has costs 948 MB. extract.Runs on neglected_curvature.pdf 2180 MB -> 948 MB The scanner is the same code the list was built from — Operations is a loop over it — so the operations are the same operations in the same order. The error it swallowed was already being swallowed here. Co-Authored-By: Claude Opus 5 --- run.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/run.go b/run.go index 0f93384..33da921 100644 --- a/run.go +++ b/run.go @@ -31,9 +31,19 @@ func (e *extractor) run(content []byte, resources reader.Dict, g state, depth in if depth > maxFormDepth { return } - ops, _ := reader.Operations(content) + // The operations are taken one at a time rather than all at once. The + // walk only ever goes forwards, so nothing needs the list; keeping it + // costs a struct and a slice header for every operator on the page, and + // pages are not always small. One arXiv figure holds a single page of + // 84.8 MB of content stream and 4 579 973 operations: holding them cost + // 2 166 MB where reading them one by one costs 935 MB. + scan := reader.NewContentScanner(content) var stack []state - for _, op := range ops { + for { + op, more := scan.Next() + if !more { + break + } n := numbers(op.Operands) switch op.Operator { case "q": From 98e1fe2f653fad152e37b44d9a5628ee0ef4898e Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 26 Aug 2026 17:57:43 +0200 Subject: [PATCH 3/5] ci: retrigger The workflow's last two runs failed to start. Nothing in the tree changed. Co-Authored-By: Claude Opus 5 From e3abaa71dc9f0240e1182a83e1dce6021876eaf5 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 26 Aug 2026 18:48:51 +0200 Subject: [PATCH 4/5] deps: take the reader that does not walk to two thousand million MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reader v0.4.1 stopped answering "which objects call themselves a catalogue?" by counting from zero to the largest object number a file names. This package was still asking for v0.4.0, so it still handed its callers the defect. bug1980958.pdf in mozilla's pdf.js corpus is 219 bytes. It has no trailer and no startxref, so it can only be read by repairing it, and the last object it declares is numbered 2 147 483 647. Two thousand million map lookups for four objects: reader.Open on 219 bytes 21.2 s, 0 MB allocated -> under 1 ms Not one byte allocated, which is why no memory limit anywhere caught it. The test builds the file rather than committing somebody else's, and guards the dependency rather than this package's own code: the answer is either a fraction of a millisecond or twenty-one seconds and nothing in between, so there is no threshold to tune. It fails in 15.5 s against the parent commit. Found by a fuzz target added here: it was the 205th seed, and it did not fail the run so much as stop it — the fuzzing coordinator gave up on the worker as hung. Co-Authored-By: Claude Opus 5 --- fuzz_test.go | 76 +++++++++++++++++++++++++++++++++++++++++++++ stalereader_test.go | 67 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 fuzz_test.go create mode 100644 stalereader_test.go diff --git a/fuzz_test.go b/fuzz_test.go new file mode 100644 index 0000000..5f00bcc --- /dev/null +++ b/fuzz_test.go @@ -0,0 +1,76 @@ +package extract_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-pdfkit/extract" + "github.com/go-pdfkit/reader" +) + +// seedDir can be pointed at an adversarial corpus — PDF_SEEDS — such as +// mozilla's pdf.js test suite, every file of which is there because it broke a +// reader once. Without it the built-in seeds and anything under testdata still +// run. +var seedDir = os.Getenv("PDF_SEEDS") + +func addSeeds(f *testing.F, max, cap int) { + f.Add([]byte("%PDF-1.7\n1 0 obj<>endobj\n" + + "2 0 obj<>endobj\n" + + "3 0 obj<>endobj\n" + + "trailer<>")) + if seedDir == "" { + return + } + ents, err := os.ReadDir(seedDir) + if err != nil { + return + } + n := 0 + for _, e := range ents { + info, err := e.Info() + if err != nil || info.Size() > int64(cap) || filepath.Ext(e.Name()) != ".pdf" { + continue + } + b, err := os.ReadFile(filepath.Join(seedDir, e.Name())) + if err != nil { + continue + } + f.Add(b) + if n++; n >= max { + return + } + } +} + +// FuzzExtract reads a page back three ways. The budget is asserted as well as +// the absence of a panic: what a page says is worked out by running a little +// program the document wrote, over numbers the document chose, and a small +// file that costs a large amount of time raises nothing on its own. +func FuzzExtract(f *testing.F) { + addSeeds(f, 300, 40*1024) + f.Fuzz(func(t *testing.T, b []byte) { + start := time.Now() + d, err := reader.Open(b) + if err != nil { + return + } + n := d.PageCount() + if n > 3 { + n = 3 + } + for i := 1; i <= n; i++ { + runs, err := extract.Runs(d, i) + if err == nil { + extract.Assemble(runs) + } + _, _ = extract.Text(d, i) + _, _ = extract.Images(d, i) + } + if el := time.Since(start); el > 5*time.Second { + t.Fatalf("%d bytes took %s over %d pages", len(b), el, n) + } + }) +} diff --git a/stalereader_test.go b/stalereader_test.go new file mode 100644 index 0000000..4d5045e --- /dev/null +++ b/stalereader_test.go @@ -0,0 +1,67 @@ +package extract_test + +import ( + "testing" + "time" + + "github.com/go-pdfkit/extract" + "github.com/go-pdfkit/reader" +) + +// hugeObjectNumber is a whole PDF in 219 bytes. It has no trailer and no +// startxref, so it can only be read by repairing it, and the last object it +// declares is numbered 2 147 483 647. +// +// reader v0.4.0 answered "which objects call themselves a catalogue?" by +// counting from zero to the largest object number the file mentioned, one map +// lookup each. For this file that is two thousand million lookups for four +// objects: twenty-one seconds, and not one byte allocated, which is why no +// memory limit anywhere caught it. +const hugeObjectNumber = "%PDF-1.7\n" + + "1 0 obj <>\nendobj\n" + + "2 0 obj <>\nendobj\n" + + "3 0 obj <>\nendobj\n\n" + + "2147483647 0 obj <>\nendobj\n" + +// TestATinyFileWithAHugeObjectNumber guards the version of the reader this +// package is built against, not this package's own code. +// +// The defect was fixed in reader v0.4.1, and merging is not shipping: a +// consumer still asking for v0.4.0 still hands its callers a file that takes +// twenty-one seconds to open. The budget is two seconds because the answer is +// either a fraction of a millisecond or twenty-one seconds, and nothing in +// between; there is no threshold here to tune. +func TestATinyFileWithAHugeObjectNumber(t *testing.T) { + b := []byte(hugeObjectNumber) + if len(b) > 300 { + t.Fatalf("the file is %d bytes; it is meant to be small enough that its cost cannot come from its size", len(b)) + } + + start := time.Now() + d, err := reader.Open(b) + opened := time.Since(start) + if err != nil { + t.Fatalf("opening %d bytes: %v", len(b), err) + } + if opened > 2*time.Second { + t.Fatalf("opening %d bytes took %s: the reader this is built against walks every object "+ + "number up to the largest one named, so it is older than v0.4.1", len(b), opened) + } + + // And the whole way through this package, since that is what callers use. + start = time.Now() + for i := 1; i <= d.PageCount(); i++ { + if _, err := extract.Text(d, i); err != nil { + t.Fatalf("page %d: %v", i, err) + } + if _, err := extract.Images(d, i); err != nil { + t.Fatalf("page %d images: %v", i, err) + } + } + if read := time.Since(start); read > 2*time.Second { + t.Fatalf("reading %d pages of a %d-byte file took %s", d.PageCount(), len(b), read) + } + if d.PageCount() != 1 { + t.Errorf("the file has %d pages, want 1 — the measurement means nothing if it was not read", d.PageCount()) + } +} From e49aefe59bc5dfe8bebbb7794fa54944c78cecb1 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 26 Aug 2026 19:22:54 +0200 Subject: [PATCH 5/5] Measure a word break against the size the text is actually drawn at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run's position came through the text matrix and the page's transform together. Its width, its size and the width of a space in it came through the page's transform alone: the text matrix's own scale was left out. A document may put the whole of its scale there — "/F1 1 Tf" followed by a text matrix of ten, rather than "/F1 10 Tf" — and TeX puts it there in every PDF it has ever produced. Such a document reported one-point text with half-point letters while saying quite correctly where on the page each run began. Those are the two numbers a word break is decided by. A gap measured in points was weighed against a space width ten times too small, so every ordinary kern between two letters looked like a space: F o r t h e y e a r J a n . 1 - D e c . 3 1 , 2 0 2 5 For the year Jan. 1-Dec. 31, 2025 That is IRS Form 1040, before and after. Nothing failed and nothing was slow; the text was simply no longer text, and no panic or timing measurement would ever have said so. Measured over 2 268 government forms — 2 079 with text, 7 630 pages, 19.4 million characters — the share of extracted words that are one or two letters long: 52.42% -> 25.81% 1 036 400 fragments became parts of words again, and 1.23 million spurious spaces went away. Over 4 000 arXiv figures, which are mostly axis labels and single-letter mathematics and so have a high floor, 57.26% -> 38.43%. The threshold itself is unchanged and did not need changing: it was already relative to the font's own space width. Sweeping it from a tenth of a space to a whole one moves the measure by 1.6 points, against the 26.6 the units were worth, so leaving it at 0.3 is the measured answer rather than the untouched one. Every test in this package wrote its size into Tf, which is why none of them saw it. The new ones draw the same page both ways and require the same answer. Co-Authored-By: Claude Opus 5 --- run.go | 20 +++++- textmatrixscale_test.go | 147 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 textmatrixscale_test.go diff --git a/run.go b/run.go index 33da921..5e47908 100644 --- a/run.go +++ b/run.go @@ -235,7 +235,25 @@ func (e *extractor) show(g *state, s []byte) { if text.Len() == 0 && !unreadable { return } - scale := g.ctm.scale() + // How far a length in text space reaches on the page: the text matrix and + // the page's transform together, which is the same matrix the run's own + // position came through. + // + // Only the page's transform used to be counted here, and the text matrix + // was left out. A document is free to put the whole of its scale there — + // "/F1 1 Tf" and then a text matrix of ten is how TeX writes every PDF it + // has ever produced — and such a document reported its text as one point + // tall with letters half a point wide, while saying quite correctly where + // on the page each run began. + // + // Those are the two numbers a word break is decided by. A gap measured in + // points was being weighed against a space width ten times too small, so + // every ordinary kern between two letters looked like a space: "Original + // Domain" came back as "Or ig inal D omain". Across four thousand arXiv + // figures, 57% of the words a reader got back were one or two letters + // long. Nothing failed, and nothing was slow; the text was simply no + // longer text. + scale := start.scale() e.runs = append(e.runs, Run{ Text: text.String(), X: x, diff --git a/textmatrixscale_test.go b/textmatrixscale_test.go new file mode 100644 index 0000000..0117fe6 --- /dev/null +++ b/textmatrixscale_test.go @@ -0,0 +1,147 @@ +package extract + +import ( + "fmt" + "strings" + "testing" +) + +// A document may put the scale of its text in the text matrix rather than in +// the font size, and TeX puts it there in every PDF it has ever produced: +// "/F1 1 Tf" followed by a text matrix of ten, rather than "/F1 10 Tf". Both +// draw the same page. +// +// Only the page's transform used to be counted when working out how large the +// text was, so such a document reported one-point text with half-point +// letters — while saying quite correctly where on the page each run began. +// Those are the two numbers a word break is decided by, and weighing a gap +// measured in points against a space width ten times too small made every +// ordinary kern between two letters look like a space. +// +// Every test in this package wrote its size into Tf, so nothing here ever saw +// it. + +// kernedWord draws one word, one letter at a time, with the pen moved between +// letters the way a typesetter kerns them — a gap far smaller than a space, +// but not nothing. inTm puts the scale in the text matrix; otherwise it goes +// in the font size, which is the same page either way. +func kernedWord(word string, size float64, x, y float64, inTm bool) string { + var b strings.Builder + b.WriteString("BT\n") + if inTm { + fmt.Fprintf(&b, "/F1 1 Tf\n%g 0 0 %g %g %g Tm\n", size, size, x, y) + } else { + fmt.Fprintf(&b, "/F1 %g Tf\n1 0 0 1 %g %g Tm\n", size, x, y) + } + for i, r := range word { + if i > 0 { + // A tenth of an em of kerning, which is a fifth of this font's + // space. It is a gap, and it is not a word break. + b.WriteString("0.1 0 Td\n") + } + fmt.Fprintf(&b, "(%c) Tj\n", r) + } + b.WriteString("ET\n") + return b.String() +} + +func TestKerningIsNotAWordBreak(t *testing.T) { + for _, c := range []struct { + name string + inTm bool + size float64 + x, y float64 + words string + }{ + {"scale in the text matrix", true, 10, 5, 150, "Original"}, + {"scale in the font size", false, 10, 5, 150, "Original"}, + {"a large scale in the text matrix", true, 24, 5, 100, "Domain"}, + {"a small scale in the text matrix", true, 4, 5, 50, "Parameterization"}, + } { + t.Run(c.name, func(t *testing.T) { + d := pageWith(t, kernedWord(c.words, c.size, c.x, c.y, c.inTm), nil) + got, err := Text(d, 1) + if err != nil { + t.Fatal(err) + } + got = strings.TrimSpace(got) + if got != c.words { + t.Errorf("the page says %q, and it was drawn as %q:\n"+ + "kerning between letters was read as a word break", got, c.words) + } + }) + } +} + +// TestARealSpaceIsStillAWordBreak is the other half. A fix that never breaks +// a word would score perfectly on the measure that found this and be useless: +// a gap the width of a space has to keep coming back as one. +// +// The two words are placed at absolute positions rather than moved apart with +// Td, because Td shifts the line matrix and so means different distances in +// the two cases. The font is 500/1000 of an em throughout, so at ten points +// each letter is five wide, "one" ends at 20, and a space is five: starting +// "two" at 27 is a gap of seven, comfortably more than a space and nothing +// like a whole word. +func TestARealSpaceIsStillAWordBreak(t *testing.T) { + for _, inTm := range []bool{true, false} { + name := "scale in the font size" + if inTm { + name = "scale in the text matrix" + } + t.Run(name, func(t *testing.T) { + d := pageWith(t, place("one", 10, 5, 150, inTm)+place("two", 10, 27, 150, inTm), nil) + got, err := Text(d, 1) + if err != nil { + t.Fatal(err) + } + if got = strings.TrimSpace(got); got != "one two" { + t.Errorf("the page says %q, want %q: a real space stopped being a word break", got, "one two") + } + }) + } +} + +// place draws one word whole at an absolute position, with the scale in +// whichever of the two places is being tested. +func place(word string, size, x, y float64, inTm bool) string { + var b strings.Builder + b.WriteString("BT\n") + if inTm { + fmt.Fprintf(&b, "/F1 1 Tf\n%g 0 0 %g %g %g Tm\n", size, size, x, y) + } else { + fmt.Fprintf(&b, "/F1 %g Tf\n1 0 0 1 %g %g Tm\n", size, x, y) + } + fmt.Fprintf(&b, "(%s) Tj\nET\n", word) + return b.String() +} + +// TestTheTextMatrixScalesTheRunItself checks the numbers a caller reads, +// rather than only the text they produce. A run drawn at ten points is ten +// points tall whichever of the two places the document put the ten. +func TestTheTextMatrixScalesTheRunItself(t *testing.T) { + inTm := pageWith(t, kernedWord("Domain", 10, 5, 150, true), nil) + inTf := pageWith(t, kernedWord("Domain", 10, 5, 150, false), nil) + a, err := Runs(inTm, 1) + if err != nil { + t.Fatal(err) + } + b, err := Runs(inTf, 1) + if err != nil { + t.Fatal(err) + } + if len(a) == 0 || len(b) == 0 { + t.Fatalf("%d runs against %d", len(a), len(b)) + } + if a[0].Size != b[0].Size { + t.Errorf("size is %v with the scale in the text matrix and %v with it in the font size", + a[0].Size, b[0].Size) + } + if a[0].Space != b[0].Space { + t.Errorf("a space is %v wide with the scale in the text matrix and %v with it in the font size", + a[0].Space, b[0].Space) + } + if a[0].Size != 10 { + t.Errorf("text drawn at ten points reports a size of %v", a[0].Size) + } +}