From 5a5481d937dd049e95687dbf342ff65f2fd6b9ca Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 28 Aug 2026 15:25:00 +0200 Subject: [PATCH] A mask we cannot read takes its image with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverses what this package did, and the corpus is why. An image whose mask could not be decoded was drawn as it stood, on the reasoning that an unreadable mask should not cost you the picture. That is right for a photograph with a soft edge and catastrophic for a scanned page. A scanned page is not one image. It is a low-resolution colour background with a HIGH-RESOLUTION BITONAL INK LAYER over it, and that ink layer is a dark rectangle whose shape comes entirely from a JBIG2 stencil in /Mask. Drawn without the stencil it is a dark rectangle over the whole page — so v0.12.0, which stopped those pages being blank, painted a great many of them dark instead. That is worse than blank, and it shipped. WHAT IT WAS, AND WHAT IT IS Judged against poppler, page by page, both given the same box: v0.12.0 with this ia-medical median 0.9749 median 0.0005 ia-biodiversity 0.1073 0.0259 ia-texts 0.1324 0.0066 ia-americana 0.0278 0.0249 ia-uscourts 0.0009 0.0009 Medical scans go from 97% of the median page's pixels wrong to 0.05%, and the count of pages agreeing to within one per cent goes from 10 of 119 to 100. WHAT IS STILL MISSING, AND WHY THIS IS THE RIGHT ANSWER ANYWAY The ink layer is not drawn at all, because JBIG2 is not decoded. So the page comes out as its background: legible, missing its sharpest text. poppler draws both. The remaining difference is that missing layer, which is what the numbers above still show on biodiversity and americana. Not drawing it is the same rule the rest of this file already follows — the image is not drawn rather than drawn wrong — and the measurement says which of the two mistakes is the smaller one by a factor of two thousand. AND A CORRECTION TO A DECISION MADE EARLIER TODAY JBIG2 was measured at 20 documents in 3 217 and three pages blank without it, and set aside on that. The survey was counting pages whose only content is an image in a given filter — and JBIG2 here is not the content, it is the MASK. Its absence does not blank a page; it ruins one. The metric could not see that, and the comparison against another renderer is what did. 100% statement coverage, go vet and -race clean, nine cross-compile targets. Co-Authored-By: Claude Opus 5 --- image.go | 34 +++++++++++++++++++++++++--------- image_test.go | 23 +++++++++++++++++++---- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/image.go b/image.go index e1a45be..e06d3d6 100644 --- a/image.go +++ b/image.go @@ -146,7 +146,14 @@ func (r *renderer) decodeImage(dict reader.Dict, raw []byte, resources reader.Di if out == nil { return nil } - r.applyTransparency(out, dict, resources) + 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 } @@ -374,23 +381,30 @@ func (r *renderer) decodeInverts(dict reader.Dict) bool { // applyTransparency reads whichever of the two ways a PDF says which parts of // an image are see-through: a soft mask of its own, or a range of colours to // treat as absent. -func (r *renderer) applyTransparency(s *sampled, dict reader.Dict, resources reader.Dict) { +// It reports whether the image may be drawn. A mask that is NAMED and cannot be +// READ means how much of the image shows is unknown, and an image drawn whole +// when most of it was meant to be invisible is worse than one not drawn: that +// is exactly how a scanned page goes wrong. Such a page is a low-resolution +// colour background with a high-resolution bitonal ink layer over it, and the +// ink layer is a dark rectangle masked by a JBIG2 stencil. Without the stencil +// it is a dark rectangle over the whole page. +func (r *renderer) applyTransparency(s *sampled, dict reader.Dict, resources reader.Dict) bool { if stream, ok := reader.ToStream(resolve(r.doc, dict.Get("SMask"))); ok { - r.applySoftMask(s, stream, resources) - return + return r.applySoftMask(s, stream, resources) } maskEntry := resolve(r.doc, dict.Get("Mask")) if stream, ok := reader.ToStream(maskEntry); ok { - r.applyStencilMask(s, stream, resources) + return r.applyStencilMask(s, stream, resources) } + return true } // applySoftMask reads a grey image whose levels say how much of each pixel // shows. -func (r *renderer) applySoftMask(s *sampled, stream *reader.Stream, resources reader.Dict) { +func (r *renderer) applySoftMask(s *sampled, stream *reader.Stream, resources reader.Dict) bool { mask := r.decodeImage(stream.Dict, stream.Raw, resources) if mask == nil { - return + return false } for y := 0; y < s.h; y++ { for x := 0; x < s.w; x++ { @@ -398,14 +412,15 @@ func (r *renderer) applySoftMask(s *sampled, stream *reader.Stream, resources re s.pix[(y*s.w+x)*4+3] = m.R } } + return true } // applyStencilMask reads a one-bit image whose set pixels are the ones to // leave out. -func (r *renderer) applyStencilMask(s *sampled, stream *reader.Stream, resources reader.Dict) { +func (r *renderer) applyStencilMask(s *sampled, stream *reader.Stream, resources reader.Dict) bool { mask := r.decodeImage(stream.Dict, stream.Raw, resources) if mask == nil { - return + return false } for y := 0; y < s.h; y++ { for x := 0; x < s.w; x++ { @@ -417,6 +432,7 @@ func (r *renderer) applyStencilMask(s *sampled, stream *reader.Stream, resources } } } + return true } // intOr reads an integer, or gives a default. diff --git a/image_test.go b/image_test.go index e99709d..5d103b6 100644 --- a/image_test.go +++ b/image_test.go @@ -419,13 +419,28 @@ func imageWithMask(t *testing.T, entry reader.Name, maskDict reader.Dict, maskDa return d } -func TestAMaskThatCannotBeRead(t *testing.T) { - // Neither kind of mask, when it cannot be read, may take the image with - // it: the image is drawn as it stands. +func TestAMaskThatCannotBeReadTakesTheImageWithIt(t *testing.T) { + // This reverses what this package used to do, and the corpus is why. + // + // It used to draw the image as it stood, on the reasoning that an + // unreadable mask should not cost you the picture. That is right for a + // photograph with a soft edge and catastrophic for a scanned page, which + // is a low-resolution colour background with a HIGH-RESOLUTION BITONAL INK + // LAYER over it — and that ink layer is a dark rectangle whose shape comes + // entirely from a JBIG2 stencil. Drawn without the stencil it is a dark + // rectangle over the whole page. + // + // Measured against poppler over 250 scanned medical documents, the median + // page had 97% of its pixels wrong that way. Not drawing it leaves the + // background showing, which is what the page mostly is. for _, entry := range []reader.Name{"SMask", "Mask"} { d := imageWithMask(t, entry, reader.Dict{ "Width": reader.Integer(0), "Height": reader.Integer(0)}, nil) - wantBlack(t, draw(t, d, Options{}), 10, 10) + img := draw(t, d, Options{}) + if !isWhite(img, 10, 10) { + t.Errorf("/%s: the image was drawn although how much of it shows "+ + "is unknown: %s", entry, pixel(img, 10, 10)) + } } }