From e249bca45ac391b848c28368f4ef6699da09c013 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 28 Aug 2026 14:54:23 +0200 Subject: [PATCH] Draw a scanned page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A page whose only content is a JPEG 2000 image came out blank, and that is what a scanned page is. Over the 250 biodiversity scans, rendering every page of every document: 730 of 747 pages go from blank to drawn 0 pages lose anything 17 unchanged, being the pages that had something else on them The 17 are the true control: nothing that was drawing before draws differently. WHY THIS WAS NEARLY NOT DONE Measured over the corpora this project had — government forms and arXiv figures — JPEG 2000 is eleven files in sixteen hundred, and the decision taken on that number was that it was not worth a decoder. Extending the corpus to where a scanned page actually lives says otherwise: all 250 biodiversity scans carry one, 248 of the 250 medical ones, and 144 of the 222 readable scanned books. The old figure measured the corpus, not the world. THE DECODER IS NOT WRITTEN HERE, AND NOT WRITTEN AT ALL github.com/ajroetker/go-jpeg2000 is 20 050 lines of pure Go with no cgo, and it decodes 1 610 of 1 610 real images taken out of eighty scanned books. It is also registered in go-gfx/gfx/codec, which is where the fleet's codec choices are made once. THIS TAKES THE DECODER, NOT THE REGISTRY, AND THE REASON IS A NUMBER go-gfx/gfx/codec brings TIFF, WEBP, BMP and ICO with it, and a PDF renderer has no use for any of them. One of this package's consumers is a wasm binary a browser downloads: today 4.17 MB the decoder on its own 5.35 MB the whole registry 6.25 MB Nine hundred kilobytes for four decoders nothing here calls. The codec choice is still mutualised — the same reference library, chosen once in go-gfx — but what is imported is the one format this package can be asked for. WHAT IT COSTS Decoding is real work, and the figure that looked good before was measuring a blank page: those 747 pages took 1.1 seconds when nothing was drawn and 2 minutes 30 seconds when they were, which is about a fifth of a second for a full-page scan. TWO SMALLER THINGS The size comes from the codestream rather than from the dictionary, as it does for JPEG: a codestream carries its own, and where they disagree the one the pixels are in is the one that can be drawn. The "a format nothing here decodes" branch is now covered by JBIG2 rather than by JPEG 2000, which is the honest representative of what is left: 12 of 250 court filings and 1 of 222 scanned books carry it, and it draws nothing rather than drawing noise. 100% statement coverage, go vet and -race clean, nine cross-compile targets. Co-Authored-By: Claude Opus 5 --- go.mod | 12 ++++- go.sum | 10 +++- image.go | 34 ++++++++++++ jpx_test.go | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 jpx_test.go diff --git a/go.mod b/go.mod index 85891a6..9dc94b6 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,18 @@ module github.com/go-pdfkit/render go 1.26.4 require ( - github.com/go-gfx/gfx v0.10.0 + github.com/go-gfx/gfx v0.12.0 github.com/go-opentype/fonts v0.9.0 github.com/go-opentype/opentype v0.10.0 github.com/go-pdfkit/reader v0.6.0 ) -require github.com/go-pdfkit/pdffont v0.3.0 +require ( + github.com/ajroetker/go-jpeg2000 v0.0.2 + github.com/go-pdfkit/pdffont v0.3.0 +) + +require ( + github.com/ajroetker/go-highway v0.0.4 // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/go.sum b/go.sum index 323b803..6bf10ff 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ -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/ajroetker/go-highway v0.0.4 h1:RDQo+9OhTXI6BFctLo+5gYpHNbb92VYJ0ObnR4l6xAQ= +github.com/ajroetker/go-highway v0.0.4/go.mod h1:C/zYPNSSpOaraejY89FUTZTyQNEhi5+rEbU0LjlqJeU= +github.com/ajroetker/go-jpeg2000 v0.0.2 h1:ni8brffZrci4Kacx3nM5d92ipmTDfak84KgHYi6IxFw= +github.com/ajroetker/go-jpeg2000 v0.0.2/go.mod h1:7ld88W47lZy0x8gRQesRGAonDPOpr6ev8rckjCAfbzE= +github.com/go-gfx/gfx v0.12.0 h1:zBaYHahRwM6yYr86ifRelX132ZjhN5hZNr/uW9dpIzQ= +github.com/go-gfx/gfx v0.12.0/go.mod h1:DpRUcQrlLZH02CSB23J0iEk1F7mQH8w9WMF60xQBxDU= 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.10.0 h1:cZVMZ3RVkcijXmxlmpqyVkjlNc33aSB2ugKVWYvoLUg= @@ -8,3 +12,5 @@ github.com/go-pdfkit/pdffont v0.3.0 h1:G5DKcAmsZJ0e17QhSrcUaL7PKEXKjUx4P/iGMl7bA github.com/go-pdfkit/pdffont v0.3.0/go.mod h1:bfmNLna1l1CljNX/Utg55YzFylovfmI7sJnvgA3bzKI= github.com/go-pdfkit/reader v0.6.0 h1:KAabNOYUcTlZlNBTbG9bEhWP1NiZhjuhzUdavdTdfes= github.com/go-pdfkit/reader v0.6.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/image.go b/image.go index 2a6f7cd..e1a45be 100644 --- a/image.go +++ b/image.go @@ -7,6 +7,7 @@ import ( _ "image/jpeg" // the one image format a PDF may carry undecoded "math" + jpeg2000 "github.com/ajroetker/go-jpeg2000" "github.com/go-gfx/gfx/geometry" "github.com/go-gfx/gfx/raster" "github.com/go-pdfkit/reader" @@ -135,6 +136,8 @@ func (r *renderer) decodeImage(dict reader.Dict, raw []byte, resources reader.Di out = r.samples(dict, data, w, h, resources) case "DCTDecode", "DCT": out = decodeJPEG(data, w, h, r.decodeInverts(dict)) + case "JPXDecode": + out = decodeJPX(data, w, h) default: // A format nothing here can decode: the image is not drawn rather than // drawn wrong. @@ -277,6 +280,37 @@ func decodeJPEG(data []byte, w, h int, inverted bool) *sampled { return &sampled{w: w, h: h, pix: src.Pix} } +// decodeJPX reads a JPEG 2000 image, which is what a scanned page is stored in. +// +// Measured over a corpus of a thousand scanned documents: all 250 biodiversity +// scans carry one, 248 of the 250 medical ones do, and 144 of the 222 readable +// scanned books — and between them 655 pages have nothing on them at all +// besides such an image. Those pages came out blank. +// +// The size is taken from the picture rather than from the dictionary, as it is +// for JPEG: a codestream carries its own, and where the two disagree the one +// the pixels are actually in is the one that can be drawn. +func decodeJPX(data []byte, w, h int) *sampled { + img, err := jpxDecode(data) + if err != nil || img == nil { + return nil + } + if img.W != w || img.H != h { + w, h = img.W, img.H + } + return &sampled{w: w, h: h, pix: img.Pix} +} + +// jpxDecode is a variable so a test can watch what happens when a decoder +// refuses what it is given. +var jpxDecode = func(data []byte) (*raster.Image, error) { + img, err := jpeg2000.Decode(bytes.NewReader(data)) + if err != nil { + return nil, err + } + return raster.FromImage(img), nil +} + // jpegDecode is a variable so a test can watch what happens when a decoder // refuses what it is given. var jpegDecode = func(data []byte) (image.Image, error) { diff --git a/jpx_test.go b/jpx_test.go new file mode 100644 index 0000000..b97c712 --- /dev/null +++ b/jpx_test.go @@ -0,0 +1,151 @@ +package render + +import ( + "bytes" + "image" + "image/color" + "testing" + + jpeg2000 "github.com/ajroetker/go-jpeg2000" + "github.com/go-pdfkit/reader" +) + +// jpxImage encodes a small picture as a JPEG 2000 codestream, losslessly, so a +// test can check the colours that come back. It is built here rather than +// committed: nobody else's scan enters the repository. +func jpxImage(t *testing.T, w, h int) []byte { + t.Helper() + src := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + c := color.RGBA{R: 20, G: 20, B: 20, A: 255} // dark + if x >= w/2 { + c = color.RGBA{R: 240, G: 240, B: 240, A: 255} // light + } + src.Set(x, y, c) + } + } + var buf bytes.Buffer + if err := jpeg2000.Encode(&buf, src, &jpeg2000.EncodeOptions{Lossless: true}); err != nil { + t.Fatalf("encoding a JPEG 2000 to draw: %v", err) + } + return buf.Bytes() +} + +// jpxPage builds a page whose only content is one JPEG 2000 image filling it — +// which is what a scanned page IS. +func jpxPage(t *testing.T, data []byte, w, h int) *reader.Document { + t.Helper() + wr := reader.NewWriter("1.7") + pagesRef := wr.Reserve() + img := wr.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(w), "Height": reader.Integer(h), + "ColorSpace": reader.Name("DeviceRGB"), "BitsPerComponent": reader.Integer(8), + "Filter": reader.Name("JPXDecode"), + }, Raw: data}) + pageRef := wr.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": nums(0, 0, 40, 40), + "Resources": reader.Dict{"XObject": reader.Dict{"S": img}}, + "Contents": wr.Add(&reader.Stream{Dict: reader.Dict{}, + Raw: []byte("q 40 0 0 40 0 0 cm /S Do Q")})}) + wr.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{pageRef}, "Count": reader.Integer(1)}) + out, err := wr.Finish(reader.Dict{"Root": wr.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef})}) + if err != nil { + t.Fatal(err) + } + d, err := reader.Open(out) + if err != nil { + t.Fatal(err) + } + return d +} + +func TestAScannedPageIsDrawn(t *testing.T) { + // A page whose only content is a JPEG 2000 image came out blank. Over a + // corpus of a thousand scanned documents that is 655 pages: every one of + // the 250 biodiversity scans carries such an image, and 248 of the 250 + // medical ones. + d := jpxPage(t, jpxImage(t, 32, 32), 32, 32) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + if inked(img) == 0 { + t.Fatal("a page whose only content is a JPEG 2000 image drew nothing") + } + // Dark on the left, light on the right — so a decoder that hands back a + // flat or a mirrored picture is caught, not merely one that hands back + // nothing. + if !isBlack(img, 8, 20) { + t.Errorf("the dark half is not dark: %s", pixel(img, 8, 20)) + } + if !isWhite(img, 30, 20) { + t.Errorf("the light half is not light: %s", pixel(img, 30, 20)) + } +} + +func TestAPictureThatWillNotDecodeIsNotDrawn(t *testing.T) { + // The rule the rest of this file follows: not drawn rather than drawn + // wrong. + d := jpxPage(t, []byte{0xFF, 0x4F, 0xFF, 0x51, 0, 1, 2}, 32, 32) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + if ink := inked(img); ink != 0 { + t.Errorf("%d pixels drawn from a codestream that is not one", ink) + } +} + +func TestTheSizeComesFromThePictureNotTheDictionary(t *testing.T) { + // A codestream carries its own size, and where the two disagree the one the + // pixels are actually in is the one that can be drawn. + d := jpxPage(t, jpxImage(t, 32, 32), 999, 7) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + if inked(img) == 0 { + t.Error("a dictionary that lies about the size stopped the page being drawn") + } +} + +func TestAFormatNothingHereDecodesIsNotDrawn(t *testing.T) { + // JPEG 2000 used to be the example of this. What is left is JBIG2, which + // 12 of 250 court filings and 1 of 222 scanned books carry — a real gap, + // and one that draws nothing rather than drawing noise. + wr := reader.NewWriter("1.7") + pagesRef := wr.Reserve() + img := wr.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(8), "Height": reader.Integer(8), + "ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(1), + "Filter": reader.Name("JBIG2Decode"), + }, Raw: make([]byte, 8)}) + pageRef := wr.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": nums(0, 0, 40, 40), + "Resources": reader.Dict{"XObject": reader.Dict{"S": img}}, + "Contents": wr.Add(&reader.Stream{Dict: reader.Dict{}, + Raw: []byte("q 40 0 0 40 0 0 cm /S Do Q")})}) + wr.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{pageRef}, "Count": reader.Integer(1)}) + out, err := wr.Finish(reader.Dict{"Root": wr.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef})}) + if err != nil { + t.Fatal(err) + } + d, err := reader.Open(out) + if err != nil { + t.Fatal(err) + } + pic, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + if ink := inked(pic); ink != 0 { + t.Errorf("%d pixels drawn from a format nothing here decodes", ink) + } +}