Skip to content

A mask we cannot read takes its image with it - #26

Merged
tannevaled merged 1 commit into
mainfrom
fix/a-mask-we-cannot-read
Aug 28, 2026
Merged

A mask we cannot read takes its image with it#26
tannevaled merged 1 commit into
mainfrom
fix/a-mask-we-cannot-read

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

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.

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 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit c8966ec into main Aug 28, 2026
1 check passed
tannevaled added a commit that referenced this pull request Aug 28, 2026
)

Two things, because the first exposed the second and neither is right alone.

JBIG2 is decoded. A modern scanned page is two images: a low-resolution colour
background stored as JPEG 2000, and a high-resolution bitonal ink layer over it
stored as JBIG2. We could read the background and not the ink. Counting filters
by what they encode as page CONTENT put JBIG2 in 20 documents of 3 217, which
is why it had been set aside; counting the images it SHAPES put it in 4 089,
because it is almost never the content — it is the /Mask.

A stencil mask paints where its sample is 0, and applyStencilMask was clearing
the alpha where the stencil painted rather than where it did not. Every
explicit mask in the corpus was therefore showing the exact complement of
itself: on a scanned page, the ink is the only part that was hidden. The test
that covered it asserted the same inversion, so the suite was green. Asked
which half of a two-colour page a mask of eight 0 bits and eight 1 bits paints,
poppler answers the 0 half; so does this now, for a raw mask and a JBIG2 one
alike.

The inversion had been invisible because the two halves cancelled: masks that
could not be decoded took their image with them (#26), and the ones that could
were drawn inside out. Decoding JBIG2 without fixing the mask made 5 of 25
medical scans worse and 2 of them catastrophic — 98% of pixels wrong on the
worst — which is how the second defect was found.

Measured against poppler, first page of 25 medical scans:

  improved 11   unchanged 14   worse 0
  mean differing pixels  0.0083 -> 0.0004
  worst page             0.1087 -> 0.0004

No decoder is written here. The reference is github.com/dkrisman/gobig2, chosen
by decoding 403 JBIG2 masks taken from real scans and comparing every pixel
against poppler's own decoder: it was exact on all 396 it accepted, where the
candidate that decoded the most was exact on 9%. Its seven refusals are a
resource budget, not a defect, and this takes it at its default: a stream it
declines is a stream not drawn, which is the rule the rest of the file follows.
gobig2's limits are process-global, so they are not raised from inside a
library.

100% statement coverage, go vet and -race clean, nine cross-compile targets,
CGO_ENABLED=0.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant