Commit Graph
111 Commits
Author SHA1 Message Date
Grzegorz MartensandClaude Opus 5.5 ade8ebbdb4 M2.6: a run pays for what a stream costs, not what it holds
Four ways round the allowance, each measured by a review of hostile input:

A Flate stream read as zlib that fails at once, and then as raw deflate that
decodes everything before failing, gave nothing back and so was charged
nothing, here and in DecodeBudget: 110 s for a 387 KB file, all decoded. What
the retry decoded now counts, though the data is still dropped as it was.

Fax and 1-bit Flate images paid by the byte for work done by the pixel: 19
bytes can be a blank page of 100 million pixels, about a second each. Every
image decoded here now pays for its pixels first, as JBIG2 already did.

A JPEG paid nothing, and what one asks for doesn't grow with its length: 156
bytes declared 80 megapixels, 72 s for an 8 KB file. A JPEG pays for its
samples before it is decoded, in Optimize and in an export that has to decode
one for its mask.

Merging fragments paid for reading strips and not for the picture it built:
393 s for 1,154 bytes. It pays for the picture before allocating it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-01 00:20:43 +01:00
Grzegorz MartensandClaude Opus 5.5 696728686f M2.6: Optimize's LZW pass spends the run's allowance, and Cancel reaches it
Storing LZW streams with Flate decoded image streams outside the allowance,
with a limit for each stream and no total, and nothing asked whether to go
on: a 593 KB file of 100 images took 113 s with none refused, and images
refused by the image pass were still rewritten. The pass now pays for what
it decodes and for storing and checking it, at 8 steps a byte since Flate
takes up to 78 ns a byte, and streams past that stay LZW.

An image is counted as refused only when it stayed as it was: one packed
before the allowance ran out on its second read was rewritten. doc_reduce
reports images_refused as doc_optimize does, and the app's action says so
in its log. A refused image no longer reads as an unsupported filter:
FilterError::Spent says what happened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 23:26:25 +01:00
Grzegorz MartensandClaude Opus 5.5 083191f970 M2.6: every filter of a run's images pays, whether or not it works
Filters were charged only for what they handed a JBIG2 decoder, once they
had handed it over: a chain that failed, or went past its image's limit,
cost its time and nothing else, and images that weren't JBIG2 weren't
charged at all. A 734 KB file of 200 images of Flate inside Flate took an
image export 111 s, and that grew with the file. It now takes 12 s.

Each byte read and each byte a stage decodes is now a step of the same
allowance, paid as it happens, so no stage runs past what is left. That
holds for every image read through ImageBudget, and a fax image pays for
its rows and its code. JBIG2 globals pay when they have to be read, and
kept ones don't. Optimize's two reads that went round the budget go
through it, and merging fragments shares it.

Optimize reads an image twice, to pack it and to resample it, so a refused
image is now counted once a run rather than once a read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 22:23:06 +01:00
Grzegorz MartensandClaude Opus 5.5 621f17f3b5 M2.6: an image already at the target resolution isn't decoded to find that out
Found by CodeRabbit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 18:07:06 +01:00
Grzegorz MartensandClaude Opus 5.5 a6944cbbdf M2.6: the widest image that can be averaged is decoded
The exclusion's test only looked past the limit, so a mutant moving it by one survived.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 17:43:46 +01:00
Grzegorz MartensandClaude Opus 5.5 199354246b M2.6: redaction reads past /Crypt, and what can't be averaged spends nothing
Redaction checked the raw /Filter array, so /Crypt in a chain removed an image it could have cleared,
though decoding leaves /Crypt out. It leaves it out too.

Decoded only to be resampled, an image that can't be averaged came to None after the decode, having
spent the run's allowance and perhaps been counted as refused. Each such case now stops before it.

Found by CodeRabbit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 17:36:44 +01:00
Grzegorz MartensandClaude Opus 5.5 bd5bcb7f10 M2.6: merge #5472's last fix
The JPEG test's doc comment goes back beside it, past this branch's allowance test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 17:36:07 +01:00
Grzegorz MartensandClaude Opus 5.5 2ad72dcbd1 M11: the JPEG test has its doc comment back
The JBIG2 test went in between the two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 17:32:10 +01:00
Grzegorz MartensandClaude Opus 5.5 4aa732ea91 M2.6: what is left out past the JBIG2 allowance is said, and redaction stops
From a review of who reads a refusal.

