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/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/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..5e47908 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. @@ -28,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": @@ -222,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/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()) + } +} 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) + } +} 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)) + } +}