From 7298a2fecb8ac13276b0152380610174ea6493c5 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sat, 29 Aug 2026 19:04:49 +0200 Subject: [PATCH] Say when a /Decode array shaped a picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A picture and the same picture as another tool EXTRACTS it can be exact complements of each other for a reason that is not a disagreement. /Decode maps the stored samples onto the range the colour space wants; this applies it, as a viewer must, and pdfimages writes the samples as stored. On a one-bit mask a /Decode of [1 0] therefore inverts every pixel. Judged against pdfimages over a corpus of scanned medical pages, 8 of 23 JBIG2 masks differed at exactly 1.0000 — every pixel — and the pages they are on differ from poppler's rendering by a median of 0.0000. Nothing was wrong except the comparison. Of the 248 JBIG2 masks on those first pages, 22 carry /Decode [1 0]. All 22 are soft masks; not one of the 194 stencils carries one. So a picture now says whether an array shaped it, which is the one thing a comparison needs in order to tell that case apart from a real one. 100% statement coverage, go vet and -race clean, nine cross-compile targets. --- images.go | 17 ++++++++++++++ images_test.go | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/images.go b/images.go index 7ce21ed..0a821d7 100644 --- a/images.go +++ b/images.go @@ -20,6 +20,14 @@ type Image struct { // JPXDecode, JBIG2Decode, CCITTFaxDecode — and is empty when the bytes // were samples that no image codec had to read. Filter string + // Decoded says the dictionary carried a /Decode array, which maps the + // stored samples onto the range the colour space wants and is applied + // here. It is worth knowing about because it is the one thing that makes + // this picture differ from the same picture as another tool EXTRACTS it: + // pdfimages writes the samples as stored, so a /Decode of [1 0] on a + // one-bit mask makes the two exact complements of each other, and every + // pixel differs for a reason that is not a disagreement. + Decoded bool // Stencil says the picture is a one-bit mask, which carries no colours of // its own: it paints whatever colour is in force through its own shape. // Its Pic has that shape in the alpha channel and black everywhere else, @@ -114,6 +122,7 @@ func (r *renderer) decoded(name string, st *reader.Stream, res reader.Dict) []Im out = append(out, Image{ Name: name, Filter: imageFilterOf(r.doc, st), + Decoded: r.hasDecodeArray(st.Dict), Stencil: bool(stencil), Pic: &raster.Image{W: s.w, H: s.h, Pix: s.pix}, }) @@ -133,6 +142,7 @@ func (r *renderer) decoded(name string, st *reader.Stream, res reader.Dict) []Im out = append(out, Image{ Name: name + "/" + string(key), Filter: imageFilterOf(r.doc, ms), + Decoded: r.hasDecodeArray(ms.Dict), Stencil: true, Pic: &raster.Image{W: s.w, H: s.h, Pix: s.pix}, }) @@ -140,6 +150,13 @@ func (r *renderer) decoded(name string, st *reader.Stream, res reader.Dict) []Im return out } +// hasDecodeArray says whether a picture's samples were mapped through a +// /Decode array on the way out. +func (r *renderer) hasDecodeArray(dict reader.Dict) bool { + _, ok := reader.ToArray(resolve(r.doc, dict.Get("Decode"))) + return ok +} + // imageFilterOf names the image format a stream's filter chain stopped at, and // is empty when the chain ran to samples. func imageFilterOf(d *reader.Document, st *reader.Stream) string { diff --git a/images_test.go b/images_test.go index 62167b1..56d9c5f 100644 --- a/images_test.go +++ b/images_test.go @@ -331,3 +331,64 @@ func TestAMaskNothingCanDecodeIsLeftOut(t *testing.T) { t.Errorf("a mask of no size came back as a picture: %+v", got) } } + +func TestAPictureSaysWhetherADecodeArrayShapedIt(t *testing.T) { + // A /Decode of [1 0] on a one-bit mask makes this picture and the same + // picture as pdfimages EXTRACTS it exact complements of each other: every + // pixel differs, and none of it is a disagreement. Whoever compares the + // two has to be able to tell that case apart from a real one. + for _, tc := range []struct { + name string + extra reader.Dict + want bool + }{ + {"no array at all", nil, false}, + {"an array", reader.Dict{"Decode": reader.Array{reader.Integer(1), reader.Integer(0)}}, true}, + {"something that is not an array", reader.Dict{"Decode": reader.Integer(1)}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + dict := reader.Dict{ + "Width": reader.Integer(2), "Height": reader.Integer(1), + "ColorSpace": reader.Name("DeviceGray"), "BitsPerComponent": reader.Integer(8), + } + for k, v := range tc.extra { + dict[k] = v + } + d := pageWithImage(t, dict, []byte{0x00, 0xff}, "") + got, err := Images(d, 1) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Decoded != tc.want { + t.Errorf("got %+v, want Decoded=%v", got, tc.want) + } + }) + } +} + +func TestAMaskSaysSoToo(t *testing.T) { + // The mask is where this actually happens: 22 of the 54 JBIG2 soft masks + // in a corpus of scanned medical documents carry /Decode [1 0], and not + // one of the 194 stencils does. + 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), + "Decode": reader.Array{reader.Integer(1), reader.Integer(0)}, + }, Raw: []byte{0x00, 0xff}}) + 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 || got[0].Decoded || !got[1].Decoded { + t.Errorf("got %+v", got) + } +}