From 0e8b1c44fe1803aa4da2e119e8839b276c08b375 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 27 Aug 2026 15:19:57 +0200 Subject: [PATCH] A stencil needs decoded bytes, not compressed ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decodeImage asks whether an image is a mask before it asks whether the filter chain finished, so a mask whose filter stopped at an image format nothing here decodes was handed to the stencil path still compressed — and drawn. A stencil is one bit a pixel; compressed bytes painted through it are noise in the shape of nothing at all. This is not a rare shape. 273 of the image masks in the 1 633 real forms carry an encoded filter: 236 CCITT and 9 JBIG2. Fifty-one first pages of real forms were showing that noise, and it took decoding faxes in the reader (go-pdfkit/reader#15) to make it visible: those pages came out with *less* ink afterwards, because most of a form is white and the noise was not. The remaining nine are JBIG2, which nothing here decodes. Until something does, the honest answer is the one the rest of decodeImage already gives: "the image is not drawn rather than drawn wrong." The test draws a mask of eight zero bytes — as samples, a solid black stencil, so anything drawn at all is visible — once with /Filter /JBIG2Decode and once with no filter, and requires the first to draw nothing and the second to draw. Against the parent commit the first fails with "drawn = true, want false". 100% statement coverage, go vet clean, nine cross-compile targets. Co-Authored-By: Claude Opus 5 --- image.go | 14 ++++++++++++ image_test.go | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/image.go b/image.go index 3921ee3..f23cb69 100644 --- a/image.go +++ b/image.go @@ -109,6 +109,20 @@ func (r *renderer) decodeImage(dict reader.Dict, raw []byte, resources reader.Di return nil } 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, + // they are not: they are still compressed, and drawing them paints + // 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". + if imageFilter != "" { + return nil + } return r.stencil(dict, data, w, h) } var out *sampled diff --git a/image_test.go b/image_test.go index 36811f8..e99709d 100644 --- a/image_test.go +++ b/image_test.go @@ -459,3 +459,62 @@ func TestAnImageUnderAClip(t *testing.T) { wantBlack(t, img, 5, 10) wantWhite(t, img, 15, 10) } + +func TestAMaskWhoseBytesAreStillEncodedIsNotDrawn(t *testing.T) { + // A stencil is one bit a pixel, so the bytes have to be samples. A mask + // whose filter chain stopped at an image format nothing here decodes is + // still compressed, and painting it puts noise on the page in the shape of + // 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. + for _, tc := range []struct { + name string + filter reader.Name + drawn bool + }{ + {"a format nothing here decodes", "JBIG2Decode", false}, + {"no filter at all", "", true}, + } { + t.Run(tc.name, func(t *testing.T) { + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + dict := reader.Dict{"Type": reader.Name("XObject"), + "Subtype": reader.Name("Image"), "Width": reader.Integer(8), + "Height": reader.Integer(8), "ImageMask": reader.Bool(true), + "BitsPerComponent": reader.Integer(1)} + if tc.filter != "" { + dict["Filter"] = tc.filter + } + // Eight rows of a byte each, every bit zero: as samples that is a + // solid black stencil, so anything drawn at all is visible. + img := w.Add(&reader.Stream{Dict: dict, Raw: make([]byte, 8)}) + page := reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": nums(0, 0, 20, 20), + "Resources": reader.Dict{"XObject": reader.Dict{"M": img}}, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, + Raw: []byte("0 g q 20 0 0 20 0 0 cm /M Do Q")})} + pageRef := w.Add(page) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{pageRef}, "Count": reader.Integer(1)}) + out, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef})}) + if err != nil { + t.Fatal(err) + } + doc, err := reader.Open(out) + if err != nil { + t.Fatal(err) + } + pic, err := Page(doc, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + if drawn := inked(pic) > 0; drawn != tc.drawn { + t.Errorf("drawn = %v, want %v", drawn, tc.drawn) + } + }) + } +}