From 150af110ade59a413059a80f2e4b8094ca1e63eb Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sat, 29 Aug 2026 15:33:17 +0200 Subject: [PATCH] Hand back what the codec read, and the mask beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Images returned what the PAGE draws: the picture with its mask applied. That made it useless for the job it was added for, and worse than useless, because it was wrong in a way that looked like a finding. Judged against pdfimages over a corpus of scanned pages, 21 of 22 JPEG 2000 pictures came out differing, at a median of 85% of their pixels. That reading was impossible on its face: the same pages, compared whole against the same renderer, differ by a median of 0.0000. A picture cannot be 85% wrong on a page that is perfect. Eleven of the twenty-two carry an /SMask. We applied it and pdfimages did not, so the two sides were not being asked the same question. So Images now hands back what each codec produced, with the mask beside it as its own entry named for the key that named it — which is what pdfimages does and the only way the two can be compared. A mask is a picture in its own right, stored in its own filter, and very often the one that is wrong: JBIG2 is almost never a page's content and almost always its mask. A picture whose mask cannot be read now comes back too. Page still declines to draw that one, because how much of it shows is unknown; the codec read it, and this is about the codec. Both halves are asserted, in one test, because it would be easy to move the first and lose the second. decodeImage keeps its meaning — what the page draws — and is now that composition written down: decodeBase, then the mask. 100% statement coverage, go vet and -race clean, nine cross-compile targets. --- image.go | 39 +++++++++++------ images.go | 44 ++++++++++++++++++-- images_test.go | 111 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 16 deletions(-) diff --git a/image.go b/image.go index db6a870..4c799ec 100644 --- a/image.go +++ b/image.go @@ -98,8 +98,34 @@ func unitSquareBounds(m geometry.Matrix, w, h int) image.Rectangle { // name a grid it would take the whole machine to hold. const maxImagePixels = 64 << 20 -// decodeImage turns an image XObject into a grid of colours. +// decodeImage turns an image XObject into the grid of colours a page draws: +// the picture the codec read, with whatever mask it names applied to it. func (r *renderer) decodeImage(dict reader.Dict, raw []byte, resources reader.Dict) *sampled { + out := r.decodeBase(dict, raw, resources) + if out == nil { + return nil + } + if !r.applyTransparency(out, dict, resources) { + // A mask was named and could not be read, so how much of this image + // shows is unknown. Drawing it whole is the worst of the three + // answers: it is how a scanned page's high-resolution ink layer, which + // is meant to show through a stencil, ends up painted over the page as + // a solid dark rectangle. + return nil + } + return out +} + +// decodeBase is the picture the codec read, with no mask applied. +// +// It is separate because the two questions are different. What a page draws +// is the composited picture; what a codec produced is this one, and comparing +// a codec against another implementation means comparing THIS, since the other +// implementation hands its masks back separately too. Measured the wrong way +// round, 21 of 22 JPEG 2000 pictures in a corpus of scanned pages looked +// wrong, and 11 of them differed only by an /SMask that had been applied to +// one side and not the other. +func (r *renderer) decodeBase(dict reader.Dict, raw []byte, resources reader.Dict) *sampled { w := int(intOr(resolve(r.doc, dict.Get("Width")), 0)) h := int(intOr(resolve(r.doc, dict.Get("Height")), 0)) if w <= 0 || h <= 0 || w*h > maxImagePixels { @@ -152,17 +178,6 @@ func (r *renderer) decodeImage(dict reader.Dict, raw []byte, resources reader.Di // 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 - } - if !r.applyTransparency(out, dict, resources) { - // A mask was named and could not be read, so how much of this image - // shows is unknown. Drawing it whole is the worst of the three - // answers: it is how a scanned page's high-resolution ink layer, which - // is meant to show through a stencil, ends up painted over the page as - // a solid dark rectangle. - return nil - } return out } diff --git a/images.go b/images.go index 982c464..7ce21ed 100644 --- a/images.go +++ b/images.go @@ -40,6 +40,18 @@ type Image struct { // mattered here — a release went out drawing scanned pages dark because "ink // appeared" was measured instead of "the right ink". // +// What comes back is what each CODEC produced, not what the page draws. A +// picture that names a mask is returned unmasked, and the mask is returned +// beside it as its own entry, named for the key that named it. That is what +// pdfimages does, and it is the only way the two can be compared: applying a +// mask to one side and not the other made 21 of 22 JPEG 2000 pictures in a +// corpus of scanned pages look wrong, when 11 of them differed by nothing but +// the /SMask. +// +// It also means a picture whose mask cannot be read still comes back. [Page] +// declines to draw that one, because how much of it shows is unknown — but the +// codec read it, and this is about the codec. +// // A picture nothing here can decode is left out rather than returned empty, // which is the same answer [Page] gives by not drawing it. Inline images — // the ones written into the content stream — are not returned: they belong to @@ -89,10 +101,15 @@ func (r *renderer) imagesIn(res reader.Dict, depth int) []Image { if sub != "Image" { continue } - s := r.decodeImage(st.Dict, st.Raw, res) - if s == nil { - continue - } + out = append(out, r.decoded(name, st, res)...) + } + return out +} + +// decoded reads one image XObject and the mask it names, if any. +func (r *renderer) decoded(name string, st *reader.Stream, res reader.Dict) []Image { + var out []Image + if s := r.decodeBase(st.Dict, st.Raw, res); s != nil { stencil, _ := reader.ToBool(resolve(r.doc, st.Dict.Get("ImageMask"))) out = append(out, Image{ Name: name, @@ -101,6 +118,25 @@ func (r *renderer) imagesIn(res reader.Dict, depth int) []Image { Pic: &raster.Image{W: s.w, H: s.h, Pix: s.pix}, }) } + // A mask is a picture in its own right, stored in its own filter, and it + // is very often the one that is wrong: JBIG2 is almost never a page's + // content and almost always its mask. + for _, key := range []reader.Name{"SMask", "Mask"} { + ms, ok := reader.ToStream(resolve(r.doc, st.Dict.Get(key))) + if !ok { + continue + } + s := r.decodeBase(ms.Dict, ms.Raw, res) + if s == nil { + continue + } + out = append(out, Image{ + Name: name + "/" + string(key), + Filter: imageFilterOf(r.doc, ms), + Stencil: true, + Pic: &raster.Image{W: s.w, H: s.h, Pix: s.pix}, + }) + } return out } diff --git a/images_test.go b/images_test.go index 7901ac5..62167b1 100644 --- a/images_test.go +++ b/images_test.go @@ -220,3 +220,114 @@ func TestAPageThatIsNotThereHasNoPictures(t *testing.T) { t.Error("page nine of a one-page document came back without complaint") } } + +func TestAMaskComesBackBesideThePictureItShapes(t *testing.T) { + // A picture that names a mask is returned unmasked, with the mask beside + // it. Applying it to one side and not the other made 21 of 22 JPEG 2000 + // pictures in a corpus of scanned pages look wrong, when 11 of them + // differed by nothing but the /SMask. + d := pageWithResources(t, func(w *reader.Writer) reader.Dict { + mask := w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(2), "Height": reader.Integer(1), + "ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(8), + }, Raw: []byte{0x00, 0x00}}) + return reader.Dict{"XObject": reader.Dict{"I": w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(2), "Height": reader.Integer(1), + "ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(8), + "SMask": mask, + }, Raw: []byte{0x00, 0xff}})}} + }) + got, err := Images(d, 1) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("%d pictures, want the picture and its mask", len(got)) + } + // The mask says nothing shows. The picture must come back anyway, opaque: + // that is the codec's answer, and the codec is what this is about. + if got[0].Name != "I" || got[0].Pic.Pix[3] != 255 { + t.Errorf("the picture came back masked: %+v alpha %d", got[0], got[0].Pic.Pix[3]) + } + if got[1].Name != "I/SMask" || !got[1].Stencil { + t.Errorf("the mask came back as %+v", got[1]) + } +} + +func TestAPictureWhoseMaskCannotBeReadStillComesBack(t *testing.T) { + // Page declines to draw this one, because how much of it shows is + // unknown. The codec read it, and this is about the codec. + d := pageWithResources(t, func(w *reader.Writer) reader.Dict { + bad := w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(2), "Height": reader.Integer(1), + "Filter": reader.Name("JPXDecode"), + }, Raw: []byte{0xff, 0x4f, 0xff, 0x51, 0, 1}}) + return reader.Dict{"XObject": reader.Dict{"I": w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(2), "Height": reader.Integer(1), + "ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(8), + "SMask": bad, + }, Raw: []byte{0x00, 0xff}})}} + }) + got, err := Images(d, 1) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Name != "I" { + t.Fatalf("got %+v", got) + } + // And the page still refuses to draw it, which is the other half of the + // rule and must not have moved. + 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 an image whose mask cannot be read", ink) + } +} + +func TestAMaskThatIsNotAPictureIsNotOne(t *testing.T) { + // /Mask may be an array of colour ranges rather than a stream, which is a + // different mechanism and not a picture to hand back. + d := pageWithResources(t, func(w *reader.Writer) reader.Dict { + return reader.Dict{"XObject": reader.Dict{"I": w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(2), "Height": reader.Integer(1), + "ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(8), + "Mask": reader.Array{reader.Integer(0), reader.Integer(0)}, + }, Raw: []byte{0x00, 0xff}})}} + }) + got, err := Images(d, 1) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Errorf("a colour-key mask came back as a picture: %+v", got) + } +} + +func TestAMaskNothingCanDecodeIsLeftOut(t *testing.T) { + d := pageWithResources(t, func(w *reader.Writer) reader.Dict { + bad := w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(0), "Height": reader.Integer(0), + }, Raw: []byte{}}) + return reader.Dict{"XObject": reader.Dict{"I": w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(2), "Height": reader.Integer(1), + "ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(8), + "Mask": bad, + }, Raw: []byte{0x00, 0xff}})}} + }) + got, err := Images(d, 1) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Errorf("a mask of no size came back as a picture: %+v", got) + } +}