Alphas keys decoded pictures by their bytes' address and length, which is
sound only while the document is borrowed for the layout pass; that is now
said where the key is. LayoutCache's outlines outlive the documents they were
made for and use the same key, so each entry now keeps a Weak reference to
the bytes: their allocation can't be freed and the address reused by another
picture of the same length while the entry is kept. MAX_FLOW_WIDTHS spells
out which layout a box past the 16-width cap reuses.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
Stacked text is laid out at STACK_WIDTH whatever the frame, so its flow key now is too.
The nearest-layout fallback only picks layouts of the box's TextVert; the first box in a
direction is laid out (at most a few more layouts).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
Crops alternating between pictures decoded each again and again, spending the budget on
pictures that fit in it. Every picture charged to the budget keeps its alpha (bounded by
the same 2^28-pixel budget).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
Ultrareview finding 6: boxes past a chain's 16th distinct width (compared
by exact float equality) showed no text, which resized boxes hit easily.
Widths are now told apart to a twip, and past the cap a box takes the
nearest layout already made (same direction, widest that fits, else the
narrowest). Work stays bounded at 16 story layouts.
Ultrareview finding 7 (also raised by CodeRabbit): opaque non-JPEG
pictures were decoded and charged to the per-layout pixel budget though
they give no outline, and a budget refusal was cached in LayoutCache as a
permanent "no outline". The header (read via into_decoder, PNG tRNS
included) now says whether a picture can be transparent before anything is
charged; Alphas::outline reports OverBudget distinctly from Ok(None), and
Ctx::wrap_outline (via picture_outline) doesn't keep a refusal in
LayoutCache::outlines. Within one layout a refused picture is remembered
so its header isn't re-read.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
out any crop's outline decoded the whole shared picture again, so many
small picture references with distinct crops multiplied full-image
decodes without repeating the encoded image. Layout now keeps the last
picture's transparency (one alpha byte per pixel) and reuses it for
every crop of it, and charges each decode's pixels against
MAX_DECODED_PIXELS (2^28) per layout; past it pictures wrap as their
rectangles. Tests: tests_budget::a_picture_cropped_many_ways_is_decoded_once,
tests_budget::decoding_pictures_stops_at_the_layouts_budget.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Audit finding 11: place_table cloned every item of the header rows onto
each page a table spills onto, but its cap (200 000) counted rows. An
exact-height header row keeps all its overflowing paragraphs without
growing, so one header row of thousands of items over hundreds of
pages placed millions of copies. The cap now counts placed items
(MAX_HEADER_ITEMS = 500 000, a row costing at least one) and the
header set repeats whole or not at all; past it later pages carry no
header rows and pagination still finishes. Test:
tests_budget::repeated_header_items_are_capped_across_the_table.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Audit finding 7: flow() laid the head box's whole story out again at
every distinct box line length in the chain and kept each layout while
splitting, so a chain of many one-line boxes of different widths
multiplied the story's layout work and memory by the chain length.
Past MAX_FLOW_WIDTHS (16) distinct lengths the text stops: the box that
would need another layout and the rest of the chain show nothing, as
overflow past the last box is hidden. Work is now at most 16x the
story. Test: tests_budget::a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Audit finding 35: Table::cols() added a deserialized row's grid_after
(u32) unchecked, and layout_table sized its column vectors from it, so a
small native JSON document could ask for a 16 GB grid (and overflow the
sum on 32-bit wasm). Row::grid_after() now clamps to MAX_COLS as
first_grid() does and the importer does; cols() sums with saturating
adds; layout reads the bounded value. Test:
tests_budget::a_huge_grid_after_is_bounded_like_the_docx_readers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The layout's linear-time tests (#3866) asserted CPU-time ratios, which a busy
CI machine can skew. Each walk that once ran again for every item it served
(a line's clusters, a page's items, the revision authors, the sections, a
table's rows, a run's glyphs, the clipped cells) now counts the elements it
looks at in a test-only counter (crate::work, nothing outside tests), and the
tests assert 8x the input takes at most 12x the steps (quadratic: ~64x).
proof_text, whose walk is the document model's, stays timed, and a ratio
over the limit is measured twice more before it fails.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
Finding 3: the table's rows carry no cells, only a column with a width skipped before and after,
so each row adds two spanning entries for next to no layout and counting the grid per entry
dominates (old code 75x for 8x the rows, fixed code 4-8x).
Finding 13: sections that link to the previous section's headers and footers are laid out
against the same sections naming them (title page on, so first-page parts are looked up too);
old code 6.1x the time, fixed code 1.0x, limit 2x.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 30: author_index rebuilt the list of revision authors for every tracked run and paragraph mark drawn, and balloons rebuilt the comment-author list for every comment (quadratic in revisions or comments). display::author_order ranks authors by first appearance once; page_display, page_layers and object_display build the revision order lazily once per call and pass it down, and balloons builds the comment order once per layout. Same colours.
Tests: drawing_tracked_changes_by_many_authors_takes_linear_time, tracked_changes_colour_by_author_in_order_of_appearance, balloons_for_many_authors_lay_out_in_linear_time.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 1: cell_clip searched every clipped cell on the page for each line drawn (quadratic in a page of many exact-height cells). clipped_cells now indexes them by story and content path, keeping each one's order; a line looks up only its path's prefixes and takes the earliest, the same cell the scan found first.
Test: drawing_many_clipped_cells_takes_linear_time.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 28: column_segments and selection_rects called pieces() per paragraph, and for a header, footer or note each call scanned the whole page (pieces_on). PartPages indexes a page's lines of the story by paragraph the first time the page is looked at; the page order (hint first, continued notes) is unchanged.
Test: selecting_a_long_header_takes_linear_time.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 13: linked() and defining_width() walked back through the sections before each section, per section and again per page (layout_pass, headers_footers, footer_table_wraps, header_table_areas). HfLinks resolves every section's linked header and footer sets incrementally once (each inherits the previous one's for the parts it leaves out) and keeps, per part id, the sections naming it, so the defining section is a binary search. Same sets and widths as before.
Test: headers_inherited_through_many_sections_lay_out_in_linear_time.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 3: column_content called Table::cols (a walk of every row) for each spanning cell, and there can be one in every row. Hoisted out of the loop; the end is also added with saturating_add.
Test: autofit_tables_with_a_spanning_cell_in_every_row_lay_out_in_linear_time.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 23: Ctx::glyphs found each character's first glyph by scanning the shaped glyphs from the start (quadratic in a long run). The first glyph at or past a boundary never moves back as the boundary grows, so one walk gives the same cuts.
Test: math::tests::a_long_text_run_lays_out_in_linear_time.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 5: off_at_x and visual_step on a bidirectional line called x_of for every caret offset, each searching the clusters from the line's start (quadratic in a line of many zero-width offsets, e.g. hidden text). ParaLayout::line_carets walks them with a cursor; the first matching cluster never moves back for non-decreasing offsets, so the x's are the same.
Tests: bidi_clicks_and_caret_steps_take_linear_time, line_carets_match_x_of.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 29: proof_text looked up each character's run with props_of_char, walking the runs from the first for every character (quadratic in a paragraph of many runs). A run cursor advancing with the text gives the same props in one pass.
Adds layout::tests_linear (sharing tests.rs's cost_ratio helpers) with proof_text_walks_the_runs_once.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Layout: a no-break space is as wide as a space, not as the font's own U+00A0 glyph (fixes#4192)
* Layout parity: a no-break space is as wide as a space (#4192)
SonarCloud's analysis of main at b4da59f0c lists 23 closures such as `|v| v.as_slice()` that hand their argument straight to one method. This passes the method itself: `Vec::as_slice`, `str::to_ascii_lowercase`, `f64::total_cmp` and the like, all forms the repo already uses elsewhere. Behaviour is unchanged.
Five stay. The four in crates/proof/src/es/mod.rs sit in code that #4191 replaces, and its replacement already uses `PoisonError::into_inner`. In crates/engine/src/io/safe_save.rs the path would be `<dyn std::error::Error + Send + Sync>::is::<Guided>`, which reads worse than `|inner| inner.is::<Guided>()`.
A field with \* Lower \* Caps applied only \* Lower, so a title of
WEEKLY REPORT showed as weekly report. Every capitalization switch in a
field code now applies in turn, and the example shows Weekly Report.
A field or table formula with \* DollarText or \* Hex showed its digits,
and PAGE \* ArabicDash showed the plain page number. \* DollarText now
writes the result in lowercase cardinal text with "and" and the first two
decimals (rounded) over 100, so { = 9.20 + 5.35 \* DollarText \* Upper }
shows FOURTEEN AND 55/100. \* Hex writes uppercase hexadecimal digits
(458 shows 1CA). PAGE \* ArabicDash shows the page number between
hyphens ("- 31 -"); other fields, PAGEREF included, skip it. The
page-number, SEQ, PAGEREF and count fields and table formulas read both
switches. Cardinal, ordinal and dollar text keep the digits of a number
past 999,999. Before, \* CardText showed such a number as "nine hundred
ninety-nine thousand nine hundred ninety-nine" and \* OrdText as "nine
hundred ninety-nine thousand nine hundred ninety-ninth".
Update Field and the page-number fields ignored the capitalization
switches, so TITLE \* Upper or FILENAME \* Lower showed the text as
stored. A \* CardText or \* OrdText result started with a capital
("Twelve"), where Word shows it in lowercase unless a capitalization
switch follows. A table formula with a \* switch, such as
=SUM(A1:B2) \* CardText or =SUM(ABOVE) \* MERGEFORMAT, was a syntax
error.
The results of the document information fields, DOCPROPERTY,
FILENAME, the dates, REF, PAGEREF, SEQ, STYLEREF and table formulas
now take the field's \* Upper, \* Lower, \* Caps or \* FirstCap switch
when Update Field computes them. PAGE, NUMPAGES, SECTION,
SECTIONPAGES, PAGEREF and STYLEREF take it when they are laid out.
Cardinal and ordinal text is lowercase ("twelve", "seventh") unless
one of the switches follows. WordCraft now reads a table formula's \*
switches separately from the formula. A whole-number result that a \*
number format such as CardText or roman covers is written in that
format; any other result, and any other \* switch, leaves the digits.
Table Layout › Data › Formula keeps the \* switches as typed.
Document pictures now decode through wordcraft_geom::picture (behind a
new `image` feature of the geom crate). It reads the header, refuses the
picture before anything is allocated when the decode's peak would pass
the budget, then decodes with the header's size and the budget as
limits.
The peak is the larger of three moments: the decoder's output beside
what it holds while decoding, the output beside its RGBA8 conversion,
and that beside a premultiplied copy. A progressive JPEG's decoder
(zune-jpeg) holds an i16 for every coefficient of every component, in
whole MCUs, for the whole picture; the SOF header gives that size. A
7900 x 7900 progressive JPEG without subsampling needs 9 bytes a pixel
(561 MB), past the 512 MB budget, although two RGBA buffers (499 MB)
fit. With the fix reverted, the test's header-only file of that size
was decoded and the test process peaked at 542 MiB resident.
- Render and PDF export ask the probe in their wrappers (decode_pixmap,
load_image), so a picture past the budget gets the grey box or is
left out, as before.
- The crop dialog and every picture edit decoded with load_from_memory
and looked at the size afterwards. They decode through the probe now,
and so do wrap outlines and the RTF re-encode.
- A picture edit has the drawing budget instead of an 80 M pixel cap
checked after decoding. A picture past that budget (about 67 M RGBA
pixels) can't be drawn either, so no edit that could be seen is lost.
Merge upstream main at 254351c01b,
retaining both roadmap histories and the existing anchor and cap fixes.
Document the changed DOCX reader, display builder, raster renderer and
PDF exporter contracts without changing their behavior.
Validation: cargo xtask ci passed all eight stages locally. Optional
craft-i18n catalogs and craft-fonts were absent. Independent local
coverage audit documents all 26 touched functions; CodeRabbit must
recalculate its own metric after a separately approved submission.
Co-Authored-By: GPT-6 <codex@openai.com>