diff --git a/go.mod b/go.mod index 9dc94b6..cc5c5de 100644 --- a/go.mod +++ b/go.mod @@ -16,5 +16,6 @@ require ( require ( github.com/ajroetker/go-highway v0.0.4 // indirect + github.com/dkrisman/gobig2 v0.0.0-20260513123937-51e39052fde6 golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index 6bf10ff..8a46a32 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/ajroetker/go-highway v0.0.4 h1:RDQo+9OhTXI6BFctLo+5gYpHNbb92VYJ0ObnR4 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/dkrisman/gobig2 v0.0.0-20260513123937-51e39052fde6 h1:hHPgbpPdcaSXtpLb9NMEUJPxsZ0cxGtGHwmvKGI36NA= +github.com/dkrisman/gobig2 v0.0.0-20260513123937-51e39052fde6/go.mod h1:2Ij0rpAVy1tZ+PQ9FM1jCpnVo/OOCMCD6rLtSp4JkEI= 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= diff --git a/image.go b/image.go index e06d3d6..db6a870 100644 --- a/image.go +++ b/image.go @@ -113,6 +113,15 @@ func (r *renderer) decodeImage(dict reader.Dict, raw []byte, resources reader.Di if len(data) == 0 { return nil } + if imageFilter == "JBIG2Decode" { + // Decoded here rather than in the switch below because a JBIG2 stream + // is far more often a stencil than an image, and a stencil never + // reaches that switch. + if data = r.decodeJBIG2(dict, data, w, h); data == nil { + return nil + } + imageFilter = "" + } if mask, ok := reader.ToBool(resolve(r.doc, dict.Get("ImageMask"))); ok && mask { // A stencil is one bit a pixel, so the bytes have to be samples. When // the filter chain stopped at an image format nothing here decodes, @@ -120,11 +129,11 @@ func (r *renderer) decodeImage(dict reader.Dict, raw []byte, resources reader.Di // noise through the shape of nothing. // // 273 of the image masks in the 1 633 real forms carry an encoded - // filter. 236 of them were faxes, which the reader now decodes; the - // nine that remain are JBIG2, and until something decodes those the - // honest answer is not to draw them. That is the rule the rest of this - // function already follows: "the image is not drawn rather than drawn - // wrong". + // filter. 236 of them were faxes and nine were JBIG2, both of which + // are decoded before this point. What is left is a format nothing + // here reads, and the honest answer is not to draw it. That is the + // rule the rest of this function already follows: "the image is not + // drawn rather than drawn wrong". if imageFilter != "" { return nil } @@ -138,11 +147,11 @@ func (r *renderer) decodeImage(dict reader.Dict, raw []byte, resources reader.Di 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. - return nil } + // No arm ran, or the one that ran could not read its bytes: the image is + // not drawn rather than drawn wrong. Every filter the reader hands back + // unread has an arm above, so the first of those is a case this cannot + // reach today and the check is here for the second. if out == nil { return nil } @@ -415,8 +424,18 @@ func (r *renderer) applySoftMask(s *sampled, stream *reader.Stream, resources re return true } -// applyStencilMask reads a one-bit image whose set pixels are the ones to -// leave out. +// applyStencilMask reads a one-bit image that says which parts of this one are +// painted. +// +// A mask sample of 0 means PAINT. The bit and the coverage run opposite ways, +// which is the whole difficulty: decodeImage returns a stencil whose alpha is +// set where the sample is 0, so alpha here already means "painted" and what has +// to be cleared is everything else. Reading the alpha as though it were the +// sample shows the exact complement of the picture — a scanned page whose text +// is the only part hidden. +// +// Asked which half of a two-colour page a mask of eight 0 bits and eight 1 bits +// paints, poppler answers the 0 half. func (r *renderer) applyStencilMask(s *sampled, stream *reader.Stream, resources reader.Dict) bool { mask := r.decodeImage(stream.Dict, stream.Raw, resources) if mask == nil { @@ -425,9 +444,7 @@ func (r *renderer) applyStencilMask(s *sampled, stream *reader.Stream, resources for y := 0; y < s.h; y++ { for x := 0; x < s.w; x++ { m := mask.at(x*mask.w/s.w, y*mask.h/s.h) - // A stencil mask marks what is hidden, so where it paints, the - // image does not. - if m.A > 127 { + if m.A <= 127 { s.pix[(y*s.w+x)*4+3] = 0 } } diff --git a/image_test.go b/image_test.go index 5d103b6..196f90d 100644 --- a/image_test.go +++ b/image_test.go @@ -220,9 +220,15 @@ func TestASoftMaskMakesAnImageSeeThrough(t *testing.T) { } func TestAMaskHidesPartOfAnImage(t *testing.T) { + // A mask sample of 0 means PAINT, so the half whose bit is 0 is the half + // that shows. This test asserted the complement of that, and so did the + // code: every explicit mask in the corpus was showing exactly the parts it + // was meant to hide. Asked the same question — which half of a + // two-colour page a mask of one 0 bit and one 1 bit paints — poppler + // answers the 0 half. w := reader.NewWriter("1.7") pagesRef := w.Reserve() - // A stencil that covers the left half. + // A stencil whose left half is 0 and right half is 1. mask := w.Add(&reader.Stream{Dict: reader.Dict{ "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), "Width": reader.Integer(2), "Height": reader.Integer(1), @@ -253,8 +259,8 @@ func TestAMaskHidesPartOfAnImage(t *testing.T) { t.Fatal(err) } img := draw(t, d, Options{}) - wantWhite(t, img, 5, 10) // hidden by the mask - wantBlack(t, img, 15, 10) // shown + wantBlack(t, img, 5, 10) // sample 0: painted + wantWhite(t, img, 15, 10) // sample 1: masked out } func TestAJPEGImage(t *testing.T) { @@ -482,15 +488,17 @@ func TestAMaskWhoseBytesAreStillEncodedIsNotDrawn(t *testing.T) { // nothing at all. // // 273 of the image masks in the 1 633 real forms carry an encoded filter. - // 236 were faxes, which the reader decodes as of go-pdfkit/reader#15; the - // nine that remain are JBIG2. Fifty-one first pages of real forms were - // showing this noise before either change. + // 236 were faxes and nine were JBIG2, and both are decoded before this + // point now. What can still arrive encoded is a mask a file gave a + // photographic filter, which no amount of decoding turns into one bit a + // pixel. Fifty-one first pages of real forms were showing this noise + // before any of it was decoded. for _, tc := range []struct { name string filter reader.Name drawn bool }{ - {"a format nothing here decodes", "JBIG2Decode", false}, + {"a mask given a photographic filter", "DCTDecode", false}, {"no filter at all", "", true}, } { t.Run(tc.name, func(t *testing.T) { diff --git a/jbig2.go b/jbig2.go new file mode 100644 index 0000000..73feeaf --- /dev/null +++ b/jbig2.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026, the go-pdfkit/render authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package render + +import ( + "bytes" + "image" + + "github.com/dkrisman/gobig2" + "github.com/go-pdfkit/reader" +) + +// decodeJBIG2 turns a JBIG2 stream into the packed one-bit rows the rest of +// this file already reads, so a JBIG2 image goes on to be drawn by the same +// code as any other one-bit image and a JBIG2 stencil by the same code as any +// other stencil. +// +// JBIG2 is what a scanned page's ink is stored in, and it is almost never the +// page's content: it is the /Mask that gives the high-resolution ink layer its +// shape. Counting filters by what they encode as content put it in twenty +// documents; counting the images it SHAPES put it in four thousand. +// +// The two conventions are opposite. JBIG2 sets a bit where there is ink; a +// one-bit DeviceGray sample of 0 is black, and an image mask paints where its +// sample is 0. One inversion here serves both. +func (r *renderer) decodeJBIG2(dict reader.Dict, data []byte, w, h int) []byte { + img, err := jbig2Decode(data, r.jbig2Globals(dict)) + if err != nil || img == nil { + return nil + } + b := img.Bounds() + if b.Dx() != w || b.Dy() != h { + // The dictionary's size is the one the page's geometry was computed + // from. A stream that decodes to another size is not this image. + return nil + } + rowBytes := (w + 7) / 8 + out := make([]byte, rowBytes*h) + for i := range out { + out[i] = 0xff + } + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + gr, gg, gb, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA() + if (gr*299+gg*587+gb*114)/1000 < 0x8000 { + out[y*rowBytes+x/8] &^= 0x80 >> uint(x%8) + } + } + } + return out +} + +// jbig2Globals returns the shared segments the stream's decode parameters name, +// which is where an encoder puts the symbol dictionary several pages draw from. +// /DecodeParms runs parallel to /Filter, so it may be one dictionary or an +// array with one entry per filter in the chain. +func (r *renderer) jbig2Globals(dict reader.Dict) []byte { + parms := resolve(r.doc, dict.Get("DecodeParms")) + if arr, ok := reader.ToArray(parms); ok { + for _, v := range arr { + if g := r.globalsFrom(resolve(r.doc, v)); g != nil { + return g + } + } + return nil + } + return r.globalsFrom(parms) +} + +// globalsFrom reads the globals stream one decode-parameters dictionary names. +func (r *renderer) globalsFrom(parms reader.Object) []byte { + d, ok := reader.ToDict(parms) + if !ok { + return nil + } + st, ok := reader.ToStream(resolve(r.doc, d.Get("JBIG2Globals"))) + if !ok { + return nil + } + data, _, err := reader.DecodeStream(st, r.doc.Get) + if err != nil { + return nil + } + return data +} + +// jbig2Decode is a variable so a test can watch what happens when a decoder +// refuses, which is the case that decides whether the image is drawn wrong or +// not drawn. +var jbig2Decode = func(data, globals []byte) (image.Image, error) { + d, err := gobig2.NewDecoderEmbedded(bytes.NewReader(data), globals) + if err != nil { + return nil, err + } + img, err := d.Decode() + if err != nil { + return nil, err + } + return img, nil +} diff --git a/jbig2_test.go b/jbig2_test.go new file mode 100644 index 0000000..d1a770c --- /dev/null +++ b/jbig2_test.go @@ -0,0 +1,260 @@ +package render + +import ( + "errors" + "image" + "testing" + + "github.com/go-pdfkit/reader" +) + +// jbig2Ink is a JBIG2 stream 16 by 8 whose right half is ink. It is written out +// here rather than committed as a file, and it is synthetic: no scan of anyone's +// document enters the repository. It was made by encoding a bitmap with an +// encoder that is not this decoder, and it decodes to the same picture under +// gobig2, under that encoder's own decoder, and under poppler. +var jbig2Ink = []byte{ + 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x01, 0x00, 0x00, 0x00, 0x13, 0x00, + 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x27, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x1e, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, + 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x03, + 0xff, 0xfd, 0xff, 0x02, 0xfe, 0xfe, 0xfe, 0x8f, 0x66, 0xff, 0xac, +} + +// indirect writes any stream inside an object as its own numbered object and +// puts a reference in its place, because that is the only form a real file +// uses: a globals stream is always referred to, never written where it is +// named. +func indirect(wr *reader.Writer, v reader.Object) reader.Object { + switch o := v.(type) { + case *reader.Stream: + return wr.Add(o) + case reader.Dict: + d := reader.Dict{} + for k, e := range o { + d[k] = indirect(wr, e) + } + return d + case reader.Array: + a := make(reader.Array, len(o)) + for i, e := range o { + a[i] = indirect(wr, e) + } + return a + } + return v +} + +// jbig2Page builds a page whose only content is one JBIG2 image filling it. +// extra is merged into the image dictionary, which is how a test says "and this +// one is a stencil" or "and its globals are over there". +func jbig2Page(t *testing.T, data []byte, w, h int, extra reader.Dict) *reader.Document { + t.Helper() + wr := reader.NewWriter("1.7") + pagesRef := wr.Reserve() + dict := reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(w), "Height": reader.Integer(h), + "ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(1), + "Filter": reader.Name("JBIG2Decode"), + } + for k, v := range extra { + dict[k] = indirect(wr, v) + } + img := wr.Add(&reader.Stream{Dict: dict, Raw: data}) + pageRef := wr.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": nums(0, 0, 16, 8), + "Resources": reader.Dict{"XObject": reader.Dict{"S": img}}, + "Contents": wr.Add(&reader.Stream{Dict: reader.Dict{}, + Raw: []byte("q 16 0 0 8 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 TestAJBIG2ImageIsDrawn(t *testing.T) { + d := jbig2Page(t, jbig2Ink, 16, 8, nil) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + // Ink on the right, paper on the left — so a decoder handing back a flat + // or a mirrored picture is caught, not merely one handing back nothing. + if !isBlack(img, 12, 4) { + t.Errorf("the inked half is not inked: %s", pixel(img, 12, 4)) + } + if !isWhite(img, 3, 4) { + t.Errorf("the blank half is not blank: %s", pixel(img, 3, 4)) + } +} + +func TestAJBIG2StencilPaintsThroughItsShape(t *testing.T) { + // This is what JBIG2 nearly always IS. A scanned page's ink layer is a + // dark rectangle whose shape comes entirely from a JBIG2 stencil; the + // filter is in twenty documents as content and shapes four thousand + // images as a mask. + d := jbig2Page(t, jbig2Ink, 16, 8, reader.Dict{"ImageMask": reader.Bool(true)}) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + if !isBlack(img, 12, 4) { + t.Errorf("the stencil did not paint where it has ink: %s", pixel(img, 12, 4)) + } + if !isWhite(img, 3, 4) { + t.Errorf("the stencil painted where it has none: %s", pixel(img, 3, 4)) + } +} + +func TestAJBIG2StreamThatIsNotOneIsNotDrawn(t *testing.T) { + // The rule the rest of this file follows: not drawn rather than drawn + // wrong. Bytes that are not a JBIG2 stream must not become a rectangle. + d := jbig2Page(t, []byte{0, 1, 2, 3, 4, 5, 6, 7}, 16, 8, nil) + 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 bytes that are not a JBIG2 stream", ink) + } +} + +func TestAJBIG2StencilThatWillNotDecodeIsNotDrawn(t *testing.T) { + d := jbig2Page(t, []byte{0, 1, 2, 3}, 16, 8, reader.Dict{"ImageMask": reader.Bool(true)}) + img, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + if ink := inked(img); ink != 0 { + t.Errorf("%d pixels painted through a stencil that could not be read", ink) + } +} + +func TestAJBIG2StreamThatIsNotTheSizeTheDictionarySaysIsNotDrawn(t *testing.T) { + // Unlike a JPEG 2000 codestream, a JBIG2 mask has no size of its own that + // the page can be laid out from: the geometry was computed from the + // dictionary. A stream that decodes to another size is not this image. + d := jbig2Page(t, jbig2Ink, 32, 32, nil) + 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 stream of the wrong size", ink) + } +} + +func TestAJBIG2DecoderThatRefusesTakesTheImageWithIt(t *testing.T) { + // The decoder refuses a stream whose symbols exceed its resource budget, + // which seven of four hundred real scanned masks do. Refusing is the right + // answer; drawing the rectangle anyway is not. + restore := jbig2Decode + defer func() { jbig2Decode = restore }() + jbig2Decode = func(data, globals []byte) (image.Image, error) { + return nil, errors.New("resource budget exceeded") + } + d := jbig2Page(t, jbig2Ink, 16, 8, nil) + 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 although the decoder refused", ink) + } +} + +func TestAJBIG2DecoderThatReturnsNothingTakesTheImageWithIt(t *testing.T) { + restore := jbig2Decode + defer func() { jbig2Decode = restore }() + jbig2Decode = func(data, globals []byte) (image.Image, error) { return nil, nil } + d := jbig2Page(t, jbig2Ink, 16, 8, nil) + 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 although the decoder returned no picture", ink) + } +} + +// seenGlobals draws a page and reports the globals the decoder was handed, so +// a test can check the plumbing without needing a stream split into two parts. +func seenGlobals(t *testing.T, parms reader.Object) []byte { + t.Helper() + restore := jbig2Decode + defer func() { jbig2Decode = restore }() + var got []byte + var called bool + jbig2Decode = func(data, globals []byte) (image.Image, error) { + got, called = globals, true + return restore(data, globals) + } + d := jbig2Page(t, jbig2Ink, 16, 8, reader.Dict{"DecodeParms": parms}) + if _, err := Page(d, 1, Options{Scale: 1}); err != nil { + t.Fatal(err) + } + if !called { + t.Fatal("the decoder was never called") + } + return got +} + +func TestTheSharedSegmentsAreHandedToTheDecoder(t *testing.T) { + // An encoder puts the symbol dictionary several pages draw from in a + // globals stream, and the pages are undecodable without it. /DecodeParms + // runs parallel to /Filter, so it is one dictionary or an array with one + // entry per filter in the chain, and both forms occur. + globals := &reader.Stream{Dict: reader.Dict{}, Raw: []byte("shared segments")} + for _, tc := range []struct { + name string + parms func(*reader.Stream) reader.Object + }{ + {"one dictionary", func(s *reader.Stream) reader.Object { + return reader.Dict{"JBIG2Globals": s} + }}, + {"an array with one entry per filter", func(s *reader.Stream) reader.Object { + return reader.Array{reader.Null{}, reader.Dict{"JBIG2Globals": s}} + }}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := string(seenGlobals(t, tc.parms(globals))); got != "shared segments" { + t.Errorf("the decoder was handed %q, not the globals stream", got) + } + }) + } +} + +func TestDecodeParametersThatNameNoSharedSegments(t *testing.T) { + // Every one of these is a stream that decodes on its own, which is what + // all 403 of the corpus's masks turned out to be. None of them is a + // reason to refuse the image. + broken := &reader.Stream{Dict: reader.Dict{"Filter": reader.Name("FlateDecode")}, + Raw: []byte("not deflated")} + for _, tc := range []struct { + name string + parms reader.Object + }{ + {"no decode parameters at all", nil}, + {"a dictionary naming none", reader.Dict{"K": reader.Integer(0)}}, + {"an array naming none", reader.Array{reader.Dict{"K": reader.Integer(0)}}}, + {"an array entry that is not a dictionary", reader.Array{reader.Integer(1)}}, + {"globals that are not a stream", reader.Dict{"JBIG2Globals": reader.Integer(1)}}, + {"globals whose own filter breaks", reader.Dict{"JBIG2Globals": broken}}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := seenGlobals(t, tc.parms); got != nil { + t.Errorf("the decoder was handed %q, not nothing", got) + } + }) + } +} diff --git a/jpx_test.go b/jpx_test.go index b97c712..d7ba184 100644 --- a/jpx_test.go +++ b/jpx_test.go @@ -112,40 +112,3 @@ func TestTheSizeComesFromThePictureNotTheDictionary(t *testing.T) { 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) - } -}