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.
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>
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
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
- 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
- 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
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
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
- 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
- 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
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>
A 2 × 2 tie always gave sample 0, black in grey and the painted part of
an image mask with the default decode array. With an inverting one,
[1 0], 0 is white or unpainted, and in a separation 0 is no ink, so a
line half a new pixel wide could vanish. dark_is_one says which sample
is dark from the colour space and decode array: 0 in grey and of a
mask, 1 in a separation or a DeviceN of one colourant, each the other
way round when the decode array inverts; an indexed image keeps entry
0, as its palette, not the decode array, says which is darker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
The PDF Optimizer and Reduce File Size downsampled colour and grey images
but not black-and-white ones. Settings::mono is now MonoSettings {
downsample, target_ppi, above_ppi, compression }, 300 ppi above 450 ppi
and CCITT Group 4 by default (Monochrome Images ▸ Downsample to;
doc_optimize mono: { downsample, ppi, above_ppi, compression }).
A 1-bit image or image mask (no filter, general-purpose filters, or CCITT
fax after any of them) and eight-bit grey of only black and white are averaged by
area: each new pixel takes the sample covering more of it, a tie 0, so a
dark line half a new pixel wide stays. They are stored as Group 4, or as
1-bit Flate when that is smaller; with Retain a resampled image keeps its
kind of compression. Images with a soft mask keep their size.
CCITT decoding moves from Export ▸ Images into pdfcraft-filters
(decode_ccitt, CcittParams, through hayro-ccitt), and
pdfcraft_cos::Document::decoded_image reads a fax image's /DecodeParms
for it (undoing filters before CCITT, such as ASCII85Decode, with any
/DecodeParms given by reference made direct first), so export
and the optimizer share one reader, and Group 4 scans can be
downsampled. A 600 ppi text page went from 79 KB to 40 KB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
An eight-bit grey image of only black and white was packed to one bit
and stored, and the grey settings never saw it. For a dithered scan that
is bigger than what Reduce File Size made of it before, downsampled to
JPEG: 1-bit Flate can't shrink the dots (a 1200 × 1500 dither at 300 ppi
was 157 KB packed, 67 KB as before). Both are made now, and the smaller
as stored wins, counting a soft mask resampled with the image. Clean
text still packs to one bit. The packing also decodes no further than
its samples can need, as the other image steps do now.
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
Packing eight-bit black-and-white grey to one bit decoded with
Stream::decoded, which ignores /DecodeParms given by reference. With PNG
predictor 10 each row then kept its tag byte, a 0, so the shifted rows
still passed as black and white and were packed and stored skewed. It
now decodes through Document::with_direct_parms (#4304), and the test
of images with their parameters by reference covers this case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
A CCITT image whose /K, /Columns or /Rows is an indirect integer was read
with the defaults (K 0, 1728 columns), and one whose /Rows is below its
/Height was cut short and skipped. The numbers are now resolved, and the
image is decoded up to its /Height rows whatever /Rows says, still bounded
by the size the export checked (review of #4335).
The doc comment of the optimizer's bilevel step now says what Retain
leaves to the grey settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
An eight-bit grey image whose samples are all 0 or 255 is packed to one
bit per pixel and then stored as Monochrome Images asks (Group 4, or
JBIG2 with #4320), when that is smaller as stored; its decode array
stays. Indexed images, a colour-key /Mask and Retain are left alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
When the optimizer stores an image as JBIG2 (PDF 1.4), it raises the
catalog's /Version for an older file. It read /Version only when it was
direct: a catalog saying 1.7 by reference looked like no version at all,
and was set to /1.4, lowering the document. It is now resolved first.
Found in pre-push review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
pdfcraft_filters::encode_jbig2_generic writes 1-bit rows as a JBIG2
stream for JBIG2Decode (embedded organisation: a page information
segment and an immediate lossless generic region, no globals): template
0 with its nominal AT pixels, typical prediction, and the MQ arithmetic
coder (mq.rs), written from the JBIG committee's ISO/IEC 14492 Final
Committee Draft. The coder reproduces its Annex H.2 test sequence byte
for byte; output round-trips through hayro-jbig2, and Poppler renders
it the same as the original.
The optimizer's Monochrome Images gets JBIG2 (lossless) beside CCITT
Group 4 (still the default), doc_optimize mono jbig2; a document below
PDF 1.4 gets /Version /1.4 in its catalog. On 300 dpi text JBIG2 is
about a third smaller than Group 4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
The optimizer's images decode no further than twice their samples plus
a byte a row, meant for ASCIIHex text under Flate; but hex text broken
into lines, as it usually is, is a little more than twice, so such an
image was left alone as if it were a decompression bomb (a 300 dpi
letter scan stayed Flate). It is three bytes for each now, a separator
for every two digits, and a test stores both images as hex text in
lines of 64 digits.
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
A test now covers what qualifies and what doesn't: an ICC profile of
one component and a DeviceN of one colourant convert, three components
and two colourants (more than a bit a pixel) don't; an image mask
converts with /BitsPerComponent 1 given, not with 8; an image of exactly
80 million pixels converts, one row more doesn't. The byte totals are
checked exactly. Each of those rules could be broken before with no
test failing (cargo mutants). one_component's comment now says /G
counts with DeviceGray, as the code does.
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
A 1-bit image was decoded for Group 4 up to MAX_DECODED (1 GiB), so a
few kilobytes of Flate could cost a gigabyte, and the same held for the
samples of a colour or grey image read to resample it (pixels). Both now
stop at twice the samples plus a byte a row and 4 KiB, as redaction
does; data past that can only be a decompression bomb, and the image is
left alone.
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
Stream::decoded reads only direct parameter dictionaries, so an image
whose /DecodeParms is a reference (whole, or as array entries) was
decoded without its predictor. For a 1-bit image with a PNG predictor
the rows came out shifted by their tag bytes, Group 4 of that was still
smaller, and the picture was replaced by a skewed one; resampling and
merging image strips read the same way.
pdfcraft_cos::Document::with_direct_parms gives the stream with the
parameters its filters use made direct: only the entries at the
filters' places, and none for a chain longer than any decoder takes,
so at most MAX_CHAIN objects are copied. The optimizer's pixels() and
the Group 4 step decode through it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
A bare /Indexed, /I or /CalGray is no colour space on its own, so it can
only name a resource, which might be RGB: such an image is now left
alone instead of being read as one bit per pixel. doc_optimize refuses a
mono that isn't an object or a compression that isn't a known name,
instead of reading it as the default (from CodeRabbit's review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
pdfcraft_filters::encode_g4 encodes packed 1-bit rows as Group 4 (ITU-T
T.6, /K -1) through the fax crate (MIT, already in the tree via tiff),
with rows capped at 32,767 samples so its 16-bit positions can't
overflow; round trips are checked against hayro-ccitt.
The optimizer (Settings::mono, CCITT G4 by default, so Reduce File Size
too) re-encodes a 1-bit image of one colour component, or an image
mask, stored with no filter or general-purpose ones, when that is
smaller as stored. The samples decode to the same bits, so colour
space, decode array and masks stay. PDF Optimizer gets a Monochrome
Images row and doc_optimize a mono option. optimize.mono-compression
goes from planned to partial (JBIG2 and monochrome downsampling not
yet).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
Clean Up removes named destinations that nothing in the document refers
to. referenced_names counted the /D of GoTo, GoToR and GoToE actions
alike. The /D of a GoToR or GoToE action names a destination in another
document, so a local destination with the same name was kept although
nothing in the document used it.
Only a GoTo's /D now counts as a use. A /Dest key and a GoTo's /D are
read as before.
Test: links with a GoTo, a GoToR and a GoToE action name three
destinations; optimize() keeps the one the GoTo uses and removes the
other two.
ROADMAP.md: kept both progress lines.
crates/optimize/README.md: PR's decoder sentence plus the target-version section from main.
Cargo.lock: auto-merged.
Conflicts: ATTRIBUTION.toml: took main's side (hashes recomputed after the merges). crates/optimize/src/lib.rs, crates/automation/src/lib.rs, tools.rs: Settings and tool schema keep object_streams (#3227) and lzw_to_flate (#4181); doc_optimize description names both options. crates/optimize/tests/optimize.rs: both sides' imports and tests kept.
Conflicts: docs/roadmap.md: all status lines kept, M10 row from main, M11 row with #3227's two sentences. parity/acrobat-features.toml: save-with-object-streams entry from #3227 with its test paths under tests/it/.
Reduce File Size removes named destinations that nothing refers to. It
collected the referenced names by reading /Dest and a GoTo's /D
directly, so when a link's /Dest was an indirect reference to the name
string (valid PDF) rather than the string itself, the name was not
counted. The destination it used was then treated as unreferenced and
deleted, silently breaking the link.
referenced_names now resolves /Dest and /D before reading the name, so
an indirectly referenced destination is kept.
Test: a link whose /Dest is a reference to the name string keeps its
destination through optimize(); an unused destination is still removed.
Reported-by: BS.ehlas.cz
Optimize reads a JPEG's header again for every group of fragments it tries,
and one image can be drawn many times. The scan skipped stray bytes between
segments one at a time, so a JPEG with megabytes of them after SOI cost a
full walk on every call: 64 references to one such image delayed the first
progress call by seconds.
The scan now treats a stray byte as an unreadable header, which leaves the
image as it is, and stops after 4096 markers and fill bytes. Each step jumps
over a whole segment, so the work no longer grows with the stream's size.
Redaction reads page content and form XObjects strictly (upstream) and
within one page's decode budget (this branch): both now go through
DecodeBudget::decode_strict.
A run of images that is not one picture was searched again from each of
its groups, collecting the rest of the run every time, so many images drawn
over one another made a page quadratic. The search reads at most 16 groups
per content operator (at least 65 536) and leaves the rest of the page as
it is once that is spent.
The pixel reader capped the image size the dictionary gives, then decoded
the JPEG at the size its header gives and compared afterwards, so a small
image dictionary over a huge JPEG allocated the huge one first. The reader
takes the size from the JPEG header and refuses a mismatch before decoding.
The fragment merge multiplied the merged width by a height cast from a
float without checking either fit. A tiny fragment rectangle gives a huge
height: the multiplication overflowed (a panic in debug builds, a wrap past
the pixel limit in release builds) and the u32 cast saturated, sizing an
enormous allocation. The height must now fit u32 before the pixel limit is
checked, and the buffer size uses checked multiplication.
Image measurement and fragment merging decoded every /Contents entry
and every form occurrence with a fresh 1 GiB allowance, so repeated
references to one compressed stream multiplied memory. Both charge a
per-page DecodeBudget. A page whose content does not decode within it
is left unmerged, and its images are not resampled, since a use may be
missed; fragment merging no longer rewrites a page from content it
could not fully decode.
ROADMAP.md: progress-log line added at the top
docs/roadmap.md: the M11 note applied onto main's row (which #3622 extended)
crates/optimize/tests/optimize.rs: new tests appended after main's
Optimize decoded DCT images with the generic JPEG decoder, which converts
three components from YCbCr to RGB whenever the JPEG itself doesn't say
otherwise, and then dropped /DecodeParms. An RGB JPEG stored untransformed
(/ColorTransform 0 and no Adobe marker) therefore came out in other colours
when recompressed, resampled or merged from strips.
The samples are now read with the colour transform ISO 32000-2 §7.4.8
gives them: an Adobe APP14 marker's flag, else /ColorTransform (default 1).
Untransformed samples are taken as stored, and the result is written as a
plain YCbCr JPEG (or Flate), which needs no /DecodeParms. When the marker
and /ColorTransform disagree, a value is out of range, the parameters are
not paired with the filter as §7.4 pairs them, or the decoder reads the
components otherwise (named R, G and B), the image is left as it is.
The tests draw each image with PdfCraft's renderer before and after
Optimize: RGB JPEGs with and without an Adobe marker under each
/ColorTransform, CMYK JPEGs (still left alone) and JPEG strips merged into
one picture.
Part of #1201.
* fix(optimize): count what form fields and comments draw with by kind
The space audit claimed the AcroForm and annotations before any page
resources, so the fonts in /DR and in widget and comment appearance
streams were counted as Forms or Comments. A fillable form with an
embedded field font showed mostly "Forms" and almost no "Fonts".
Resources are now sorted by kind first, wherever they are used: page
resources, the form's default resources and every annotation
appearance (one stream or a dictionary of states). A form XObject's
other entries now count as X Object Forms instead of overhead.
Sorting nested forms' resources recursed once per form, so a long
chain of nested forms overflowed the stack. The recursion is now
bounded at 64 levels; deeper forms are still counted as X Object
Forms through the iterative walk.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): line up the Space Audit columns and say what it shows
The percentages were drawn at the dialog's far edge, away from their
header and outside the striped rows: egui gives a grid's last column
the rest of the window, and the right-to-left layout used to
right-align the numbers filled all of it. Every cell is now laid out
first and given exactly its column's width; numeric columns and
their headers are right-aligned, and the description column takes
the rest, so the stripes span the dialog.
A caption under the title says the table is what takes up the file's
space now (not what optimizing would save), translated in every
catalogue. The parity notes no longer call the space audit missing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Refresh catalog hashes after merging main
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>