The rule is to leave it out and say so, and it held on some paths only. An image whose JBIG2 mask is
refused no longer comes out opaque. The first image past the allowance reads as that and not as
corrupt. A page reports what it left out. The Office warning stopped blaming forms. Redaction refuses
the run, leaving the document as it was: removing a readable image whole would take what no mark
covers with it, unreported.

The 2 GiB an export holds is twice the document if that is more, so a large honest file loses nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 14:13:48 +01:00
Grzegorz MartensandClaude Opus 5.5 0ed339901e M2.6: the JBIG2 images of an operation or a page share one allowance of decoding
One image has its own limit, about 6 s to 17 s, but nothing bounded a file of thousands of images
made to reach it: an operation took that long for each, and an image sharing /JBIG2Globals decodes
them again.

Image::decode_within (hayro-jbig2 patch 11) spends from what a run may still do, starting with its
page's pixels, and charge_reading charges reading a stream and its globals, 4 steps a byte, before
anything is parsed, so an image that fails has paid too. pdfcraft_filters::decode_jbig2_within takes
it, and ImageBudget in cos is that allowance: 2^34 steps, about 1,000 scanned pages, and 2^13 more for
each byte of the file, so a long scan is read whole. An image past it spends all that's left, and
later ones aren't read, globals included.

Optimize leaves such an image as it is and counts it in Report::images_refused. Image export leaves it
out, with the reason, and stops at 2 GiB of files held. Office export shares one allowance, leaves the
picture out and says the page was truncated. Redaction removes it whole, as any image it can't read.

Rendering gives a page's images 2^30 steps between them, inside the watchdog, and each page starts
afresh in Context::new as its other budgets do (hayro-syntax patch 31, hayro-interpret patch 73).
It's a thread-local, unbounded until a page sets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 13:21:45 +01:00
Grzegorz MartensandClaude Opus 5.5 7d422a0998 M11: JBIG2 alone isn't decoded for Group 4 unless it is resampled
An ordinary scan stored as JBIG2 was decoded for Group 4 by default, at 0.26 s a page at 300 ppi,
and came out unchanged: Group 4 of a symbol-coded page was 201,132 bytes against 17,291. Over a
500-page scan that is minutes for nothing. Dropping the guard changed no output, as a cost guard
wouldn't, so its test counts the pixels decoded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 01:39:27 +01:00
Grzegorz MartensandClaude Opus 5.5 d41d4c5eaa Merge the fixes to the PR below into this one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 01:16:58 +01:00
Grzegorz MartensandClaude Opus 5.5 24c6529f77 M11: test: a palette stream is read up to exactly 16 KiB of raw data
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 01:15:52 +01:00
Grzegorz MartensandClaude Opus 5.5 e0e40c6460 Merge the fixes to the PR below into this one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 01:06:26 +01:00
Grzegorz MartensandClaude Opus 5.5 6ad95f0aa4 M11: an indexed image's palette is read once it matters, within 4 KiB; a colour key keeps its image
From a review of the spellings an indexed image can take:
- The palette was read for every 1-bit indexed image, resampled or not, and twice each. Images
  share a table, so 1,000 images sharing one with 16 MiB of raw data took 16 to 60 s, none of
  them resampled. It is now read only for an image about to be resampled, and a table over
  4 KiB is refused before it is copied or decoded, since no palette needs that.
- A colour key can hide the dark sample a tie goes to, so a thin rule of the other was lost: a
  white rule on a hidden black went from 255 to the backdrop's 128. An image with a colour key
  now keeps its resolution. Grey had this since #5426 and is covered too.
- `/Indexed` by reference, and a base of `[/DeviceRGB]`, are the same colour space and are read
  so. `/I`, an inline abbreviation, and a real hival aren't, and such an image is left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 01:05:58 +01:00
Grzegorz MartensandClaude Opus 5.5 fbb1942933 Merge the fixes to the PR below into this one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 00:41:23 +01:00
Grzegorz MartensandClaude Opus 5.5 e2adbe7faa M11: tests: an indexed palette is read by its colours' luma, and only a real palette counts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 00:41:23 +01:00
Grzegorz MartensandClaude Opus 5.5 4d30af00a9 Merge the fixes to the PR below into this one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 00:20:54 +01:00
Grzegorz MartensandClaude Opus 5.5 d1677b4b3f M11: an indexed image also drawn where the optimizer doesn't read keeps its resolution
From CodeRabbit's review: default colour spaces apply to an indexed colour space's
device base, so one in force where an annotation's appearance (or a pattern, or
another image's /Alternates) draws the image may change which palette entry is darker,
and the tie a resampled image keeps. Only black-and-white RGB went through
used_elsewhere; indexed images now do too when downsampling is on, and one used beyond
the page and form resources the walk read keeps its resolution, as such RGB keeps its
colour space.

A grey image stays out of this check, since sample 0 stays dark in a grey space, and #5426's rule
for grey is unchanged: the test now has a grey image drawn on the page and in an annotation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 00:20:10 +01:00
Grzegorz MartensandClaude Opus 5.5 39eb03a0b6 M11: Monochrome Images reads JBIG2 scans: averaged like any, and stored again as JBIG2
#5461 made Document::decoded_image read JBIG2, page exactly the image's size, and
kept the optimizer's monochrome step off it until it could weigh JBIG2 again. Now
one_bit reads JBIG2 last after any filters, within the raster's allowance, and mono
treats it as it does any black-and-white image:
- above the threshold it is averaged, and stored as the compression asks, the
  smallest of what that may store; under Retain it is stored as JBIG2 again, its kind
  of compression kept;
- not resampled, it is stored again as the compression asks when that is smaller: a
  generic region becomes symbols with JBIG2 asked for, and JBIG2 behind ASCIIHex is
  stored again alone (as Group 4 where that is smaller);
- a JBIG2 image stored again refers to no /JBIG2Globals; one whose page isn't its
  size isn't read, and stays.

jbig2_images_are_left_as_they_are becomes jbig2_scans_are_averaged_and_stored_again:
a JBIG2 scan at 600 ppi, alone, behind ASCIIHex and with globals, averages to the
same samples as its Flate copy under each compression (Retain keeping JBIG2); at 300
ppi a generic region of rendered text stays under Group 4 (larger) and Retain and
becomes symbols under JBIG2 (under half the size); behind ASCIIHex it is stored again
alone, as Group 4 where the hex is spaced out; a taller page stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:05:09 +01:00
Grzegorz MartensandClaude Opus 5.5 c412093dc7 M11: an indexed image's palette says which of its samples is dark, so it is averaged too
#5426 left indexed images at their resolution rather than guess which sample is
dark. The palette says: dark_is_one now maps samples 0 and 1 through the decode array
to palette entries (rounded and clipped to hival, ISO 32000-2 §8.9.5.2) and compares
how light those entries are (`lightness`: the grey, or Rec. 601 luma over DeviceRGB).
Only DeviceGray and DeviceRGB bases count, the table a string or a stream (within
4 KiB), directly or by reference: there the palette's values are the colour, and
under /DefaultGray or /DefaultRGB no polarity is asked for. Other bases (CMYK,
calibrated, ICC-based, a resource name), two entries alike, an entry the table
doesn't hold or a hival past 255 still say nothing; separations and DeviceN still
aren't averaged, their tint transforms unread. #5426's tone guard applies as to any
image.

#5426's indexed thin-rule test now checks what it was for: with the palette either
way round and the decode array either way, the averaged image holds the averaged
DeviceGray one's samples (flipped where its samples are) and renders the same. A unit
test covers each base, both orders, an inverting decode array, the weights of luma,
the table by reference and in a stream, and every refusal; the older polarity test's
doc says what is known now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:04:42 +01:00
Grzegorz MartensandClaude Opus 5.5 ea6c329731 M2.6: JBIG2 decoding in pdfcraft-filters; Document::decoded_image reads JBIG2 images
decode_jbig2 sits next to decode_ccitt, through hayro-jbig2 as Export ▸ Images
used it, and shares CCITT's row sink. The JBIG2 page has to be the image's size:
PdfCraft's renderer draws the whole page into the image (black box: a 4-row page
behind /Height 2 fills the image box with all four rows), so the rows of a taller
page aren't the image's. The decoder decodes a page whole, so the page is checked
before it is decoded: that size, within the caller's bound as rows of one bit, and
at most 2^28 pixels (MAX_JBIG2_PIXELS; the decoder keeps a bit a pixel, about 34 MB
there).

Document::decoded_image_within decodes a JBIG2 image as it does a fax image: the
filters before it undone first, then the decoder with the /JBIG2Globals of the
image's own /DecodeParms entry (/Crypt counts in the position; fax_params and the
globals now share last_filter_parms). Globals have a limit of their own,
MAX_JBIG2_GLOBALS (32 MiB, moved from Export), and globals that can't be read are an
error rather than ignored.

Export ▸ Images reads JBIG2 through it, so its own decoder, row sink and DecodeParms
reader go, and hayro-jbig2 leaves pdfcraft-create. JBIG2 behind another filter now
exports, and so copies (Copy Image uses the same function); a page taller than the
image, which Export cut to the image's rows, now skips the image, as one of another
width did. doc_export_all_images' description no longer lists JBIG2 inside other
filters as skipped.

The optimizer's monochrome step read images through decoded_image_within and
relied on JBIG2 failing there: it now leaves JBIG2 images alone explicitly, as
before (a resampled one would come out Flate under Retain), and the README and
parity note list them as not yet handled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:03:06 +01:00
Grzegorz MartensandClaude Opus 5.5 c0e3c6bec5 M11: the docs say which black-and-white images monochrome downsampling averages since #5426
The integration in #5426 made downsampling conservative: dark_is_one answers only for
DeviceGray and image masks (a decode array of two different numbers or none), and not
under /DefaultGray or /DefaultRGB, and a square of four new pixels that each cover both
samples keeps the image at its resolution. The README, the crate docs, mono()'s comment
and the optimize.downsample-images parity note still said separations and indexed
images were averaged, a tie going to full ink or entry 0.

Measured on main: a 600 ppi page of the ragged-text fixture is no longer averaged
(it is with the square guard switched off), so the docs say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:09:02 +01:00
FredApps 6772772440 test: accept preserved CCITT filter arrays in lossless regression 2026-10-30 15:52:18 +01:00
FredApps 7fe33d1cbd test: verify conservative review fixes across rendering and automation 2026-10-30 15:32:44 +01:00
FredApps 4791cacd2c test: qualify conservative tone retention and format review regressions 2026-10-30 14:55:44 +01:00
FredApps d7f3e67141 fix: bound backdrop edits and withhold unsafe integration behavior 2026-10-30 14:45:50 +01:00
FredApps ee71348bc8 fix: bound soft-wrap metadata and preserve unknown backdrop content 2026-10-30 12:47:37 +01:00
FredApps 7d3d4830e5 Integrate remaining six PdfCraft parity PRs with conflict resolutions
Includes #4325 #1952 #4180 #2529 #4320 #4805, preserving their original histories.
Keep paragraph alignment and hard/soft wrap metadata together; retain CCITT/downsampling/RGB ownership guards with bounded JBIG2 export and exact-copy symbol encoding.
Combined interaction regressions added. Validation pending.
2026-10-30 11:02:07 +01:00
Grzegorz MartensandClaude Opus 5.5 2d0a7b2241 M11: black-and-white RGB used beyond the page content read stays RGB
Packing makes an RGB image grey for every use at once, but /DefaultGray and
/DefaultRGB were checked only where the walk reads: page content and forms.
An annotation's appearance, a pattern, a Type3 glyph or a soft mask's group
drawing the same image can have other default colour spaces, so its colours
could change there.

The walk now records, by object number, every reference in the resources it
reads (each /XObject entry, drawn or not, an /XObject dictionary under its
resources, resources under a page, page-tree node or form), reading the
resources of every page and of every node above one, each page and node a
root. An image the walk reached stays covered unless an object reachable
from the trailer refers to it, or to an object on its way from a page, other
than through those references, or it is held by one that isn't covered; an
eight-bit DeviceRGB image that isn't covered stays RGB. So resources, an
/XObject dictionary or a form a page shares with an appearance keep it RGB,
as does a reference with another generation, while LibreOffice's layout (one
resources object for a page, its /Pages node, and a form inside it that
names it again) still packs. The scan runs only when packing is on and there
is such an image.

The images drawn or listed where the defaults are in force are kept by
object number too, so one named with another generation there stays RGB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:57:08 +01:00
Grzegorz MartensandClaude Opus 5.5 fe05b73d08 M11: JBIG2 symbol coding: tests for region numbering; the symbol cap applies to each plan
Review of the previous commit:
- Nothing checked the segment numbers the region split assigns: a
  mutant numbering every region 2 decoded. The many-marks test now
  reads each segment's number, and has a bar too tall to be a symbol,
  so the generic region is numbered after the last text region.
