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) + } +}