* M11: JBIG2 images share a symbol dictionary, up to 16 at a time
With Monochrome Images set to JBIG2, Optimize stored each image's
letters in a symbol dictionary of its own, so a document of N pages
carried its font's shapes N times. Now up to 16 images the run stores
as JBIG2 share one dictionary in a /JBIG2Globals stream, holding the
marks found in more than one of them; each image's text regions refer
to it first, then to a dictionary of its own for the marks only it
repeats. An image is stored that way when it comes out smaller, and a
group only when what its images save is more than the globals take.
- filters: encode_jbig2_shared codes a group: the globals are a symbol
dictionary segment numbered 0 and associated with no page (7.2.6);
each page numbers its segments from 1, as a segment refers only to
lower numbers (7.2.5), and the shared symbols' IDs come first, as the
dictionaries are referred to in that order (Table 31, SBSYMS). At
most 16,383 shared marks, those in the most images, so a page keeps
room for its own under the decoder's 32,767.
- optimize: the run holds the 1-bit samples of the images it stores as
JBIG2 (16 at most, 64 MiB at most) and stores them again sharing,
weighing each with the longest reference the globals could have.
Eight pages of 10 pt text rendered at 300 dpi take 41% less in
Helvetica and 42% in Times than coded alone, 10% in Courier, and 46%
in Helvetica at 600 dpi; groups of 2, 4, 16 and 32 save 19%, 33%, 45%
and 47%. Redacting one image stores the others of its group again
without the dictionary, so 16 keeps that to 15 images.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: test what the mutants found untested in shared JBIG2 dictionaries
- filters: the group goes through encode_jbig2_shared, which refuses it
for an image the encoder refuses; two marks in common are enough to
share; past the 65,536 distinct marks a group holds, a mark first seen
later isn't shared while those held count every image.
- optimize: optimizing again keeps both the shared pages and the pages
coded alone as they are; under Retain, averaged JBIG2 pages share
nothing; a last group of two shares.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: only images redaction can store again share a JBIG2 dictionary
Redacting one image of a group stores the others again without the
dictionary, which can hold the shapes of marks under the area, and
refuses the redaction for one it can't: it takes image masks and
images of one component as its own reading counts them (no DeviceN,
the family named directly), and at most 64 Mi pixels. Optimize took
DeviceN of one colourant and up to 80 million pixels, so such an image
in a group made any redaction of the others fail, where before it was
coded alone and the same redaction worked (the Consumers review).
Only an image mask or a DeviceGray image named directly, of at most
2^26 pixels, now joins a group; others are coded alone. An engine test
optimizes three pages, two DeviceGray and one DeviceN, and redacts the
first: the DeviceN page stays alone, the second is stored again, and
no image names the dictionary after a save.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: optimize and redaction share one limit on the images redaction stores again
The second Consumers review: Optimize's limit on images that share a
JBIG2 dictionary copied redaction's private 64 Mi, with nothing tying
the two, so a lower one in redaction would bring back refused
redactions with no test going red. Both now read
pdfcraft_cos::MAX_REDACTED_IMAGE_PIXELS. The engine test adds an image
mask, the other kind that shares, so a redaction that stopped storing
either kind again fails it. doc_optimize's description, the README,
the parity note and ROADMAP say which images share (named directly).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: a JBIG2 group is coded before its samples would pass the limit
CodeRabbit on #5910: hold added an image to the group and then checked
SHARED_BYTES, so a group could pass the 64 MiB it is documented to stay
within by its last image, which can itself be up to 64 MiB (an image
that shares has at most 2^26 pixels, and as its rows are whole bytes,
at most that many bytes; a 1-pixel-wide one takes them all). hold_in
codes the group first when an image would take it past the limit, and
after when it has 16 images; with room for 10 bytes, images of 4 go two
to a group, where the old order made one of 12. CodeRabbit's follow-up
too: the 18-image test decodes every image and compares it with its
samples, across the boundary between the groups.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* optimize: count the globals' object framing; filters: test the shared coder's size limit
- A JBIG2 group shares a dictionary only when it saves more than the
globals' dictionary and data plus NEW_OBJECT (96 bytes: the object's
framing, its xref entry, and an encrypted file's IV and padding), so a
group that saves less than that no longer makes the file larger.
- encode_jbig2_shared is Unsupported for an image taller than the
decoder takes and codes one at the bound.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* filters: drop the unused rows binding in encode_shared
page() no longer takes the rows since #5511.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* filters: the shared coder's at-bound test says what it returns
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* M1: a crypt filter selected by reference is used
A stream can name its own crypt filter, and every part of that can be indirect. cos read only the
direct forms, and took anything else for no name, which is /Identity. So a stream selecting a filter
by reference was saved in the clear in a file meant to be encrypted, and in a file from elsewhere
its ciphertext was read as data. The /Encrypt dictionary's /StmF, /StrF, /EFF, /CFM, /Filter,
/EncryptMetadata and a filter's /Length had the same fault, and /StmF by reference left every
stream undecrypted. A stream's /Type and a signature dictionary's are read through references too.
transform now reads these from the document, so a full save encrypts before it renumbers: the
references in what it reads have to be the document's. Dropping a crypt filter that new protection
doesn't define comes before the walk, so what it copies out of a reference is encrypted too.
Loading can re-enter here, and LoadGuard ends a cycle, which reads as absent. A chain longer than
any decoder takes names no filter: its entries would be followed for every stream sharing it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M2: a stream with a crypt filter of its own is drawn
The renderer decrypted every stream with the document's filter and then failed on /Crypt as a filter
it couldn't decode, so a stream naming its own filter read as nothing, however it was spelled, and a
page whose content was such a stream stayed blank. It now uses the filter the stream names, /Identity
without one, and /Crypt is nothing further to decode (hayro-syntax patch 30).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M1: tests for a crypt filter's place among the others
A lone parameter dictionary is the first filter's, so /Crypt behind another filter has no name of
its own, and an array too short for /Crypt's entry stays as it is when the filter is dropped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M1: encryption reads every legal form, and a full save keeps them
From a review of the crypt filter fix.
The renderer read a stream's own filter from the form /DecodeParms had to match the form of /Filter,
so [/Crypt] with a lone dictionary, an everyday spelling, read as /Identity and the page stayed
blank. Parameters are now taken in either form, which also gives [/FlateDecode] its predictor.
It also refused a V4 file without /StmF or /StrF, which are optional, and used a /CF entry named
/Identity, which is never one. A chain past what cos decodes names no filter there, so it names none
here either (hayro-syntax patch 30).
A full save lost what a direct /Encrypt refers to, and kept its references under the old numbers, so
nothing in the file read afterwards. /O or /U given by reference were decrypted and encrypted again
under a new number, so the file it wrote no longer opened. What /Encrypt and /ID refer to now stays
as stored. /ID by reference, which has to be direct, is read, and written direct.
A signature dictionary is known by a /ByteRange given by reference, and a null or dangling /Type or
/Subtype is an absent one. References to references are followed in Document::resolve, so /Name by
way of two no longer saves a stream in the clear. New protection drops every /Crypt in a chain.
An object stream's /DecodeParms can be given by reference.
Reading what decides how an object is treated can load more objects, each doing the same, which a
file could make a tree that doubles with every level once the cache lets go of it. What is loaded
for one of these no longer follows its own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M1: what a read follows is bounded by a budget, and nothing shared is copied per stream
From a review of the fixes before this, all in them.
A flag that kept what is loaded for a selector from following its own stopped the fan-out, but a
legal file lost object stream members to it: read first through a selector, an object stream with
parameters by reference was kept undecoded, and a full save wrote nulls without saying so. A budget
replaces it: one read or write of an object, with all it loads on the way, follows up to 32
references, which is more than any object needs when all of it is by reference. Past that they
read as absent, so a file's ladder of selectors still stops, and nothing a real file holds is lost.
Finding out whether a dictionary is a signature's loaded objects for every dictionary of an object,
and a load that fails takes a scan of the file, so one read could hang. Only a dictionary with a
direct string /Contents is asked now, and the budget bounds those.
/ID written direct copied every entry, so a 1 MB file naming a large string thousands of times ran
out of memory: it is at most two strings now. Dropping a crypt filter copied a shared /DecodeParms or
/Filter into every stream, 1 GB from a 1 MB file: of what's shared only filter names and the
parameters decoders read are copied now, through Document::with_direct_parms.
The renderer skips /Crypt in a chain instead of copying the data for each entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M1: only an object itself follows references, and a failed scan is remembered
From a review of the budget.
Following a reference while walking deep inside an object added that depth again for each object
loaded, which overflowed a 2 MiB stack on a file under 10 KB and took the process with it, past any
catch_unwind. References are now followed for the object itself and never for what is nested in it.
A stream can't be nested, and a signature dictionary nested in another object reads as it says
directly, as it did before this PR. walk is back to following nothing.
A reference that fails to load scans the file, and nothing remembered that, so a 1 MB file took
minutes to save. What a scan didn't find is kept for the document: the file doesn't change.
Filter names copied out of a shared chain are bounded, since a name can be any length. /ID keeps each
entry in its place, or the key made from the first entry changes. direct_id's comment was on another
function.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M1: tests with no deadline a busy machine can reach
Wide margins on the two that time what they guard. A test of dictionaries nested in an object goes:
nothing nested follows a reference now, which the small-stack test covers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M1: a crypt filter past what a read may follow is the document's own
Past the budget a reference reads as absent, and a crypt filter with no /Name is /Identity, so a stream
that selected one by reference had its ciphertext read as data. What can't be told takes the document's
filter, as a /Filter that can't be read does.
Found by CodeRabbit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M1: a read refused, not a budget full, is what leaves a crypt filter unknown
My last commit took a read that used up all it may follow for one that was refused, so an /Identity
stream read at exactly 32 follows was decrypted to garbage. Refused reads are counted now, and only
one on the way to this stream's /Name says anything about it.
Found by CodeRabbit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M1: a save keeps the handler, the key and the permissions
Under every algorithm and by either kind of save, `/Encrypt`, `/ID`, the file key and the permissions
come back as they were. No test held that. `/ID` by reference is held through an incremental save
too, which leaves it as it was.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* cos: encrypt an edited or added object whatever /Encrypt names
stored_as_is now also requires the object to be unedited, so an edit of
an object /Encrypt or /ID refers to, or a new object given a number they
name, is encrypted on both saves. encryption_members skips numbers with
no object behind them and streams, which those dictionaries never hold.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
The merge left a | after patch (13)'s test, which made a sixth cell and
hid (14) and (16) from the rendered table.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vendor/README.md: patch (13), from #5999, goes into this branch's
hayro-jbig2 row ahead of (14) and (16).
The page-area test now fills the page's words with 1s, padding included:
since patch (13) new_with clears the padding, so the test no longer
exercised the page-width half of the mask.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M2: a JBIG2 refinement reads 0 past its reference's width
hayro-jbig2 keeps each row of a bitmap in 32-bit words and filled a new
bitmap whole, so with a default pixel of 1 the bits past its width in
each row's last word were 1s too. A refinement reads its reference a word
at a time, those bits included, where ISO/IEC 14492 6.3.5.2 says a pixel
outside the reference is 0. A refinement of the page, or of an
intermediate text or halftone region, whose default pixel is 1 and whose
width isn't a multiple of 32 decoded to other pixels than were coded.
Vendored patch 13: Bitmap::clear_padding clears those bits when
new_with or reinitialize fills a bitmap with 1s, as cutting symbols and
patterns out of a collective bitmap already did.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M2: the doc on a JBIG2 bitmap's words says their padding is 0
Three places in hayro-jbig2 now clear the bits past a row's width, and
the field's doc said only that rows are padded. It now says the padding
is 0 and why: generic and refinement decoding read the rows above their
own pixels a word at a time, as a refinement reads its reference, so
whatever next writes whole words keeps it 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M11: JBIG2 encodes only what its decoder takes: Optimize no longer loses large images
encode_jbig2 took rows of up to 2^20 samples and any number of them, while hayro-jbig2, which
draws and reads these streams, takes 65,535 on a side and 2^28 samples. The optimizer doesn't
read back what it stores, so with Monochrome Images on JBIG2, any 1-bit image with a side of
65,536 or more was replaced by a stream that draws nothing: 65,536 × 16 went from 37,875 dark
pixels to none, and was reported as recompressed.
The encoder now refuses what the decoder can't read, so such an image stays as it is. Symbol
coding's limit of 2^24 rows can't be reached any more, so it goes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: the JBIG2 boundary test checks a pixel
It counted rows, which a page decoded blank would pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: the encoder's bound on samples is MAX_JBIG2_PIXELS, the decoder's
main brought decode_jbig2 and its public MAX_JBIG2_PIXELS into this crate,
2^28 like this branch's private MAX_JBIG2_SAMPLES and for the same reason.
One constant now bounds both, so the encoder can't drift past what the
decoder reads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: an image too large for JBIG2 isn't stored as JBIG2, as the notes say
The parity note said such an image stays as it is. A 1-bit one does unless
it is averaged, but one packed from more bits may become 1-bit Flate. The
note now says it isn't stored as JBIG2 and cites
an_image_too_large_for_the_jbig2_decoder_stays_as_it_is, and the optimizer
README lists the bound under Not yet. MAX_JBIG2_PIXELS is the most the
decoder takes, not about that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: the optimizer's own size limit, and tests for what JBIG2 can't take
The optimizer leaves images over 80 million pixels alone whatever the
settings, and nothing said so; the README and parity note say it now, and
that JBIG2's 2^28 in all is past it, so from Optimize the side bound is the
one that applies. The note says what the image step does: it leaves a
1-bit image past the bound as it is unless it is averaged, and may store
one packed from more bits as 1-bit Flate.
what_is_too_large_for_jbig2_is_stored_another_way tests those two cases:
eight-bit grey of black and white at 65,536 wide becomes 1-bit Flate, and
a 1-bit image at 600 ppi averaged to 70,000 wide does too. At 65,535 and
35,000 both become JBIG2.
The symbol cap's comment says why hayro-jbig2 refuses 32,768 symbols: it
caps new and exported symbols together at 65,535, and a plan exports every
symbol it codes. Measured: 32,767 decode, 32,768, 40,000 and 65,535 are
refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: the 80-million-pixel limit is the image steps', tested on both sides
The README and parity note said images over 80 million pixels are left
alone whatever the settings, but Clean Up still Flates an unfiltered or
LZW one. They now say the image steps leave them alone, and the note cites
group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit for it.
(The last commit said the note also gives 2^28 as past that limit; only
the README does.)
what_is_too_large_for_jbig2_is_stored_another_way covered the width
bound only: it now runs tall images too, packed and averaged, and checks
the averaged ones are one bit and half the size both ways. The older test
uses the same bars() fixture, and its comment says such a stream drew
nothing in PdfCraft, not that the picture was lost: Poppler and
Ghostscript draw it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: the JBIG2 stored at the decoder's bound reads back, and grey keeps the pixel limit
- The optimize test at 65,535 on a side decoded nothing it stored: it
checked the filter only, and the coding stored there is symbols, not
the generic coding the filters test reads back. It now decodes the
stored stream and compares it with the bars.
- The README's 80-million-pixel sentence covers the colour and grey
steps too, and only the 1-bit step had a test. A grey image of
10,000 × 8,000 at 600 ppi is resampled; a row more stays as it is.
- The filters README names decode_jbig2_within, with its work, as main
renamed decode_jbig2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: the filters README names #5569's other renames too
The README named decode_jbig2_within but still decode_ccitt, which
#5569 made private behind decode_ccitt_within, and gave
Document::decoded_image as both decoders' caller, which is test-only
now: ImageBudget::decode calls them, as lib.rs's doc says. The ccitt.rs
test line keeps decode_ccitt, which those tests call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* build(i18n): embed the craft-i18n catalogs from a pinned checkout
- `craft-i18n.rev` pins storytold/craft-i18n at e45ddf7 (the PdfCraft cutover sync of
pdfcraft@ecdd1e6); `.gitattributes` keeps it LF.
- The `craft-i18n` crate (craft-libs 787a7a0, PdfCraft's existing pin) is a direct dependency
of the UI crate; it was already in the tree through craft-ui.
- `crates/ui-egui/build.rs` writes `CRAFT_I18N` from `CRAFT_I18N_DIR`: one (locale, common.tsv,
pdfcraft.tsv) entry per locale with a `pdfcraft.tsv`, paths written with `{:?}` and never
canonicalized. Unset it is empty (English only); `CRAFT_I18N_REQUIRED=1` makes a missing or
invalid checkout an error.
- Both flatpak manifests name the craft-i18n commit and `CRAFT_I18N_DIR`; a test keeps both
commits equal to the trimmed pin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(i18n): look strings up through craft-i18n behind a thin Lang wrapper
`i18n.rs` keeps PdfCraft's API (tr, tr_ctx, tr_id, trn, key_name, fmt,
Lang, LANGUAGES, detection) and hands lookups to the craft-i18n crate:
`get` for key names, `trn_plain` for plurals (no isolates, no digit
grouping), PdfCraft's own `fmt` (no FSI isolates). `@id` rows and the
"Undo {action}" template are read only where PdfCraft had them, so menus
and history labels read exactly as before.
Detection is unchanged (LC_ALL/LC_MESSAGES/LANG, macOS defaults,
sys_locale); the crate's match_tag would drop nb for no/nn, so PdfCraft's
own tag mapping stays, with one parity test per path.
Completeness tests become reports; tests that need catalogs skip with a
note when the build has none. Font coverage tests read the embedded
catalogs (shared and PdfCraft rows).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(i18n): remove the in-repo catalogs; craft-i18n holds them
The 34 `crates/ui-egui/src/i18n/*.tsv` catalogs were migrated into
craft-i18n at pdfcraft@ecdd1e6 and are now embedded from the pinned
checkout. ATTRIBUTION.toml drops their 34 entries for one craft-i18n
`[[build_input]]` (craft-i18n's ATTRIBUTION.md credits the translators);
AGENTS.md and docs/localization.md describe the new system and how to
add a language.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(i18n): skip catalog tests without craft-i18n; drop the catalog hash exemption
Tests that switch to a non-English language skip with a note when the
build embeds no catalogs (CRAFT_I18N_DIR unset), so the English-only
build tests green. `skip_without_catalogs` moves to the integration
test support module.
With no catalogs left in the repository, `cargo xtask assets` no longer
exempts `crates/ui-egui/src/i18n/*.tsv` from hashing: every `[[asset]]`
carries a sha256 again, and a `.tsv` added back would need one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* build(i18n): watch the locales directory before reading it
A CRAFT_I18N_DIR that doesn't exist yet now reruns the build script once it appears (review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* build(i18n): pin craft-i18n dfbe955, the merge of storytold/craft-i18n#129
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): skip an @id row that changes the label's placeholders or ellipsis
craft-i18n checks placeholders and the trailing ellipsis on every row whose
source is English text, but not on @id rows, whose source is the command id.
The in-repo catalog test used to fail on such a row; since the cutover it only
prints it, while tr_id and menu_label still showed it. Both now use PdfCraft's
own @id row only when it keeps the label's shape (same_shape, the rule
craft-i18n applies elsewhere), and otherwise show the label's translation, as
bundled_catalogs_are_consistent says.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): reject an @id row with an open brace; list unused @id rows in the gaps report
same_shape skipped a `{` with no `}`, so an @id row such as "Öffnen {name…"
for "Open…" was still shown. It now counts as a changed shape.
bundled_catalogs_are_consistent printed the @id rows PdfCraft doesn't use, but
CI captures test output, so nobody saw them. translation_gaps_report, which
`cargo xtask i18n-report` runs with --nocapture, now lists them
(unused_id_rows).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): reject an @id row with a `}` that closes no placeholder
same_shape counted only the `}` that close a `{`, so "Öffnen name}…" for
"Open…" or "{name}} öffnen" for "Open {name}" still passed. A row whose `}`
count differs from its placeholders now counts as a changed shape.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): no braces in the assert messages of the brace test
assert! reads its message as a format string, so "a `}` without its `{`"
did not compile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
A refinement of the page refines "the current contents of the page
buffer, restricted to the area of the page buffer specified by this
segment's region segment information field" (ISO/IEC 14492 7.4.7.4), at
an offset of 0 (Table 35), and reads 0 outside that area (6.3.5.2).
hayro-jbig2 read the whole page at the refinement's location, the
pixels around the area too, so a refinement of the page decoded to
other pixels than were coded, and one wider or taller than the page was
refused.
Vendored patch 16: Bitmap::area copies the page's area, 0 off the page
and past its width whatever its rows' padding holds, and both handlers
refine that. Every refinement region now reads its reference at offset
0, so the per-page offset #6016 kept goes.
And RefinementContextGatherer::start_row loads the reference's words a
column left of where a row starts in it, as the reload would, not from
column 0. With the reference offset 32 or more left of a refinement (a
refined text instance from a narrower bitmap or an RDX below -31, a
refined symbol from such an RDX), each row's first context read 0 for
the reference column it starts at, and from 33 left for the one before
it too. That half was left out of #6048: alone
it read the page column left of a page refinement's area, which the
area now keeps at 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
In continuous view the only demand off screen was the pages near the
window. A fast wheel spin crosses them faster than a heavy page renders,
so pages came into view blank.
The view now keeps the direction it last moved and also asks for the
next two rows that way (four pages in two-up). A zoom or a layout change
clears the direction. Scrolling the other way drops those pages from the
queue, which cancels their renders. Pages on screen stay at the head of
the queue in their usual order, and the byte and count limits bound the
prefetch. The web build renders on the UI thread and does not prefetch.
A raster that arrives over the allowance now evicts the cached pages the
frame does not ask for first, then those behind the scroll, so it no
longer evicts a prefetched page that the next frame asks for again.
A prefetched page that is tiled at the current zoom keeps a cached
whole-page raster sharper than its backdrop, as a tiled page on screen
does. This also applies to the prefetch of single-page and two-page view.
Whole-page rasters shared a fixed 128 MiB allowance. In a 3440 x 1440
window a Letter page at fit width is a 2,980 x 3,850 raster (43.8 MiB),
so two pages on screen left no room for another, and scrolling back
over five pages rendered all five again. Zooming in on a smaller window
has the same result.
The allowance now holds eight rasters the size of the largest whole
page on screen, between 128 MiB and 384 MiB (256 MiB in the web build).
The size comes from the page's request at the current scale, or from
the raster held for it while a zoom hold leaves the request out. A
tiled page is on screen as a small backdrop and does not raise the
allowance. Queue admission, keeping pages already rendered, and
texture admission use the same allowance. Tiles keep their own
128 MiB allowance.
The template (and the saved form state) was found anywhere under the XDP root, so a
template-shaped element inside imported datasets could supply the form and its event
scripts. A date field's pattern went into AFDate_FormatEx/KeystrokeEx string literals
unescaped.
A region and its refinement, directly and through an intermediate
refinement, decode otherwise than the same refinements of the page, and
at 2, 3 to exactly the pixels they decode to at 0, 0, moved. A handler
that refines the page instead, or reads the region at the page's
offsets, fails it, as does dropping intermediate refinements; no test
caught those. The test's helpers move to the module. A comment still
said a refinement of the page reads "its own area"; it reads the page at
its own location. And only a location within a region's size of 2^32
came back onto the page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
"GRREFERENCEDX 0, GRREFERENCEDY 0" (ISO/IEC 14492 Table 35): a refinement
region segment that refers to another region refines that region where
it is. hayro-jbig2 offset the reference by the difference of the two
locations, which 7.4.7.5 says must be equal; where a stream has them
differ, the refinement decoded against a shifted reference, or, the
region past i32::MAX, against none. It now decodes as coded, and only a
refinement of the page takes an offset, from its own location.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
A region's location is unsigned (ISO/IEC 14492 5.4.4), and hayro-jbig2
took it for a signed one. A region at 0xFFFFFFFC was drawn back onto the
page four pixels left of it, and a refinement anywhere past i32::MAX, of
the page or of a region at the same place (7.4.7.5), was refused as an
overflow, and the page with it.
Vendored patch 14 (13 is #5999): Bitmap::combine_region keeps such a
location past the page, for the four kinds of region drawn onto it, and
a refinement's offset to its reference is taken in 64 bits and held to
an i32, which leaves every reference pixel it reads outside the
reference, as the exact offset does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
The faux-redaction audit held every operator of a page's content and of each
form XObject in a vector before walking it, so a stream of millions of short
operators (compressed to a few kilobytes) was materialised whole within the
page's decode budget. Each Do and gs also copied the whole XObject or ExtGState
table to pick one entry, and each annotation listed in /Annots was deep-copied
before its subtype was read.
The walk now reads operators one at a time through pdfcraft_content::ops,
borrows resource tables and annotation dictionaries through the resolved Arc,
charges a hidden line's text bytes (padding included) against the comparison
bound once per cover, and charges each finding's record against the output
bound, so a shared annotation list on many pages or withheld text cannot grow
the finding list for free.
* M5: keep Fill & Sign editing on Enter and preserve line breaks (#5850)
* M5: preserve typewriter whitespace through readers and edits (#5850)
* M5: retain direct-subtype comment listing for Fill & Sign fix (#5850)
* feat: add multi-file Bates numbering
* fix(automation): doc_bates_multi rejects a malformed start_number and >> in prefix or suffix, accepts a range ending at u64::MAX, and counts only staged outputs
Addresses CodeRabbit's quick-win notes. Parity is partial until a GUI dialog exists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(export): add simple HTML mode
* test(automation): reject simple HTML mode for other formats
* test(export): check simple HTML without new parser dev-dependencies
The parser test pulled html5ever, markup5ever_rcdom and seven more crates
into Cargo.lock for one test; plain string checks cover the same markup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Windows exporters map the hyphen of an Arial they don't embed to U+00AD in
/ToUnicode. The CFF standard substitute has no soft hyphen, so the CID was
taken as its glyph index and drew an unrelated glyph (an Ë, or ! in bold).
ISO 32000-2 Annex D.2 calls the soft hyphen typographically the same as
the hyphen, as patch (30) already reads WinAnsi 0xAD: draw the
substitute's hyphen. The text keeps U+00AD.
Two of CodeRabbit's findings on #5803. A right-click off the pages
cleared only the menu's anchor and kept the selected comment, so the
menu that opened was that comment's, not the current page's. A
right-click on a page's empty part deselects it, so one that no page
takes (`PageFrame.right_clicked`) now does the same. A selected Prepare
field keeps its menu off the pages, as it does on a page's empty part;
the comment there says so.
Where pages touch, the first page takes a right-click on the row they
share and anchors the menu there, but a picture on the second page under
it still opens its menu. Paste from that menu centred the image on page
1's point, which on page 2 is at its foot. The image menu now uses the
anchor only on the picture's own page, and otherwise pastes as Ctrl+V
does.
Tests, each seen failing with its fix reverted:
`a_right_click_between_the_pages_opens_the_page_menu_not_the_selected_comments`,
`paste_from_a_picture_menu_where_two_pages_touch_keeps_off_the_first_pages_point`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): do not open right-click context menu on image in crop mode (#5835)
* M7: preserve active crop context routing (#5835)
Prioritize active crops over overlapping images and share measured action-pill geometry with painting. Cover overlapping images, handle margins, page edges, and all view rotations with regression tests.
* fix(ui): test selected cropped image by quad in right-click loop (#5835)
* fix(ui): trim crop right-click routing to the crop box and handles (#5835)
The Done/Cancel/Remove Crop pills are widgets and already take their own
right-clicks, and the canvas context-menu guard already closes the image
menu, so drop the pill hit-test, the crop_pills layout extraction and the
extra secondary-click block. Move the session note to the Progress log.
---------
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* feat(ui): unify quick bar and hotbar and refresh settings dialog (#5890)
* style(ui): compact hotbar tools customization menu
* fix(ui): keep hotbar settings visible when docked to sides and address review findings
- Add placed bool to Hotbar to distinguish explicitly placed [0.0, 0.0] from default floating margin offset
- Check !a.locked before applying quick bar color edits to annotations
- In non-full-side docked vertical layout, scroll tools within available height so settings button remains visible in compact windows
- Remove max_width(cell) on vertical full-side scroll area to avoid clipping wider controls
- Restore outer vertical scrolling with max_height on Hotbar tools picker window
- Add test verifying docked hotbar in compact windows keeps settings button visible, and assert controls do not overlap
* feat(ui): close hotbar settings menu when clicking outside or pressing Escape
- Detect pointer clicks outside the settings menu window bounds and dismiss the menu automatically
- Keep settings button click behavior to allow opening and keeping the menu open
- Support Escape key to dismiss the settings menu
- Add integration test verifying clicking outside on canvas or on another tool dismisses the menu
* style(ui): restore hotbar settings dialog design to match concept mockup
* fix(ui): cap hotbar tools picker width to viewport on narrow screens
* fix(ui): anchor hotbar tools picker to rendered bar edges to avoid overlap
* fix(ui): keep upgraded hotbars in the margin and label the settings controls (#5890)
Preferences written before this change have no `placed` key, so every
existing user's untouched floating, horizontal, top-centre hotbar would
have jumped out of the margin. Such a bar now takes the new default; any
spot the user chose, including the top-left corner, is kept.
The side picker reuses the Preferences segmented pill control, so its
options are themed, reachable as radio buttons by label and visible to the
translation scan. The layout switch now carries its label and enabled state.
---------
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* i18n(tr): translate the 288 strings the gaps report lists
The translation gaps report listed 288 English strings with no Turkish
translation, among them the Window menu ("Pencere"), the side-panel and
hotbar labels, the signature and timestamp validation messages, the
accessibility checker findings, the plurals and the calendar weekday heads.
The two English stand-ins at the top of the catalog are translated too.
`cargo xtask i18n-report` now lists 0 missing for tr.
Terms follow the existing catalog (annotation = açıklama, digital ID =
dijital kimlik, bookmark = yer işareti, redact = karart). Placeholders and
trailing ellipses match the English source. AI-assisted; not separately
reviewed line by line by a native speaker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* i18n(tr): note the native review of the judgement calls
A native Turkish speaker reviewed the translations the PR flagged as
judgement calls ("of" as a slash, hotbar, navigation rail, the certificate
key-usage terms, the weekday heads) and kept them. The rest stay
AI-assisted, so full proofreading is still pending.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(i18n): state the Turkish coverage by the gaps report, not the dated union
The 2,798-key union in localization-parity.md was measured on 2026-10-25 and
predates keys added since, so "every current key" against it did not add up
(CodeRabbit). The Turkish row now says what was measured: 2,867 entries and
0 missing in `cargo xtask i18n-report` on 2026-11-03.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Revert the vendored Cargo.lock changes c700ce42 picked up by mistake
They came from running the vendored crates' tests locally and have nothing to
do with this PR.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The French catalog lacked 16 interface strings: the crop-image and
mouse-wheel-speed labels, the signature-validation findings (RSA key
size, CMS content-type and message-digest rules, timestamp authority
certificate), the certificate recognition sentence, the changed-on-disk
reload warning, the password rule, the table-layout validations, and
the Appearance theme name.
Adds them in the catalog's TSV format. After this the report lists
nothing missing for fr.
Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
Add a saved preference, invert_middle_drag (off by default), that
reverses the direction of the middle-button pan and of the latched
auto-scroll. It changes only the direction, not which of the two a
platform uses, so it composes with the Windows auto-scroll work.
Reachable as Preferences > Invert middle-button drag and headlessly
through ui.set invert_middle_drag. Parity "Automatic scrolling" notes
updated; status unchanged.
Tests: a unit test of both paths with the switch on and off (dead zone
kept), a settings round-trip test, and an end-to-end control-channel
drag. Each fails with its fix reverted.
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Co-authored-by: KenM76 <KenM76>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M10: macOS print jobs name their title and owner (#5743)
lp sends no com.apple.print.JobInfo.* attributes, and Kyocera's macOS
filter crashes without PMJobName and PMJobOwner, so the job stops with
cups-filter-crashed and nothing prints. Both go to lp as job options, in
the form lp_args passes on: printable ASCII, up to 64 bytes.
* M10: keep macOS job metadata as the name CUPS should send (#5743)
Review of #5757, checked against this Mac's libcups in memory:
- `'` and `\` were consumed as quoting and `{` opened a collection, and a
bare true/false became a boolean, so a filter reading PMJobName got
nothing or the wrong type. They now become `_`, or the fallback.
- CUPS keeps the last of a repeated option, ignoring case, so the
defaults replaced a value a doc_print caller had set. They are now
added only for a key the job leaves out.
- Nothing proved the job info reached the spooler. A test now runs both
submit paths against a stand-in `lp` on PATH.
* M11: Optimize runs when a category it discards holds nothing
PDF Optimizer's Discard Objects ticks pass their categories to
remove_hidden, which refuses a run that removes nothing. Ticking "Discard
bookmarks" on a file without bookmarks, or doc_optimize with such a
discard, ended the whole optimization with "there are no redaction marks
to apply". An empty category is now nothing discarded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M8: doc_remove_hidden says when the listed categories hold nothing
Its error came from remove_hidden's refusal: "there are no redaction marks
to apply". It now checks the scan first, as redact_apply checks for marks,
and says there is nothing to remove. The scan counts what removal takes
(an AcroForm with no fields as 1), so the check refuses only what the
removal would. The tool description says it fails then.
The tool test also runs doc_optimize discarding categories that hold
nothing, end to end.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: Optimize passes remove_hidden only the ticked categories that hold something
Catching remove_hidden's refusal kept a copy it may have changed: with
bookmarks ticked it drops an empty outline root before counting nothing,
and its own documentation says to drop such a copy. The optimizer now
scans first and passes only the ticked categories the scan finds something
in, and with none doesn't call it, so an empty outline root stays, as
Remove Hidden Information leaves it.
Corrects 278a588bf's message: the ticks are under Discard Objects and
Discard User Data, and hidden text is one of them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M8: doc_remove_hidden reads one scan, and says why it can't edit
A document that can't be edited has no scan, so the check read every
category as empty and said the listed ones hold nothing; the reason it
can't be edited comes through again. The check and the count of what was
removed now share the one scan the tool took already.
Corrects ada2a1ccb's message: the removal also takes what its scan counts
as 0 (an empty outline root); what the check matches is that the removal
refuses when its scan counts every listed category as 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: the optimizer test discards a category that holds something
Mutants of the category filter survived: no test ticked a category that
holds something, or an empty one beside an unticked one that does. A page
with a comment and no bookmarks, discarding both, discards the comment
and only it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
* M11: Optimize passes every ticked category, and keeps the copy from before a refusal
c7c018ac6 passed remove_hidden only the ticked categories its scan finds
something in. But with form fields ticked, discarding hidden text also
takes the hidden text the flattened appearances bring onto the page,
which no scan before flattening counts, so with Flatten form fields and
Discard hidden text ticked, the hidden text was dropped from the list and
stayed in the file, where main removes it.
Every ticked category goes to remove_hidden again, as on main. When it
refuses (every category counts 0), it may have changed the copy (an
empty outline root goes with bookmarks, and then it refuses), so the copy
from before the call is kept and nothing is discarded. That also drops
the extra scan c7c018ac6 added.
Corrects c7c018ac6's message: remove_hidden counts first, then drops an
empty outline root, then refuses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(optimize): make pictures under a stencil mask smaller
An image with any /Mask was skipped by the colour and grey settings. That is
right for a colour-key mask, which names sample values that resampling and JPEG
would change. A stencil mask is an image of its own, with its own resolution
(ISO 32000-2 8.9.6.3), so the picture can change under it and the mask stays as
it is. Some exporters put one on every picture, even where it hides nothing, so
whole drawing sets of pictures were left at full size.
A mask that can't be read as a stencil, such as a reference to nothing, still
leaves its picture alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(optimize): only a mask that says /ImageMask true counts as a stencil
A stream /Mask without /ImageMask true let its picture be replaced, although a
mask that isn't a stencil is meant to leave its picture alone. It now does.
The stencil test also checks that the hole still shows what's behind it. The first
pixel all round is left out, because the coarser picture blends into it a little
(at most 26 of 255 in this test).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(optimize): draw a coloured page behind the stencil test's picture
On a white page, a hole painted white looks the same as an open hole, so the check
on the hole couldn't tell them apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: quincy-jc <184324679+quincy-jc@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M7: Arrange keeps a top-level inline image a unit of its own (#3146)
The parser emits a whole inline image (BI ... ID ... EI) as one BI op, but
content_units scanned on from it for an EI op that never comes, so the unit ran
to the end of the stream. SendBackward on an image after a top-level inline
image then acted like SendToBack, and BringForward/BringToFront on an image in
the swallowed region could do nothing. A BI op is now a unit of its own, like Do
and sh.
* M7: pin Arrange around inline images at every position (#3146)
Check the exact content units and the paint order after each move, not
string positions: several top-level inline images (at the start, side by
side and at the end) are one unit each, BringForward and SendBackward step
past one inline image at a time, BringToFront on the front image is refused
without touching the page, and SendToBack and BringToFront go past inline
images that start or end the stream. A guard checks that an inline image
inside q/Q or marked content stays in that unit. Document content_units.
* M7: Arrange tests check each image's transform through q/Q (#3146)
`num_text` printed every non-integer with twelve fixed decimals. A value with
six or more digits before the point then shows the binary noise of the double
that holds it: a FormCalc calculate `qty * price` with price 279893.88 gave
`279893.880000000005`, and a Sum() of five such amounts gave
`1466917.350000000093`. Real invoice forms (USACE ENG Form 93, a dynamic XFA
payment estimate) show these in their amount and total fields.
Print at most 15 significant digits (the most a double holds for every value)
and at most 12 decimals, as before for small values, so sums that should be
zero still print 0. Only a fraction loses trailing zeros: past 15 digits before
the point no decimals are left and the integer keeps its zeros.
Co-authored-by: seanGSISG <seanGSISG@users.noreply.github.com>
Share the stream-splicing path between mark_decorative and mark_tag_artifact so mark_tag_artifact resolves named property lists in /Resources /Properties, resolves indirect /MCR references in /K, leaves untouched and shared content streams intact, and validates the tag parent before mutating /Contents or /ParentTree.
Create PDF added each page to the page tree through Document::update_dict, which copies the tree's dictionary and its whole /Kids array, so a text of many form feeds took quadratic time: 40,000 pages took 108 s in a debug build. Pages are now collected and /Kids and /Count are written once, for blank, image and text documents.