- The decoder's 32,767-symbol limit was checked against all distinct
  marks, before either plan, so an image with more distinct marks lost
  the second plan, which codes only the repeated ones. The limit now
  applies to each plan (MAX_CODED_SYMBOLS); finding marks keeps its
  memory bound of 65,536 (MAX_SYMBOLS), as before the previous commit.
  A test has 32,768 distinct marks above a page of repeated text, which
  encode_jbig2 still symbol codes through the second plan.
- The dense-page optimizer test now checks that it takes several text
  regions, and a compile-time assert keeps segment numbers within a
  byte's reach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 22:37:07 +01:00
Grzegorz MartensandClaude Opus 5.5 deb70c92ca M11: JBIG2 symbol coding: at most 10,000 copies a text region and 32,767 symbols
A review found that a dense page came out as a stream PdfCraft's own
renderer refuses, so the page drew blank after optimizing: hayro-jbig2
decodes a text region of at most 10,000 symbol instances, and refuses
32,768 symbols or more. The stream was valid (Poppler decoded it), but
every mark went into one text region and up to 65,536 symbols were kept.
A 300 dpi letter page of the optimizer test's own text crossed it.

A page with more than 10,000 marks is now placed by several text
regions, each over a band of rows and all referring to the one symbol
dictionary, which each region but the last keeps retained (7.2.4). Past
32,767 distinct symbols the image is coded generically.

New tests: 25,000 copies placed by three regions, which decode; the
symbol caps decoded, not just reached; and in the optimizer, a whole
letter page of rendered text that looks the same as JBIG2 symbols (it
drew blank before). The READMEs say "still looked at" for the caps, and
that the scans figure is the six pages' total (20% on one page).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 22:37:07 +01:00
Grzegorz MartensandClaude Opus 5.5 8eeafe3309 M11: JBIG2 symbol coding: tests for tall marks, the other corner and the symbol caps
A second review found two rules tested one way only: a mark 257 tall
(not just wide) is left to the generic region, which also bounds the
bitmap allocated for each mark, and runs joined at the lower-left corner
are one mark. The test now has a 1 x 257 column and an anti-diagonal.
The caps on distinct symbols (65,536) and their pixels (2^24) are tested
at and past them, as the run and row caps are.

The scans figure now names its noise: with 2% or more of the letters'
edge pixels flipped, symbols save at most 8% over the generic region.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 22:37:07 +01:00
Grzegorz MartensandClaude Opus 5.5 0d40594cf9 M11: JBIG2 symbol coding: tests for padding, the 32-bit range and the mark rules
Review found behaviour the tests let break unnoticed (each was a
surviving mutant, and each new test fails on it):
- black padding bits: a run reaching past the row would stop symbol
  coding on many scans;
- Annex A's 32-bit range and PREV at exactly 256, where a decision would
  go uncoded: coordinates past 4,435 on a 12,000-pixel-wide and a
  10,050-row page, and the contexts of 4436's coding;
- marks joined at a corner are one mark; 256 pixels a side is a symbol,
  257 is left to the generic region;
- the run and row caps, at and past them.

The roadmap's status line for this change now comes first, and the
"about a third smaller than Group 4 on scans" figure is replaced by what
was measured: 27-33% on the same pages with scan-like noise added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 22:37:07 +01:00
Grzegorz MartensandClaude Opus 5.5 9edd08ce07 M11: JBIG2 symbol coding for black-and-white images
pdfcraft_filters::encode_jbig2 stores each distinct mark of a 1-bit
image (8-connected black pixels, at most 256 a side) once, as a symbol
in a symbol dictionary, and places its exact copies with an immediate
lossless text region that refers to it. Larger marks, and in a second
plan marks that occur once, go to a generic region over their box. It
keeps whichever of that and the generic coding (encode_jbig2_generic,
unchanged) is smaller. Only exact copies share a symbol, so the stream
decodes to the same bits.

Written from the JBIG committee's ISO/IEC 14492 Final Committee Draft:
6.4 and 6.5 (text region and symbol dictionary decoding), 7.2, 7.4.2
and 7.4.3 (segment headers and syntax) and Annex A (the integer coders,
checked against its worked example and Table A.1).

