chore: sync upstream (1081 Commits) #22
No reviewers
Labels
No labels
herkunft/alpha-feedback
status
blockiert
status
zu-testen
typ
bug
typ
docs
typ
enhancement
typ
feature
typ
idea
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
iris/pdfcraft!22
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "chore/sync-upstream-20261111"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Upstream-Sync von
upstream/main(1081 Commits). Fast-Forward.1d7a9f9fffix(edit,a11y): font aliases share the metrics built for their font (#5998)69936f1d1fix(engine): run the renderer redaction check on redactions applied inside a batch (#5977)abec4e3fefix(js): bound array text, month parsing, name registration and list work (#5883)77078864ffix(render): charge inspection text (fields, tooltips, layers, bookmarks, blank comments) against shared allowances (#6015)daec898d7fix(sign): judge a shared signature value once per listing; dedupe DSS evidence by object number (#6041)c73cccfb3fix(redact): hidden-layer removal reads the default state as viewers do (#5867)2b65ab000ci: use StepSecurity's maintained actions (#7026)1e91e8a93fix(forms): a read-only radio button is not picked or left through set_value (#6038)ee367a4a1fix(xfa): bound the name and path bytes data nodes and queued instances copy (#6032)b52bc3032fix(annot): count ink points as strokes resolve and cap the eraser's samples (#6001)13d636738fix(redact): read each Type 3 glyph program once per cleanup (#5993)87aebd1b2fix(preflight): follow every /Next action and bound colour and intent work (#5926)fba22fc73fix(optimize): the fragment search charges every operator of a group it reads (#6095)683ccc9fcfix(optimize): walk shared resource dictionaries and name-tree kids once (#5956)d46d9daeafix(edit): editor font enumeration reads a face name without building font metrics (#6086)d8b27058180f3fd9adScripts got an object that takes anything for `this.border`, `this.ui`, `this.validate` and the like (XFA 3.3 ch. 3, "Properties vs. Children", p. 78), so `ui.oneOfChild.className == "choiceList"` was never true, `this.validate.nullTest == "error"` never matched, and `border.getElement("edge", 0).color.value` threw a TypeError. The parser now keeps each object's own attributes and its property elements (border, font, para, margin, ui, validate, occur, break, breakBefore, breakAfter, assist, format, value, bind, desc, extras, keep, caption, items, calculate) as a generic tree, bounded per object: 256 elements, 16 KiB of text, 8 levels, 64 attributes an element; past that the rest is left out with one warning. An element keeps only its own text, not that of children left out (an extras, a script); a validation or a calculation keeps its first script with its text outside the budget, as forms read it, within the script engine's 256 KiB limit on a script. A field's first two `<items>` (a field has two at most, p. 726) are kept as lists: each counts against the budget as one element, and its entries, outside it, are bounded by the list's own limits (1,000 entries), as the field's lists are; a further `<items>` (one public form has a third, empty one) is an ordinary property, every entry charged. The tree is shared through an Arc by the template, every instance of the object and every script's snapshot, and the engine keeps one converted copy of each tree in the form's template, so it is made once per opened form rather than per event or per script, behind the form's own lock, and goes when the form is closed. On a 10,000-field form with 17 property elements each, a script's snapshot takes 3.24 ms against 1.46 ms on main (median of 15 interleaved runs); the property trees are converted once per form, and each later snapshot adds one walk over the form and a lookup per tree. In JavaScript a property object is a Proxy naming its node and path. It reads what the template writes, or the default XFA 3.3 gives for the properties the 367 inventoried public forms read: presence of border, edge, corner, fill (p. 629, 687, 663, 734); colours (a fill white, p. 734; a font fill, edges and corners black, p. 734, 687, 663); font typeface/size/weight (pp. 741-744); para hAlign/vAlign (p. 800); margin insets (p. 776); validate nullTest/scriptTest/formatTest (pp. 880-881); occur min/max/initial (p. 786); break before/after (p. 632); bind ref (p. 623); keep next/previous none and intact by container (pp. 762-763); a comb's numberOfCells (p. 657); a gradient's type (p. 770, 806, 820); a fill's type solid (p. 734) and a field's widget textEdit (p. 877); a calculation's override, "disabled" where the template has no calculate element and "error" where it has one that doesn't say (pp. 641-642). Where XFA 3.3 leaves the outcome open, or where every reader tested contradicts its text, reads follow the readers. A border's presence reads "hidden" when the object has no border element ("visible" with one that doesn't say, p. 629). For the edges and corners a border doesn't list, p. 38 says the last one listed is reused; readers draw such a side from the edge or corner on the same side instead (two listed alternate, three reuse the second for the fourth, one serves every side), and reads follow the readers: a side past those listed reads as the one drawn there (with none listed, the defaults: black, 0.5pt, solid, a square cap, a corner's radius 0in, square join, not inverted, p. 687, p. 663), and is made as a copy of it (with what a script already set on it) when a script reaches it, so a write then changes only that side. A border has four sides: a fifth or later edge or corner, even one the template lists, is out of reach, as the layout draws four. The copies have their own budget (8 MiB a run), apart from the run's text budget, so reaching sides never crowds out a script's writes; past it a side still reads as the one drawn there. A fill type the fill doesn't have (its pattern when it is solid, p. 734) can't be read: the read raises an error the script can catch (GeneralError, "operation failed"), the fill keeps its type, and `"pattern" in fill` is false. Anything else unspecified reads as an empty value that compares as "" and never throws; further defaults are added as forms need them. Property objects have className, oneOfChild, children by name (desc.vMandatory, message.scriptTest) and by class (ui["#textEdit"]), getElement(name, index), isPropertySpecified, getAttribute, setAttribute, rawValue (a desc's first text), a validate's or calculate's script text and message (p. 827), resolveNode(s), somExpression and nodes (length/item/namedItem); nodes get getAttribute, getElement, isPropertySpecified, and mandatory, validationMessage, formatMessage and mandatoryMessage read from their validate's nullTest and message texts (the text of that name, as the template or a script named it, else a single unnamed one, which serves every test: p. 782, p. 880). Setting a test's message sets its named text, else overwrites a single unnamed one (so every test then reads it), else adds a text of that name, as readers do; reading validate.message.<test> where no such text exists raises a script error, as in readers. fillColor, fontColor and borderColor read from the border fill, font fill and first edge colours; borderColor set colours all four edges. A child of the same name comes before a property, an attribute or a member, on nodes and on property objects alike, for reads and writes (a write to a member a child shadows leaves the property alone), as XFA SOM resolves name clashes in favour of the explicitly named object, and `#name` reaches the property (p. 99). A SOM expression from a node, or from xfa.resolveNode, that runs into objects' properties (resolveNode("ui.#textEdit"), "border.edge[1]", "row[*].border.fill.color", "extras.arrayGroup[*].groupName", "ui.*") gives those property objects, each instance's, 4,096 at most (about 30 of the forms do this); `value.#text` on a draw stays null, as before, until a node's value is its value element. Writes (border.fill.color.value = ..., validate.nullTest = ..., rawValue, setAttribute, mandatory, validationMessage = ...) are kept for the rest of the script run and read back through the same properties, bounded by the run's text budget and 64 attributes a property, but have no effect on the form yet. The caption (XFA 3.3 p. 643) and a field's items (p. 758) are property objects too. A caption's value holds its label as text or rich text, read by name, by class (`['#text']`), through resolveNode and oneOfChild; rich text reads as its words; the content a value doesn't hold reads as null (one of them, p. 598). Items list their entries through `nodes`, each with the text the choice list or check button shows. A field's `items` is its first `<items>` (the shown list), a second is reached by index (`items[1]`), and a field with none reads an items object with no entries. Items reads follow the template: `HNode::items` is the hook PdfCraft's choice-list methods (addItem, clearItems; not on main yet) will set, so that reads agree with them once they land. exData.loadXML and saveXML do nothing yet. Over the 163 dynamic forms (118,208 objects) no object reaches the budget: elements p99 30, max 41 (limit 256); bytes p99 773, max 8,500 (limit 16 KiB); item entries per list p99 51, max 342. The choice-list methods (selectedIndex reads still give an object that takes anything), execEvent and script objects are handled separately. The one known gap is value.text and value.exData (about 15 of the 367 forms): a node's value stays its raw value, and changing what value returns is left to a follow-up with a full-corpus regression check. FormCalc does not read these properties yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrKFind, text_find and Search & Redact's word search read the Latin compatibility ligatures U+FB00-U+FB06 as their letters, in the page and in the query, so "office" finds a page drawn with an fi ligature glyph (ISO 32000-2 9.10.3 maps ligature glyphs to their letters). Each letter stays owned by its glyph, so the highlight covers the ligature. Where a line ends in a hyphen (U+002D, U+00AD, U+2010) between two letters, the page is also searched with the hyphen and the break dropped ("example" finds "exam-" + "ple") and with the hyphen kept but the break dropped ("exam-ple"). The hits are merged in page order; a match covers both lines and is highlighted on each. Pages without such a hyphen are searched once, as before. Accent-insensitive search is left to the open pull request for #3460. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSCAn /Info value whose words are too short to be terms ("J. Li") survived when the sentence holding it was redacted. A value that is wholly part of a removed run, with three letters or digits at least, now goes as well (the reverse check #2598 proposed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>A value shorter than the redacted sentence ("James Bond" from "JAMES BOND IS A SPY") in a custom schema's element or attribute takes its packet with it, as it goes from /Info, while a clean packet and an unrelated entry stay. The regression #2598 suggested keeping whichever fix lands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>A rectangle with no height or no width encloses no area, so the rasteriser marked nothing for it. As a clip (`10 10 100 0 re W n`) it hid everything painted after it; as a fill it fell back to a thinnest-line stroke that half-covered two rows when the line lay on a pixel boundary. ISO 32000-2 §10.7.4 paints every pixel whose half-open square the shape intersects, "no matter how small the intersection is" ("A zero-width or zero-height rectangle paints a line 1 pixel wide"), and a clipping region is "the set of pixels that would be included by a fill operation". hayro-interpret patch (65), `degenerate_fill_area`: a fill or clip whose path has no width or no height (but not both) gets the device pixels the line touches - whole pixels when it runs along a device axis, a band one device pixel wide when a rotated CTM turns it. Points, and paths with area however thin, paint as before. Tests: crates/render/tests/it/zero_area_fill_clip.rs (5 of 8 fail with the patch reverted; 9 mutants of the patch each fail a test). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSCA standard 14 font named without /Widths is drawn with a bundled CFF stand-in. Its glyphs are fitted to the standard metrics only when the stand-in's own advance is known, and for the CFF stand-ins it never was, so each glyph was drawn at its own width. The stand-ins' Euro is 667 to 722 units wide against the metrics' 556 (Helvetica) and 500 (Times), and it overlapped the glyph after it ("€5", "€€", "a€b"). The advances themselves were right: both the renderer's metrics and the writer's Std14 table already give the Euro 556/500/600. hayro-interpret patch (67): CffFontBlob::advance_width reads a glyph's advance from its charstring, and StandardFontBlob uses it, so a stand-in glyph is fitted to the standard width as the OpenType stand-ins already were (ISO 32000-2 §9.6.2.2; Annex D Table D.2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSCCreate Custom Stamp offers an optional line under the picture, a template with {name}, {date} and {time} filled in when the stamp is placed, in the built-in dynamic stamps' format, and kept with the stamp library. The picture keeps its shape above the line and the stamp widens to fit the line. stamp_custom takes the same line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>The panel listed every group in /OCGs, flat. Table 99 says only the groups /Order lists are presented, nested arrays as a tree, and a leading text string in a nested array as a non-selectable label; in the default configuration /Order defaults to the empty array. - render: DocInfo::layer_order, rows of LayerRow { depth, item } where an item is a layer (index into DocInfo::layers) or a label. A nested array's entries are one level deeper than the layer or label before them. At most 4096 rows, 32 levels and 256 characters a label; an indirect array is read once, so cycles end. DocInfo::layers is unchanged: unlisted groups keep their state, draw, print and answer layer_set and set-layer actions. - ui-egui: the Layers panel draws the rows, indented 16 points a level, a label as a heading that can't be toggled; the count is the layers shown. A document whose /Order lists none says so. - automation: doc_info reports layer_order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC- form_fill took a JSON number for a radio group as text, so {"title": 1} on /Opt [(1) (2)] with on states /0 /1 turned on the second button. A number now resolves through state_for_export as the same option written as a string does. - strip_xfa compared the XFA data with the selected button's /Opt entry only, but saving writes the button's /PCOn ahead of it. A group with /PCOn then reported a lost value on Flatten and XfaToStatic. The data now agrees with the field when it holds what saving writes (radio_dataset_value, made public) or a value that selects the same button (state_for_export). Tests (each fails with its fix reverted): the automation form_fill radio test now also fills 1, 2 and 3 as numbers; an engine test strips a group whose /PCOn equals the state, and one whose /PCOn is neither the state nor the /Opt entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdvA /DA string comes from the file. Reviewing how the PR reads it: - An operand that is not a finite number was taken as one: Rust parses "NaN" and "inf", so "NaN NaN NaN rg" reached the appearance stream as "NaN NaN NaN rg" (clamping keeps NaN), and "/Helv inf Tf" drew at the 400 pt cap. parse_da now ignores such an operand with its operator. - A Tf without its size ("/TiRo Tf") lost its font: da_font_name read the name two tokens before Tf only. The name is now read whether or not a size sits between it and Tf; the size falls back to 12 pt. - A zero or negative size keeps the default 12 pt (zero is auto-size only for form fields, §12.7.4.3; a text box has none), now stated in parse_da's comment. Tests (each fails with its fix reverted): a text_style unit test over missing, zero, negative, NaN, infinite, huge and non-numeric sizes, missing Tf, non-finite and out-of-range colours, garbage and non-UTF-8 bytes; and an appearance test that draws boxes from such /DA strings, including a font in /DR and one no /DR lists, and checks the stream's font, size and colour and that no NaN or inf reaches it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdvform_fill turned a JSON number for a radio group into text with Number::to_string, which spells a float with a fraction part: 1.0, 1e0 and 0.1e1 became "1.0", and serde_json parses -0 as the float -0.0 ("-0.0"). None names the option "1". number_option now spells a whole number below 2^53 as that integer (1.0 -> "1", -0 -> "0"); integers keep their exact spelling; any other number keeps JSON's spelling (1.5, 1e300), so it names only an option exported as exactly that text, and is otherwise refused with the options listed. The precedence when an on-state name equals another button's export value (export value first, then on-state names; ISO 32000-2 Table 230 lets state names be positions) is now stated in state_for_export, at the call site and in form_fill's tool description, with the number rules. Test (fails with Number::to_string restored): the ambiguous pair (the first button exports "1", the second's state is /1) filled with 1, 1.0, 1e0, 0.1e1, "1", -0, 2e0, 2.000 and 3.0 selects the right button; "1.0", 1.5, -1, 1e300, u64::MAX and 9007199254740993.0 are refused with the options listed and the selection kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdvConflicts, each where both sides added at the same place; both kept: - crates/annot/src/tests.rs, crates/automation/tests/it/automation.rs and crates/ui-egui/tests/it/comments.rs: this branch's pop-up tests and main's new tests. - crates/ui-egui/src/i18n/{ca,fi,ro,vi}.tsv: this branch's pop-up lines and main's image-paste and Home lines. No key is added twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdvConflicts: - crates/engine/src/tests.rs: both sides added tests at the end of the file. Kept this branch's three stamp-line tests and main's page-deletion save and image-transform tests; the closing braces after the markers are shared, so the branch's last test gets its own. - crates/ui-egui/src/i18n/{ca,fi,hi,id,ko,nb,ro,sq,th,vi}.tsv: both sides added rows at the same place. Kept both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv3936f93ainto pr/printer-marks: code conflicts resolved by hand, translation catalogs kept from both sides, ATTRIBUTION hashes recomputed c9abbc74c014ae839ainto pr/convert-colours: code conflicts resolved by hand, translation catalogs kept from both sides, ATTRIBUTION hashes recomputed 43c40582e7The Consumers review of ba28458ad..b7224cbc9: - `recovered_thai_map` read the descendant's /CIDToGIDMap with `Dict::name`, on the line after e9142b03b's /Encoding check, so a map given by reference made Repair Thai maps pass the font over, and so did a missing one, which is Identity (ISO 32000-2 Table 115), as `composite_glyph_maps`, `LegacyThaiFont` and the renderer read it. It now resolves the map and takes a missing one as Identity. - `MAX_THAI_PROGRAMS` says what it pays for: the programs, and the CMaps and glyph maps read to adapt their cmaps. The metrics read to find and measure the subsets aren't charged to it. Its refusal ("MiB of font data") still says what it counts. Test: `recovered_thai_map_checks_every_kept_glyph_against_the_font_program` recovers the same map with the font's /CIDToGIDMap given by reference and with none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY## What this changes `read_xref_chain_materialized` in `crates/cos/src/document/reader.rs` keeps a running total of the rows it merges across the cross-reference chain. Past `MAX_XREF_OBJECTS` (8,388,607) it returns `CosError::Syntax` ("cross-reference chain lists more than 8388607 entries"). The open path then rebuilds the index from object headers and logs the repair, as it already does when a single section decodes past that cap. Before this, the per-section cap and the 10,000-section chain limit multiplied, so the merged map had no overall bound. Each section adds `sect.len() + odd.len()`: its entries plus the rows it set aside, including those from a hybrid `/XRefStm`. Set-aside rows are kept for the whole chain, and a section can set aside nearly everything it lists. The count is of rows merged, not the map's size, because a chain that relists the same objects costs as much to merge. A section's rows never outnumber what the compact reader counts for it, and that reader stops at 2,000,000 rows. So no chain the compact reader accepts is refused here. The limit itself lives in `read_xref_chain_materialized_with_limit` (`pub(super)`). The existing entry point, `read_xref_chain_materialized`, calls it with `MAX_XREF_OBJECTS`, so tests can reach the limit in a few rows. Both functions have doc comments. Fixes #5082 ## How you verified it Three regression tests in `crates/cos/src/document/tests.rs`. The two small-limit tests were suggested by @Harrison-Booth in review. 1. `a_cross_reference_chain_past_the_total_cap_is_refused_and_the_file_reconstructed` runs end to end at the production cap. It builds a synthetic file: - a catalog, a page tree with one page, and a chain of three cross-reference streams; - the newest stream locates objects 0 to 6; - two older Flate-compressed streams each list 4,200,000 rows (`/W [1 0 0]`, disjoint `/Index` ranges). Each is half the per-section cap and too large for the compact reader. Their rows are in use at offset 0, so they are set aside, which keeps the test at a few bytes a row. It asserts that `Document::open` logs the cap with "rebuilding from object headers" and then "rebuilt cross-reference index ...". The rebuilt document must keep `/Root 1 0 R` and the `/Page`. `read_xref_chain_materialized` must return `CosError::Syntax` at the oldest stream's offset. 2. `the_chain_row_limit_counts_disjoint_and_relisted_rows` uses three classic tables of 2, 1 and 1 rows. A limit of 4 passes, and a limit of 3 refuses the chain at the oldest table. This holds when the sections list different objects, and when one relists an object a newer section has: that is a map of three entries but four rows merged. 3. `the_chain_row_limit_counts_hybrid_rows_set_aside` uses a hybrid file whose table puts object 3 at an offset while its `/XRefStm` puts it in an object stream. It merges six rows and sets object 3 aside. A limit of 7 passes, and a limit of 6 refuses it. Seen failing, each by reverting part of the fix in the working tree: | Revert | Test 1 | Test 2 | Test 3 | |---|---|---|---| | no chain check at all | fails (empty repair log) | fails | fails | | count without `sect.len()` | passes | fails | fails | | count without `odd.len()` (state before 780764cb) | fails | passes | fails | | fix restored | passes | passes | passes | All three run in about a second together. Commands, all with `-j 2` and a separate `CARGO_TARGET_DIR`: - `cargo fmt -p pdfcraft-cos --check`: clean - `cargo clippy -p pdfcraft-cos -j 2 --all-targets --locked -- -D warnings`: clean - `cargo test -p pdfcraft-cos -j 2 --locked`: 95 unit tests and 39 integration tests pass (2 ignored) - [x] `cargo fmt --check` - [x] clippy with `-D warnings` on the crates I changed - [x] tests pass on the crates I changed - [x] n/a — `cargo xtask layers` / `cargo xtask assets`: no crate boundary or asset touched ## Never-crash - [x] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests (the offset conversion uses `unwrap_or(usize::MAX)`) - [x] Checked arithmetic: `saturating_add` per section, `checked_add` for the running total; the change bounds an allocation sized by input - [x] No `unsafe` - [x] The unbounded allocation this fixes has a small synthetic regression test ## Provenance - [x] n/a — no assets of any kind - [x] n/a — no font files - [x] No GPL/AGPL code copied in - [x] Behaviour comes from ISO 32000 (§7.5.8 cross-reference streams, the Annex C object limit) and the crate's existing escalation path - [x] n/a — no new assets, so no `ATTRIBUTION.toml` entry ## Security - [x] n/a — no file paths or tool dispatch touched (`--root` confinement unaffected) - [x] n/a — MCP and control channel untouched - [x] No new outbound network access ## Parity and reach - [x] n/a — hardening of the cross-reference reader, not a parity feature; no `parity/` entry changes - [x] n/a — no new behaviour to expose; every `automation` tool that opens a document gets the cap - [x] n/a — no documented behaviour changes ## Anything a reviewer should look at first - The check runs after each section is read. So one section, already under the per-section cap, is decoded before the chain is refused. Peak memory stays bounded by the cap plus one section. - The row limit is a parameter of a `pub(super)` function so tests can set it small. Production always passes `MAX_XREF_OBJECTS`, and the error text names whichever limit applied. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **Bug Fixes** * Documents with cross-reference chains that exceed the supported total size now report a syntax error at the section where the limit is exceeded. * The limit applies across the entire chain, including repeated object numbers and set-aside entries, so individually valid sections cannot exceed the total cap. * When opening an affected document, the index can be rebuilt from object headers, allowing available catalog and page content to be recovered. <!-- end of auto-generated comment: release notes by coderabbit.ai -->## What this changes The nesting guard that keeps hostile scripts from overflowing the stack in boa's parser could be put out of phase, and this replaces it with one that can't be. boa parses by recursive descent with no depth limit of its own (boa_parser 0.22 has none to turn on), and a stack overflow aborts the process, since `catch_unwind` can't catch it. **The problem.** `refuse()` scanned bytes and guessed where strings begin. A quote inside a regular expression put it out of phase with the real strings. The first version of this PR counted opening brackets inside skipped text, which closed the reported `/'/` reproduction. It still let closing brackets inside *real* strings count as code. `x=` followed by 5,000 lines of `(/"/,"))))",` nests 5,000 deep in 65 KB (under the 256 KiB limit), while that scan never went past depth 1. The same works with `'`, and with template literals, which the scan didn't read at all. **The fix.** The guard now lives in `crates/js/src/nesting.rs`. - **It doesn't guess.** Whether a `/` is a division or starts a regular expression depends on where the parser is: boa's parser, not its lexer, sets that. So the scan follows every reading at once. At each `/` that isn't a comment, it splits into a division reading and a regular-expression reading, and counts nesting on each. boa's reading is always one of them, so the deepest count is at least boa's real depth. That makes it a sound bound, not a mitigation for one reproduction. - **It drops readings boa rejects,** at the byte where boa stops with a syntax error: a string or regular expression running into a line break, or a regular expression whose groups don't match. boa runs `regress::try_parse` on each literal as it lexes it. This keeps ordinary divisions such as `Math.round(a / b * 100) / 100` from leaving stray readings that add up to false depth. - **It reads the rest of the lexical grammar that changes what's code:** strings with escapes and line continuations, comments, template text and `${}` substitutions (which count as a level), the Annex B `<!--` and `-->` comments (followed both ways), and a leading `#!`. - **It caps the work.** At most 16 readings are followed at once. Readings in the same place are merged first, keeping the larger counts, and past that the script is refused. The limits are unchanged: 64 brackets, a run of 64 prefix operators, 4,096 conditionals. Every path that hands source to boa goes through `refuse()`: AcroForm scripts, XFA JavaScript, and `CheckedCodeFromStrings`. Brackets inside strings and comments no longer count at all. That drops the trade-off in this PR's first version, which refused a script whose strings held more than 64 opening brackets in total. Fixes #5079 ## How you verified it Locally, only formatting was run. I didn't build or test this commit, and I'm relying on CI for the build, clippy and the tests. - [x] `cargo fmt --check -p pdfcraft-js` passes. - [ ] clippy with `-D warnings` on `pdfcraft-js`: not run locally. - [ ] `cargo test -p pdfcraft-js`: not run locally. A local test build was stopped before it compiled anything, so I have no local results for this commit. What the tests check: - `a_quote_in_a_regex_cannot_put_the_guard_out_of_phase`: the 5,000-line bypass above, and the same with `'` and backticks. It also runs a script nested exactly to the limit, closed properly. - `nesting_guard_reads_templates_regexes_and_comments` and `divisions_do_not_add_up_to_a_refusal`: controls that must run. - `boa_rejects_what_the_nesting_guard_drops`: the boa behaviour the guard relies on. - The XFA-open test (`session::a_quote_in_a_regex_cannot_hide_nesting_from_an_initialize_script`) now runs the 5,000-line bypass too. - `ordinary_nesting_still_runs` again expects 1,000 brackets inside strings and comments to run. Not yet shown: that these tests fail on the previous head (439f86220). By reading the previous scan, the `(/"/,"))))",` script never goes past depth 1 there, but I haven't run that. ## Anything a reviewer should look at first - `boa_rejects_what_the_nesting_guard_drops` pins the assumptions behind dropping a reading. If a boa upgrade starts accepting one of those inputs, that test fails, and the guard has to keep that reading. - Out of scope: recursion that isn't bracket nesting, prefix runs or conditionals, such as long `a=a=a=…` chains or `if(1)if(1)…` statements. The guard didn't count those before this PR either. They're worth a separate issue once someone checks whether any of them overflows the stack within 256 KiB. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **Bug Fixes** * Scripts that exceed supported nesting limits are rejected with a “nested too deeply” error, including excessive bracket nesting, prefix operators, and conditional expressions. * Nesting checks now account for strings, comments, templates, regular expressions, and other JavaScript syntax, avoiding incorrect results from bracket-like text or ambiguous slash characters. * After a nesting-related refusal during initialization, the session remains usable. <!-- end of auto-generated comment: release notes by coderabbit.ai -->## What this changes **File ▸ Open opens Word, Excel, PowerPoint, OpenDocument and RTF files** as new, unsaved PDFs (`Report.docx` → `Report.pdf`), the "LibreOffice sidecar" that `docs/gaps.md` (gap 8) and `docs/file-format-parity.md` planned for `create.from-office`. - **Conversion** (`crates/create/src/office.rs`): an installed LibreOffice runs headless as a separate process, in a private temporary folder (the input is always `document.<ext>`, so the user's file name never reaches its command line). It gets a fresh profile with `DisableMacrosExecution`, `MacroSecurityLevel` 3 and `BlockUntrustedRefererLinks` set, and Writer's and Calc's `Content/Update/Link` set to *Never* (2 for Writer and 1 for Calc: the two number their values differently), no console window on Windows (`CREATE_NO_WINDOW`), and is killed after 120 s. The output is size-capped (512 MB), must start `%PDF-`, and must open as a PDF before it is used. LibreOffice is found through `PDFCRAFT_SOFFICE`, the `PATH`, then the usual install folders per OS. Without it, the user is told it's needed rather than "not a readable PDF". - **Off the frame thread:** File ▸ Open converts on the existing open worker (`ResultKind::Converted`); the frame thread only opens the result. - **Detection:** `SourceKind::Office` requires an Office extension *and* an OLE, ZIP or RTF signature, so `photos.zip`, or a PDF saved with a `.docx` name, are not misread. - **Open dialog:** PDF (still the default), Images, Office documents, Text, All files. All files is left out on macOS, where rfd merges every filter into one allowed-types list, and on the web; Office documents are left out on the web. The extension lists are now shared constants in `pdfcraft-create` (`IMAGE_EXTENSIONS`, `TEXT_EXTENSIONS`, `OFFICE_EXTENSIONS`), used by both detection and the picker. The duplicate filter list in `open_dialog` is gone. The two new labels are in all 22 catalogues, with their `ATTRIBUTION.toml` hashes updated. - **Automation:** `doc_create_multiple`, `doc_combine` and batch `convert` go through `convert_to_pdf`, so they take Office documents too; their descriptions say so. **Deliberately not in this PR:** Insert pages, Combine files and Create ▸ Multiple files convert on the frame thread, so they now refuse Office documents with a pointer to File ▸ Open (`convert_to_pdf_inline` / `as_pdf_inline`) rather than freezing the window for seconds. Moving them onto a worker is the follow-up. `create.from-office` goes planned → **partial**, with that and the rest listed in its notes. ## How you verified it On Windows 11 Pro with LibreOffice 26.8.1 installed, per crate: - [x] `cargo fmt --check` - [x] `cargo clippy -p pdfcraft-create -p pdfcraft-engine -p pdfcraft-ui-egui -p pdfcraft-automation -j 2 --all-targets -- -D warnings` - [x] `cargo test -p pdfcraft-create -p pdfcraft-engine --lib` (42 and 276 pass); `-p pdfcraft-automation --test automation` (the combine/create tests); `-p pdfcraft-ui-egui --lib open_worker` and `--test editing --test create` - [x] `cargo xtask layers`, `cargo xtask assets`, `cargo xtask parity --partial` - [ ] wasm check: the `wasm32-unknown-unknown` target isn't installed here, so I'm leaving it to CI. The web paths are `cfg`'d and were reviewed by eye. **Tests seen failing before the fix**, by reverting each part: - Detection off → `office_documents_are_their_own_kind`, `source_kind_tells_pdfs_images_and_text_apart`, and both engine Office tests fail. - Worker conversion off → `file_open_converts_an_office_document_on_the_worker` fails with the old user-facing message, *"the file is not a readable PDF: no readable page tree or cross-reference data was found"*. New tests also cover a converter that hangs (it's stopped; the test binary stands in for LibreOffice), one that writes no PDF, a missing converter, temp-folder cleanup, `file://` URL escaping (spaces, drive letters, UTF-8), and the search order. **Real conversions, end to end:** `crates/automation/tests/it/office.rs` builds a minimal DOCX, an XLSX (with a formula, `=6*7`), a PPTX and an RTF inside the test, so no binary fixtures are committed. It runs them through `doc_create_multiple` in `separate` mode (each output reopened and its text checked, including `42`) and in `combine` mode (page order and bookmarks), and through `doc_combine` beside a PDF, with a page range on one file. These tests, `libreoffice_converts_a_document_when_installed` and the process-tree cleanup test (5 out of 5 reruns) pass with LibreOffice 26.8.1. The Office tests skip where LibreOffice isn't installed, which includes CI. Three existing tests used a 4-byte `report.docx` as their "file that can't be converted" example. That's no longer true, so they now use `archive.zip`, which keeps what they test. ## Never-crash - [x] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests - [x] No input-derived indexing or slicing (`rsplit_once` for the extension); the PDF read back is size-capped - [x] No `unsafe`. The Windows flag uses `std::os::windows::process::CommandExt` - [x] A crashing, hanging or misbehaving LibreOffice is an error, never a panic or a stuck app: the worker's `catch_unwind`, a 120 s kill, and output checks ## Provenance - [x] No Adobe assets; no fonts; no copied code - [x] LibreOffice (MPL-2.0) is only *run* as an external process, as the docs planned; nothing of it is linked or copied. Its command-line options and the profile keys come from LibreOffice's public documentation. - [x] The translations are contributor-original, written from the plain meaning of "Office documents" and "All files" ("Office" kept as a product name); hashes updated, `cargo xtask assets` passes ## Security - [x] `--root` confinement unchanged: automation still resolves and reads paths before converting; only the private temp folder is written outside it - [x] MCP and the control channel untouched - [ ] **No new outbound network: please decide.** PdfCraft itself opens no connection. The profile blocks untrusted referer links and sets Writer and Calc never to update linked content on load. I converted eight synthetic files that link to a listener on 127.0.0.1: RTF `INCLUDETEXT` and `INCLUDEPICTURE`, a DOCX external picture, an ODT linked section and linked picture, an ODS table source and an external cell reference, and an ODP linked picture. None made a connection, with or without the link settings. Two things remain open: Impress has no link-update setting, and the process's network access isn't sandboxed (documented in `crates/create/README.md` and `docs/gaps.md`). ## Parity and reach - [x] `create.from-office` planned → partial, with the evidence tests and what's still missing - [x] Reachable headlessly (`doc_create_multiple`, `doc_combine`, batch `convert`) - [x] Docs: `docs/file-format-parity.md` (Word/Excel/PowerPoint and RTF rows), `docs/gaps.md` gap 8, `docs/target-app-parity.md` row J (numbers not re-measured), `docs/roadmap.md` M10, the `ROADMAP.md` log, and the `crates/create` README ## Anything a reviewer should look at first 1. **The network question** above. It's the owner's call. 2. **The frame-thread restraint:** Insert, Combine and Create Multiple refuse Office documents rather than block. Is a worker for those wanted in this PR, or as a follow-up?