## What this changes
Fixes#3850.
Partly addresses #3682: drags within one column of its two-column page now select that column's text (the tester's harness measured 3/9 → 7/9 on `cols.pdf`). Drags from one column into the next still follow reading order, as on `main`; they need column-correct reading order (#3676).
Selecting text in a table selected the wrong things. A table reads column by column, so a plain drag along one row, or down and across a few rows, took the rest of the first column and the top of the next ones between its ends. On some rows the highlight jumped to the column headers and half the table. Copy gave the cells out of order. Preview selects these by rows, and this makes PdfCraft do the same.
Rebased on `main` after the module split (#4889): the row selection is in `crates/render/src/text.rs`, the padded-cell join and barcode rows in `text/order.rs` and `text/layout.rs`, the drag's pointer end in `crates/ui-egui/src/canvas/selection.rs` and `canvas.rs`.
- **Visual rows** (`PageText::visual_between`, used by `Selection::rects` and `Selection::text`). The drag selects by rows when reading order between its ends would not follow them: the rest of the first row, the rows between, and the start of the last. Copy gives the cells tab-separated, as Column select does. Rows are used when either of these holds:
- Reading order strays above or below the rows dragged over.
- The drag ends in another column. That means across a gutter that no text of those rows crosses (checked a few lines above and below as well, so a justified line's wide word gap does not count), or with the pointer past the end of both lines.
- **Limits on the rows.** Rows never take lines left of everywhere the drag has been, and they stay in the column when the drag does. A drag in the right column of side-by-side text that ends on a hanging list marker does not pull in the left column. Running text keeps reading order whenever it covers the same glyphs, and so does a column of running text beside a sidebar. A drag down one article or table column keeps reading order too. A line's extent for these limits is its ink: spaces padding it out don't count.
- **Pointer position.** `Span::Run` records where the drag's pointer ended (`head_x`). It can lie well past the nearest glyph, as in the empty end of a table row. Select all, a clicked word, line or page, and four-click page selection keep reading order.
- **Hit testing.** `PageText::nearest` still takes the glyph whose em box holds the point first (#3693). Failing that, it prefers the row level with the pointer, so past the end of a short row a longer row just below no longer captures the drag. Between rows, the old distance rule still applies.
- **Padded cells.** Report generators pad each cell of a row with trailing spaces. When a cell's padding stops short of the next cell (more than the 3-height continuation gap), the next cell on its baseline now joins the row, within 8 line heights (`PADDED_FIELD`, `PADDED_REACH`). Without this, the row's last cells were read after the whole table, in selection, copy and `text_extract` alike.
- **Barcodes drawn with a font** (new since the last review). Mexican CFDI invoices and receipts print their QR or Aztec code with a barcode font: rows of glyphs stacked flush, 5 pt from the seal's text and on nearly the same baselines. Each code row joined the seal line beside it, so the seal read interleaved with the code, some seal lines came after the whole code, and a drag down the seal jumped through it. Rows of at least 8 glyphs, 4 or more stacked flush with one glyph count and the same edges (`barcode_rows`), now join no other text and read after the page's text. A point beside the code is not taken by the code row level with it.
## How you verified it
- [x] `cargo fmt --all --check`
- [x] `cargo clippy --workspace --all-targets --locked -- -D warnings`
- [x] tests pass on the crates I changed, and on the crates that use text extraction:
| Crate or suite | Passed |
|---|---|
| `pdfcraft-render` (lib + integration) | 476 + 64 |
| `pdfcraft-ui-egui --lib` | 696 |
| `pdfcraft-ui-egui --test it` (`column_select`, `text_select`, `drag_selects`, `selection_works`) | 33 |
| `pdfcraft-automation` | 359 |
| `pdfcraft-engine` | 442 |
| `pdfcraft-export` | 43 |
- [x] `cargo xtask layers`, `cargo xtask assets`, `cargo check -p pdfcraft-render --target wasm32-unknown-unknown`, `cargo xtask parity`
**Tests.** I reverted each fix and watched these fail:
- `column_select.rs::a_plain_drag_along_one_table_row_selects_only_that_row` (UI, select tool and highlighter). Before the fix it got `"Apple\nPear\nQty\n12"` instead of `"Apple\t12"`.
- `column_select.rs::a_drag_ending_between_the_columns_selects_the_rows` (UI). Without `head_x` it got `"Name\nApple\nPear"`.
- `column_select.rs::a_drag_down_text_beside_a_barcode_selects_only_the_text` (UI). Without the barcode rows it got `"ABCDEFGHIJKL one two three\nFGHIJKLMNOPQ\n…"`.
- `text/tests.rs::a_barcode_drawn_with_a_font_reads_apart_from_the_text_beside_it`
- `text/tests.rs::a_point_beside_a_barcode_takes_the_text_not_the_code_row`
- `text/tests.rs::a_point_level_with_a_row_takes_that_rows_nearest_glyph`
- `text/tests.rs::a_drag_in_the_right_column_takes_nothing_from_the_left`
Also new in `text/tests.rs`: `a_drag_along_one_table_row_selects_that_row`, `a_drag_over_table_rows_selects_those_rows`, `a_drag_that_reading_order_already_covers_keeps_it`, `a_pointer_ending_beside_the_column_takes_the_rows`, `a_cell_padded_with_spaces_joins_the_next_cell_of_its_row`, `a_justified_gap_is_not_a_gutter`, and the running-text, sidebar, heading and vertical-text cases from earlier review rounds. In `column_select.rs`: double-click drags, select all and four-click page selection on uneven columns.
All fixtures are synthetic.
**Corpus.** Before the rebase, text extraction of the pinned pdf.js corpus was byte-identical with and without this PR. The barcode change leaves it identical too: the plain text of the first 60 pages of each of its 989 PDFs (1,525 pages) is the same with and without it.
**Real-world files** (private, not attached):
- **Text extraction:** on a personal folder of PDFs (bank and hotel reports, payslips, policies, contracts, slide decks), the only files that differed from `main` before the rebase were bank statements whose padded rows now keep their amounts. The barcode change alters one page across 105 files: a statement's CFDI page, whose seal now reads in order.
- **Simulated drags:** about 53,700 random drags across those files. Selection takes at most about 145 µs per frame.
- **Two-column contract:** on a bilingual two-column contract, 2,649 drags kept inside one column leak into the other column less often than before (1,224 vs 1,591). In no file does the new code leak a two-column drag where the old one did not, except across table rows, where rows running across the page is intended.
## Never-crash
- [x] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests
- [x] No indexing with input-derived positions in the new code (`get`). Allocations are bounded by the page's glyphs, and `side_by_side` compares at most `SIDE_BY_SIDE_LINES` lines. Barcode rows are found with one hash map over the page's rows. NaN boxes rank last in `nearest` (`total_cmp`) and are never barcode rows. No recursion.
- [x] No `unsafe`
- [ ] Any crash this fixes has a small synthetic regression test (n/a, no crash)
## Provenance
- [x] No asset from an Adobe product, and no Adobe icon recreated, traced or imitated
- [x] No font files committed here
- [x] No GPL/AGPL code copied in
- [x] Behaviour comes from black-box observation (Preview's selection on the same files), not from reading another implementation's source
- [x] No new assets
## Security
- [x] `--root` confinement untouched
- [x] MCP and the UI control channel untouched
- [x] No new outbound network access
## Parity and reach
- [x] `parity/acrobat-features.toml`: `view.text-select` gains the new evidence and a note. Selection across pages is still missing, as before.
- [x] Reachable headlessly: the padded-cell join and barcode rows change `text_extract` too. Visual-row selection is a pointer interaction, like the rest of plain drag selection.
- [x] `ROADMAP.md` progress log line
## Anything a reviewer should look at first
1. **A deliberate behaviour change.** `alt_drag_selects_only_the_text_inside_the_rectangle` asserted that a plain drag from "Name" to "12" selects in reading order: `"Name\nApple\nPear\nQty\n12"`. It now selects the two rows dragged over, `"Name\tQty\nApple\t12"`, and I changed the assertion. Reading order itself is unchanged (`a_column_selection_takes_one_table_column` still asserts it), and so is a drag down one column.
2. **The gutter test is a heuristic.** "Another column" means a vertical strip at least one line height wide that no text in the dragged rows, or 4 line heights around them, crosses. That catches table columns and side-by-side text, and ignores justified word gaps in practice. Thin columns with ragged edges may still fool it.
3. **The padded-cell join changes reading order.** It applies only to a segment whose box runs on at least 2 line heights of trailing spaces past its last visible glyph, and only towards a cell on the same baseline within 8 line heights.
4. **Barcode rows change reading order.** A stack of 4+ flush rows of 8+ glyphs, each with the same glyph count, height and edges, reads after the page's text. Text set solid still has lines of different lengths and counts; a hex dump set solid in a monospaced font would also match, and would then read last.
5. **Overlap with #2431** (narrow article gutters, #2387). The two PRs touch different code paths and should rebase cleanly.
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit
* **New Features**
* Text drags across table-like layouts now select cells by visual row, with copied cells separated by tabs. Multi-click and Shift-click selections can also follow visual rows where applicable; select-all and other non-row selections retain reading order.
* Text selection and pointer placement work more accurately around columns, gutters, and barcode-like text, reducing unintended selection of nearby content. Barcode text is kept separate from adjacent text.
* **Documentation**
* Updated feature coverage and progress notes to reflect text-selection behavior and regression testing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
- A link that fails puts the realm's timers back as they were before its
own script ran, and turns no layer: timers it set are dropped, timers it
cleared come back (timers::save/restore in Realm::run_job).
- In a read-only document a link's requests still apply when its field
changes can't; the outcome carries the read-only error.
- app.openDoc during a calculation (calculateNow) is noted on the console
and not queued, as for a field script running on its own.
- Link script streams are decoded once per stream, whatever their source
length, and /Filter [] reads as the raw source.
- The web build sets the view's zoom, page and highlight before a link
script runs, as the desktop does.
Main now has page actions, timers and layers (#6985) and shared text
allowances for inspection (#6015). Conflicts:
- render/inspect/annotations.rs: main's collect_link (which notes
JavaScript even when the link isn't listed) takes this branch's link
script budget and its once-only warning; push_link is gone.
- automation form_tools.rs, js tests.rs, docs/acroform-script-workflows.md:
both sides added at the same place; both kept.
Adapted to main: the link runner's Job gets `timer: None`, and, as a
button's script does, the document's layers and the fields as page
actions and timers show them (live_state).
vendor/README.md conflicted: main took hayro-interpret patch 104 (RGB
colours in isolated DeviceCMYK groups). This branch's viewer overrides
become patches 105 (annotation_overrides) and 106 (appearance_overrides)
in the README, in vendor/hayro-interpret/src/interpret/mod.rs, in the
raster test and in docs/acroform-script-workflows.md.
* fix(render): an RGB colour in an isolated DeviceCMYK group blends in CMYK, as its ink
Part of #4887. An isolated DeviceCMYK group blended in CMYK only when every
colour in it had process tints (DeviceCMYK, DeviceGray); one RGB fill sent
the whole group back to RGB blending.
Vendored hayro-interpret patch (104): Color::ink_tints gives DeviceRGB,
CalRGB, Lab and three-component ICCBased colours the ink that draws back to
them through the bundled CMYK profile (new cmyk_inverse module: black
0.4*k0^2 + 0.6*k0^8 with k0 = 1 - max(r, g, b), matched to Acrobat on
synthetic pages; then cyan, magenta and yellow by at most 12 Newton steps
against the profile; one-entry per-thread cache). ICCBased CMYK still has no
tints.
Test an_rgb_colour_in_an_isolated_cmyk_group_blends_as_its_ink: 50% grey
under Difference with black within 10 levels of Acrobat's 137 112 106;
in-gamut colours draw as themselves; red within 8 of Acrobat's 238 32 37.
With the additive arms reverted the grey draws 93 97 96 and it fails.
#5228's control now uses an RGB image, which still has no tints.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(render): an RGB colour with a non-finite component has no CMYK ink
`to_rgba` turns a non-finite component into some finite colour, so the
inverse could not see it and gave that colour's ink. `ink_from_srgb` now
declines such a colour and the group draws it as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: KenM76 <KenM76>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(render): charge field, tooltip, layer, bookmark and blank-comment text against shared allowances
* address review: charge comment text once, keep JS and field-action flags past the budgets
* review: count an unlisted field's actions through its whole subtree
* review: mark multi-select values the allowance dropped
* review: count only real actions, walk each field once, keep name selections
* address review: revisit shared field descendants from shallower paths, charge value separators, check every widget for actions
* merge main, keep both
- A page action or timer whose edits are refused shows nothing it did:
view changes go in only after apply_script_outcome accepts them.
- js_advance runs at most its 6,000-run cap; the last call only moves
the clock (Session::run_timers_at_most).
- Field callbacks see the document's layers (getOCGs), also after a
batch rebuilds the runner.
- The viewer's hit test uses what scripts show (shown_by_scripts): a
frame they showed takes presses, one they hid doesn't.
- Print previews draw without the live annotation and appearance
overrides.
- An appearance override naming no form (a cleared icon) draws nothing
instead of the button's own face (hayro patch 105).
- Page actions and timers wait while a dialog or prompt is open.
- A timer editing the focused field refreshes its untouched draft.
Ported from #2081: link annotations whose action is JavaScript keep the script (up to 256 KiB
each, 4 MiB per document; larger ones are rejected and reported once).
Co-authored-by: LMatt08 <245691047+LMatt08@users.noreply.github.com>
## What this changes
lopdf's fallback loader (used by `inspect` when the lazy reader fails, and always by `attachment_data`) parsed every object stream eagerly, capping only decoded bytes. A stream of `0 0 0 …` inside the 256 MiB limit built ~120M `Object`s (10+ GB) and aborted. This bounds what it builds:
- **Per top-level object:** at most 4,194,304 objects (`parser::MAX_OBJECT_NODES`, the same as cos's `MAX_NODES`), counting every array element and dictionary value at any depth. One budget is threaded through `array` / `_dictionary` / `_direct_object`. An object is charged once it is built, so failed `alt` branches cost nothing, and an exhausted budget is a nom `Failure`.
- **Per object stream:** at most 1,000,000 index entries (cos's `/N` clamp), and the index list is no longer allocated from the whole block.
- **Per load:** at most 8,388,608 objects across all object streams, in an atomic `NodeBudget` shared by rayon workers. A member reserves up to its own byte length (every object takes at least one byte) and refunds what it doesn't use, so parallel members can't hold the budget between them. Once the budget is spent, the remaining members are skipped with a warning.
- **`convert` in `crates/render/src/structure.rs`:** the depth limit is now 128, one level per array or dictionary, matching cos's parser. Before, it was 100 with two levels per dictionary, so ~51 nested dictionaries alone forced the lopdf fallback.
Logged as lopdf patch (7) in `vendor/README.md`.
Fixes#5083
## How you verified it
`cargo fmt --check` on `pdfcraft-render` and `cargo clippy -p pdfcraft-render --all-targets -- -D warnings` (which compiles the vendored lopdf). Regression tests in `crates/render/src/inspect/tests.rs`, where lopdf patches are tested in-tree (the vendored lopdf's own tests need its missing `assets/example.pdf`):
- `object_stream_members_over_the_node_cap_are_skipped` (croquete1): a member that is one array of 4.3M elements is skipped at the per-object cap; a small member is kept.
- `object_stream_index_entries_past_the_clamp_are_ignored`: of 1,000,001 distinct members, the one past the 1,000,000-entry clamp is never built.
- `object_streams_of_one_load_share_their_node_budget`, `object_streams_loaded_in_parallel_share_their_node_budget` and `a_malformed_object_stream_member_refunds_its_reservation` (Harrison-Booth): a small shared `NodeBudget` bounds the members of several streams, serially and across threads, and a malformed member refunds what it didn't build. `NodeBudget` and `ObjectStream::new_within` are now public for this.
- [x] `cargo fmt --check`
- [x] clippy with `-D warnings` on the crates I changed
- [x] tests pass on the crates I changed (`cargo test -p pdfcraft-render --lib object_stream`: 9 passed, including the five new tests)
## Never-crash
- [x] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests
- [x] No indexing or slicing with input-derived positions; the budget uses `checked_sub` and a `min`-bounded reservation; index entries and objects built are capped; nesting stays bounded (128 in `convert`)
- [x] No `unsafe`
- [x] The abort this fixes has synthetic regression tests (above)
## Provenance
- [x] No assets, fonts or copied code; the limits follow `pdfcraft_cos`'s own caps
## Security
- [x] No change to `--root`, MCP, the control channel or network access
## Parity and reach
- Not applicable: an internal robustness fix to the fallback loader, with no user-facing feature or parity entry. The vendored-patch log (`vendor/README.md`, lopdf (7)) is updated.
## Anything a reviewer should look at first
- `vendor/README.md` now lists the tests above for patch (7).
- Ordinary (not compressed) indirect objects are capped at 4M objects each but aren't charged to the per-load budget; their total is bounded only by file size.
- `get_compressed_object` still decodes and re-parses the whole stream per lookup (pre-existing); it now gets one object's budget.
- Objects nested 101–128 deep now reach the Inspector through the lazy reader rather than being skipped by the fallback. The Inspector's own walkers cap depth at 32 or less.
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->
## Summary by CodeRabbit
* **Bug Fixes**
* Added limits to PDF object parsing and object-stream processing. Loading now parses only selected members, while lookups parse only the requested member. Members that exceed available limits are skipped in a deterministic order.
* Increased the supported nesting depth for dictionary and object conversion, allowing more deeply nested content to be processed.
* Documented that conversion failures can trigger eager loading as a fallback.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
* Render: an isolated CMYK group of any size blends in CMYK, in bands
hayro patch (27) blended an isolated DeviceCMYK transparency group in CMYK only up to 2^24
device pixels (a 4096 x 4096 square); a larger one was blended in RGB. Print as image renders a
whole page at once, up to 8192 px on the long side, so a page enlarged to 11 x 17 in at 600 dpi
(about 43 MP) blended any group covering most of it in RGB, and a print test file's CMYK blend
patches drew their X's. The same applied to exporting a large page as an image.
A larger group is now blended in bands of whole rows, each within the old limit: both ink passes
and the tint conversion run per band, so their memory stays as before, and only the finished
group (4 bytes a pixel) is kept whole. Every band is blended before any is drawn, so a group a
pass has no tints for is still drawn as before, whole.
The regression test lowers the band to 7 rows (set_cmyk_band_pixels, hidden, per thread like
set_skip_offscreen_paths) and checks the banded group matches the whole one across an
anti-aliased edge, and is CMYK's green where magenta is under black with Difference.
* Test: CMYK green through the profile is (0, 166, 81); check green against RGB's magenta