On six pages of rendered 300 dpi text the output is 28% of the generic
coding's size and 17% of Group 4's, at about 100 ms a page; hayro-jbig2
and Poppler (pdfimages) both decode it to the original bits. Scans,
whose copies of a letter are seldom identical, gain little: that needs
refinement coding. A dictionary shared between pages (/JBIG2Globals)
is a further step.

The optimizer's Monochrome Images > JBIG2 (lossless) and doc_optimize
mono jbig2 use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 22:37:07 +01:00
Grzegorz MartensandClaude Opus 5.5 952eb344e9 M11: Group 3 on bytes through the Group 4 step: tests against the picture
group_3_fax_images_become_group_4 coded its rows only off bytes. It now
also stores again rows on bytes: Modified Huffman (no end-of-line codes),
and codes whose fill ends each on a byte, as TIFF writes them, at /K 0 and
1 without /EndOfLine, which reading by that flag misread (the test fails
with that rule). Each becomes Group 4, smaller, decoding to the picture.

The page is compared with the picture in Flate rather than with itself
before: PdfCraft's bootstrap renderer draws TIFF's layout (the fill
ending each code on a byte) as other rows, at every /K and with
/EndOfLine or not (black box), so storing it as Group 4 corrects what
PdfCraft shows rather than keeping it. (Redaction removes such images
whole, for that reason, since #4384's "redaction removes Group 3 on
bytes whole".)

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 20:48:03 +01:00
Grzegorz MartensandClaude Opus 5.5 abae2472d3 M11: the crate docs, doc_optimize's mono and the M11 row name what the black-and-white step does now
The optimize crate doc and MonoSettings named only 1-bit, CCITT and
eight-bit grey images; doc_optimize's mono said Group 4 only; the M11
milestone row stopped at Group 4 compression. They now include
downsampling (halftones aside), Group 3 to Group 4, 1-bit Flate, and
black-and-white RGB and indexed images that use only palette entries 0
and 1, packed to one bit. That last isn't "two-entry", as the roadmap and
the downsampling parity note had it: an indexed image packs whatever its
palette's size, when its samples are 0 and 1. The mono-compression note
says byte-aligned Group 3 with end-of-line codes but none before the
first row is stored again as PdfCraft draws it, which at /K 0 may be
other rows than were coded.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 20:48:03 +01:00
Grzegorz MartensandClaude Opus 5.5 2cbbae458c M11: decode arrays read as numbers however written; Retain keeps a Group 3 halftone
- Packing compared an indexed image's decode array, and RGB's pairs, as
  written: [0 255.0], an entry by reference or a real among RGB's pairs
  stopped it, though they mean the same as integers. Each entry is
  resolved and read as a number now, as is_identity_decode and
  dark_is_one read them; RGB's pair is written back direct.
- A test holds that Retain stores nothing again it doesn't resample: a
  Group 3 halftone above 450 ppi, which isn't averaged down, stays Group
  3 byte for byte (the guard's || as && passed every test).

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 20:48:03 +01:00
Grzegorz MartensandClaude Opus 5.5 740bb874a4 M11: say damaged Group 3 with end-of-line codes is stored as the decoder recovers it
With end-of-line codes, the CCITT decoder picks a bad row up again at
the next code and reports no damage (an invalid code, a row of too many
pixels: probed as a black box), so such an image is stored again as
Group 4 the way it decodes. Without them, damage stops the decoding,
too few rows come out, and the image is left alone. Telling damage
apart would take a Group 3 parser of our own; the optimize README and
the mono-compression parity note say so instead.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 20:48:03 +01:00
Grzegorz MartensandClaude Opus 5.5 cb1e573c08 M11: tests for packing in other one-component spaces, mixed Group 3 and Flate after resampling
Mutation testing found three behaviours no test held:
- eight-bit black-and-white in calibrated grey, an ICC profile of one
  component or a separation is packed, each keeping its colour space,
  and calibrated RGB of only 0 and 1 isn't (taking every array colour
  space for an indexed one passed every test);
- mixed Group 3 (/K 1, with end-of-line codes) is stored again as
  Group 4 (taking /K above 0 for Group 4 passed);
- a resampled Group 4 image of vertical stripes is stored as 1-bit Flate,
  which is smaller (ruling Flate out for any CCITT image passed).

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 20:48:03 +01:00
Grzegorz MartensandClaude Opus 5.5 450c327c6b M11: black-and-white RGB stays RGB where grey would be drawn otherwise
- Packed to one bit, black-and-white DeviceRGB became DeviceGray. Where
  /DefaultGray or /DefaultRGB is in force (ISO 32000-2 §8.6.5.6), that
  drew it otherwise: through DefaultGray as grey, or no longer through
  DefaultRGB. The page walk now notes the images drawn, on any use, where
  a page's or form's resources (an enclosing one's included) name
  either, and those stay RGB. When a use couldn't be followed, every
  image counts as such.
- One whose soft mask has a /Matte stays RGB too: the matte has one
  number for each of the parent's components, three, and grey has one.
- The optimize README, the mono-compression parity note and doc_reduce's
  description said Group 3 to Group 4 wasn't done yet, that only images
  packed from eight bits could become Flate, and that the colour space
  and decode array always stay. They now match the code.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 20:48:03 +01:00
Grzegorz MartensandClaude Opus 5.5 76236edf1e M11: the optimizer packs black-and-white RGB and indexed images to one bit
Monochrome Images only took eight-bit grey of only black and white
besides 1-bit images, so the same scan written as RGB, or as an indexed
image of two palette entries, stayed at eight bits (and RGB went to the
colour settings, as JPEG). Now these are packed to one bit first:

- eight-bit DeviceRGB whose every pixel is black or white becomes
  DeviceGray; a decode array the same for each component becomes that
  pair, another stops it. Calibrated RGB and RGB with a profile stay.
- an indexed image of 2, 4 or 8 bits that uses only palette entries 0
  and 1 keeps its palette, whatever the two colours; a decode array
  other than the default stops it.

Colour-key masks still stop all of them, and Retain leaves them to the
grey and colour settings. They are decoded through /DecodeParms given
by reference too (Document::with_direct_parms), and when downsampled a
tie goes to the dark sample of the image as packed: packed RGB is grey,
with grey's decode pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
2026-10-29 20:48:03 +01:00
Grzegorz MartensandClaude Opus 5.5 e221d552f9 M11: the optimizer stores Group 3 fax images as Group 4 when smaller
A CCITT fax image that wasn't resampled was left as it was, so Group 3
images (the 1-D and 2-D codings fax software writes) stayed Group 3.
Group 4 codes each row against the one above and is usually much
smaller. Now only Group 4 alone skips decoding; Group 3 is stored again
as Group 4 when that is smaller as stored, and Retain still keeps it.

It reads an image's CCITT parameters (its /DecodeParms entry with Table
11's defaults) through pdfcraft_cos::Document::fax_params, from #4384.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
2026-10-29 20:48:03 +01:00
Grzegorz MartensandClaude Opus 5.5 aaa3d815c1 M11: say where Group 3 with no code before the first row is written back as read
decode_ccitt reads byte-aligned Group 3 whose data has end-of-line codes
but none before the first row (not as T.4 sends them) on bytes. At /K 0
with the fill ending each code on a byte, about one small picture in
five is then read as other rows; at fax width almost none are, as such
pages come out short and are refused. Export already said so. The
optimizer, when it resamples such an image or stores it again as Group 4
from behind another filter, and redaction, when it clears pixels in one,
write those rows back, and nothing said that.

Black-box, PdfCraft's renderer draws such an image exactly as
decode_ccitt reads it (all 42 misread of 200 seeded pictures, behind
ASCII85Decode too), so the written image is what PdfCraft showed before.
Refusing such data instead (a code after nearly every row but none
first) would also refuse some ordinary Modified Huffman images, whose
rows can meet 11 zeros by chance (0.5 to 5% of small ones in a
black-box probe), so the limit is stated rather than guarded: in
decode_ccitt's doc, the filters, optimize and redact READMEs, and the
core.image-filters and mono-compression notes. The optimize README's and
the mono-compression note's "not yet: Group 3 to Group 4 without
resampling" now say a lone Group 3 image: one behind another filter is
stored again as Group 4.

Also from the review: docs/roadmap.md keeps #4335's status line under
this PR's, rather than replacing it; the filters README's tests and the
core.image-filters evidence name the Group 3 layout test; the filters
crate's description names Group 3 and 4 decoding.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 20:48:03 +01:00
Grzegorz MartensandClaude Opus 5.5 5f3fde33d6 M11: tests for what Monochrome Images keeps, and for the fax bounds; Retain's wording
Mutation testing found behaviour no test held, now covered:
- a Group 4 halftone above 450 ppi isn't resampled and stays byte for
  byte, alone by default and behind ASCIIHexDecode with Retain;
- under Retain a resampled Flate scan stays Flate, though Group 4 would
  be smaller (ragged bands);
- sixteen-bit grey of only 0x0000 and 0xFFFF keeps its bytes;
- a busy Group 4 image behind ASCIIHexDecode, whose code is over three
  times its raster, decodes within the fax bound and is stored again as
  Group 4 alone. Redaction's busy-fax test is that busy again: since the
  bound for other images is three times the raster, the old checkerboard
  fitted it, and dropping the fax bound passed.
The redaction README says three times, and doc_optimize's description
says what Retain keeps: CCITT stays CCITT, anything else becomes Flate.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 20:48:03 +01:00
Grzegorz MartensandClaude Opus 5.5 d92ec2101a M11: monochrome downsampling's docs: halftones, the column cap, mask streams, what is left alone
- The optimize README, the crate doc and both parity notes say a
  halftone or dither, and an image wider than a fax row, isn't averaged
  down.
- The README's "Left alone" list is the colour and grey settings'; image
  masks, decode arrays and CCITT go to Monochrome Images now. Its
  monochrome "Not yet" names an image's own /SMask or /Mask image again
  (only images drawn on a page are visited), as the parity note does;
  the parity note's row limit is Group 4's.
- docs/architecture.md says CCITT decodes through pdfcraft-filters for
  export, redaction and the optimizer, not only the renderer.
- docs/target-app-parity.md no longer lists monochrome downsampling as
  missing.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 20:48:03 +01:00
Grzegorz MartensandClaude Opus 5.5 ff8a07f283 M11: monochrome downsampling leaves halftones alone; bounds; an indexed tie under an inverting decode
- Halftones and dithers: most of their new pixels cover both black and
  white, and taking the sample that covers more turned their tones
  solid (a 50% screen black, a 25% one white). An image whose new pixels
  mostly cover both is now left at its resolution; text covers both only
  along its edges and is still averaged down. downsample_bits says how
  many new pixels cover both.
- A counter for each column: an image wider than a fax row
  (MAX_CCITT_COLUMNS) isn't averaged, so a strip of 80 million columns
  can't make the counters 640 MB.
- A fax image's decoding is bounded at about a byte a pixel, as
  redaction's is (decoded_image_within), not 1 GiB; behind a Flate bomb
  it is left alone.
- An indexed image's tie went to sample 0, which an inverting decode
  array maps to entry 1, though the docs say entry 0. It goes to the
  sample that decodes to entry 0 now (ISO 32000-2 §8.9.5.2).

Each has a test that fails without it.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 20:48:03 +01:00
Grzegorz MartensandClaude Opus 5.5 8bd156a4ac M11: Reduce File Size's descriptions say black-and-white images go to 300 ppi
doc_reduce, the engine's reduced_bytes doc, the optimize README's
presets and the optimize.reduce parity note still said every image
above 225 ppi goes to 150 ppi; black-and-white images go to 300 ppi
above 450 ppi, as CCITT Group 4 when smaller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:48:03 +01:00
Grzegorz MartensandClaude Opus 5.5 e3b9e175b5 M11: redaction clears pixels in CCITT fax images instead of removing them whole
Create PDF and the optimizer now store black-and-white scans as Group 4,
and redaction couldn't decode CCITT, so redacting one word on such a
scan removed the whole page image. A CCITT image (the last filter,
after any others) now decodes through Document::decoded_image and is
cleared in a Flate copy like any other image; one that can't be decoded
in full is still removed whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:48:03 +01:00
Grzegorz MartensandClaude Opus 5.5 9fca1d44f0 M11: monochrome downsampling: say an indexed image's tie goes to entry 0, its palette unread
The README said an indexed image's palette decides which entry is the
dark one on a tie, but dark_is_one never reads the palette: entry 0
wins whatever its colour. The README, the function's comment and the
parity note now say so (from FredApps' review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:48:03 +01:00