Text after a decimal tab is shifted after each of its clusters, and each
shift walked the text again from the tab to find its first '.'. A long run
without a '.' (soft hyphens take no room, so it stays on one line) made a
single paragraph's line breaking quadratic. The scan now carries on from the
cluster it reached.
* DOCX: VML horizontal rules take their line
A web page's <hr> pasted into Word is a VML rule: `v:rect` with
`o:hr="t"`, often `width:0`. WordCraft took the width as written and
drew a 1 pt dot where Word draws a line across the page.
Measured on Word with a synthetic document: a rule with no width and
no `o:hrpct` spans the line inside the paragraph's indents; `o:hrpct`
is a share of it in tenths of a percent (500 = half); `o:hrpct="0"`
keeps the shape's own width; `o:hralign` places a shorter one (left,
centre, right); `fillcolor` and `stroked` apply as to any VML shape.
WordCraft now matches each case within 0.1 pt.
- wordcraft-doc: ShapeExtra::hr (HorizontalRule: share, alignment,
Word's standard look).
- wordcraft-docx: `o:hr`, `o:hrpct`, `o:hralign`, `o:hrstd` read; a rule
is written back as the same VML rule (DrawingML has none), and Word
opens the re-saved file with the same lines.
- wordcraft-layout: a rule takes its whole line and is drawn at its
share; measured for a table column it asks for no width, so it
follows its column. (Word draws a rule in a cell at the page's text
width, centred on the cell and past its edges; it stays in the cell
here.)
docs/file-format-parity.md is updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Horizontal rules: only an inline, shown rule is one
Review: the rule arm of the writer ignored the shape's placement, so a
rule imported with position:absolute or visibility:hidden came back
inline and visible. Only a rule in the text and shown is read as a rule
now (a pasted <hr> is one); a positioned or hidden one stays a plain
shape, which keeps both when saved. The writer checks the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Horizontal rules: a see-through fill is kept; column measuring asks for the right width
Review: `write_hr` wrote only the fill's colour, so a rule with `v:fill
opacity` came back opaque after a save; the opacity now goes back as `v:fill`.
Review: measured for a table column, every rule asked for no width, so one of
its own width (`o:hrpct="0"`) could end up clipped by a narrow column; it now
asks for its width. AutoFit to contents measures on a line of its own width
that the rule took for a real line, so a rule sharing its line made the
column as wide as it could go; any measuring width now counts.
`Placed::Shape` boxes its extras: with the rule, `Placed` went past clippy's
large_enum_variant limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Horizontal rules: placed in what the line has left; rotation and flips kept on save
Review: a whole-line rule ignores a first-line indent or a line narrowed by a
float. Measured in Word, it does too: the width stays the share of the
paragraph's width and the rule runs past the margin, or under a float on its
right. What did differ was the placement: Word centres and right-aligns a rule
in what the line has left, with a first-line indent moving the end of that room
along, and a right-aligned rule beside a float was drawn off the page here. The
display now places it that way (`ParaLayout::rule_room`).
Review: a save dropped a rule's rotation and flips; they now go back in its
style, as Word writes them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: VML horizontal-rule revision row at the top of the history
---------
Co-authored-by: Daniele Mori <Dinasty@hotmail.it>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Shapes: a solid fill keeps its transparency
A shape's fill colour can carry a:alpha, its opacity. WordCraft dropped
it, so a translucent white wash laid over a picture was drawn opaque and
hid the picture. The transparency is now read into the shape, drawn on
screen and in the PDF, and written back.
* Fix#1600 freeform and soft-edge fill opacity
* #1600: wrap canonical caret sampler signature
* #1600: format canonical integration
* #1600: assert canonical DOCX alpha round-trip
* #1600: document canonical shape opacity API
* Adapt alpha fixture to rectangle render bounds
* docs: one file-format-parity row for soft-edge fill opacity, status line
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Count the runs proofing text walks, instead of timing it (#5185)
`proof_text_walks_the_runs_once` was the one linear-time test here that timed
its walk, and the ratio crept over its 24x limit when the crate's tests ran in
parallel -- 28.2, 26.4, 25.6 -- from cache and memory contention rather than
from any regression.
The file's own module doc says why the others don't have this problem: "The
cost is the steps the lookups' walks count (crate::work), not a timing, so a
busy machine can't make a linear lookup look quadratic or the other way round."
It then says this one is timed because "proofing text, whose walk is the
document model's" counts no steps -- but `proof_text` does the walk itself,
advancing a runs iterator alongside the text, so it can count the runs it looks
at like every other walk here. Nothing in `wordcraft-doc` had to change.
before 8x the runs took 28.2x the time (limit 24), under load
after 50000 steps for n, 400000 for 8n, ratio 8.0 (limit 12)
The count is the same on any machine under any load, so the flakiness is gone
rather than made less likely: raising the limit to 32x would have left the same
failure available on a busier machine. It is also about 100x faster -- 0.03 s
against three rounds of eight repetitions -- and `work::steps` is `#[cfg(test)]`
inside, so release builds are untouched.
The test still catches what it was written for. Putting the quadratic walk back
(the run for each character found from the first run, every time) gives:
proof_text: 1250025001 steps for n, 80000200001 for 8n, ratio 64.0
panicked: 8x the input took 64.0x the steps: not linear
Verified under the reported condition: three runs of the whole crate suite in
parallel, 559 tests, all green.
* linear tests: proofing text is counted now, not timed
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Word draws an arrowhead 2, 3 or 5 times the line's width long and wide
(small, medium, large; `a:headEnd`/`a:tailEnd` `len`/`w`), a line under
2 points as a 2-point one. Every arrowhead was drawn one size and saved
as a medium triangle: the kind, width and length are now read (DrawingML
and VML), kept and written back, and the size is drawn as Word does.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* #5178: the line between columns is drawn for a section starting mid-page
Columns for selected text are a continuous section in the middle of a page, and
the line between columns was only added when a page started, down to the page
bottom. The lines are now drawn per section on each page, from the top of its
text there to the bottom of its tallest column, when the page or the section ends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* #5178: the line between columns spans the text's lines, not its paragraph spacing
Review: the line started above the section's first line (its space before) and
ended below its last (its space after). It now runs from the highest text line or
table cell of the section on the page to the lowest, falling back on the flow's
extent when there are none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* #5178: the column lines find the section's text by place, and draw nothing without it
Review: the section's items were the page's items after an index, which objects
behind the text (inserted at the front) shift; the flow's extent drew a line
where the section had only spacing; overflow lines of exact-height cells counted.
The text is now the page's top-level body lines and table cells below the
section's top; lines in cells count by their cell's box; no text, no line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* #5178: the column lines take the section's text by its blocks
Review: a floating table of an earlier section placed lower on the page would
pass a filter by place. The section's text is now the page's items of top-level
blocks from the section's first block on the page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
The numeral and automatic-space tests compare two layouts of one text. Tests
running alongside share the font database, and one loading a font between the
two layouts can change the face a character falls back to: U+06DD drew in
Segoe UI in one layout and Noto Sans Arabic in the other on Windows, failing
arabic_mode_keeps_arabic_indic_digits_as_typed and
context_mode_follows_run_direction now and then, and
spaces_punctuation_hangul_and_latin_alone_get_nothing failed the same way. The
pairs are now laid out again until both use the same faces.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
When a row's first part on a page that already holds something would leave
a cell empty because that cell's inline picture (one line, never split) or
first line runs past the room, the row moves on whole instead of leaving the
other cells' text beside the empty cell. An at-least row grown taller than its
minimum by its picture used to pass the #1742 check (room >= declared minimum)
and then split, the picture going on alone. At the top of a page such a row
still splits.
Co-authored-by: Harrison Smith <harrison@hsmith.dev>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Measured in Word for Windows: when a table row breaks across pages, a cell
aligned to the middle or bottom whose text all fits in the part of the row
left on the page is aligned within that part, as tall as the other cells'
text there (with its space after), and a cell whose text doesn't fit runs
from the top of the cell with the rest on the next page. WordCraft kept the
offset the whole row's height gave the cell, so a short centred label
beside a long cell went to the top of the next page and left its cell empty
on the first, and a long centred cell started far down the page.
The vertical offset each cell's content got is kept with the row, so a split
moves the content back to the top before it decides what fits.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Layout: tab leader dots spaced and placed as in Word
Measured in Word for Windows with six fonts at four sizes: Word spaces leader
dots by the dot's advance at the font size in whole 600-dpi pixels (the size
itself in whole pixels), so a TH SarabunPSK dot at 16 pt (2.59 pt) is drawn
every 2.64 pt and a Tahoma one at 22 pt every 6.6 pt. The dots sit on the
page's grid of that pitch, from the first place after the text before the
tab to the last one that ends before the text after it. WordCraft drew them
at the unrounded advance, half a dot after the text and 1.2 dots short of
the number, so a table of contents line had a dot or two more than Word's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Layout: each leader dot's place comes from its grid index
The leader's dots were placed by adding the pitch to the last place, so on
a long leader the rounding of each addition added up. From 72 to 528 pt at
2.64 pt it came to more than the 0.001 pt allowed at the end, and the last
dot, which ends exactly on 528 pt, was left out. `leader_xs` now works out
each place as its grid index times the pitch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Layout: name the leader slack and glyph limit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Layout: leader dots use the font size rounded to whole pixels
Word rounds the font size to the nearest 600-dpi pixel before it rounds the
dot's advance (Arial 14 pt: 116.7 px, so 117, dots every 3.96 pt); taking the
floor drew them 3.84 pt apart, as measured at 11, 14, 17, 20 and 26 pt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* leader_xs: saturate the glyph range end
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* #5036: paragraph side borders lie beyond their distance from the text
push_para centred the left and right rules on the distance, so a 3 pt border
at no distance reached 1.5 pt into the text and, drawn after the lines, covered
the first letter. In a heading whose border matches its shading the letter
looked cropped, on the page and in the ribbon's style tiles. All four lines now
lie outside the distance, as the top and bottom ones did. Top and bottom reach
the corners and the shading fills out to the outer edges, so no seam shows
where rules and shading meet.
* #5036: pin the right border on its own in the side borders test
The test only compared the right border with the end of the top rule, and both come from the same value, so a wrong right edge still passed. Check it against the line's own bound.
* docs: progress lines for #5088
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* T280: anchor header drawings below tall headers
* #2485: share rendered header boundary with pins and obstacles
* docs: layout-parity status line for #2485
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Keep with next asked for two lines of the paragraph after a kept heading under widow/orphan
control. A three-line paragraph cannot split at all there (two lines and a widow, or an orphan
and two), so it moved over whole and left the heading alone at the foot of the page. Word moves
the heading with it: ask for all three lines in that case.
Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* #3631: index laid-out objects during connector rerouting
* docs: keep the gaps.md note in the status line and revision table
* docs: drop a stray blank line
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Chart and diagram text was shaped in one face, the document's body font,
so Chinese legends and axis labels showed boxes while body text was fine.
Characters the face lacks now go to a fallback face, one face per run
when one covers it all, as in body text; a bold fallback face without a
bold style is emboldened.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Tables: share span minima by preferred widths (#1883)
* 1222: Match nested preferred span weights
* docs: word the #1883 layout-parity entries
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Shortcuts that give the same answers as the lookups they skip:
- is_rtl: nothing before U+0590 is right to left, so Latin, Greek and
Cyrillic text skips the bidi class search.
- strong_rtl and is_mark: ASCII letters are the only strong ASCII
characters, and no ASCII character is a mark.
- cluster_starts: in ASCII text every character starts a grapheme except LF
after CR, and no ASCII character joins a cluster.
- display lines: a glyph run's vectors and text are sized up front, and the
symbol-font check is made once per run instead of once per cluster.
- PDF glyph runs: the baseline (the y most glyphs share) is only worked
out for italic runs, the only ones that use it, and a run on one baseline
returns it without counting.
Tests check each shortcut against the full lookup (every code point for the
bidi classes, every ASCII pair for the clusters).
PDF export of the export_bench document, interleaved with main: median
293 ms -> 241 ms (-18 % with the previous commit). The PDF bytes are
identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A paragraph with keep with next measures the paragraph after it before it is
placed (next_first_line). That measurement called layout_para directly, so
every paragraph following a heading was laid out twice in each layout pass:
once to measure it and once, through the cache, when it was placed.
The measurement now goes through the layout cache under the key
para_labelled uses (Ctx::para_ahead) when its inputs are the same: no
proofing, no typed word, not inside a text box, no inline text boxes that
resize, the body's tracked-deletion view. Otherwise it is laid out on its
own as before. It counts nothing (list labels, equation numbers and the
page-total flag are restored), so pagination is unchanged.
Adds crates/engine/examples/export_bench.rs, which saves a long generated
document (the sample document 200 times, 259 pages) through io::save_bytes
and prints wall times:
cargo run --release -p wordcraft-engine --example export_bench -- pdf 200 9
PDF export on that document, interleaved with main (Ryzen, 16 threads):
median 286 ms -> 256 ms (-10.6 %). The PDF bytes are identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dpkruse's Genuine Steps Certificate: a bulleted text paragraph closes a
two-column continuous section, then an empty bulleted one closes another.
Word draws no bullet for the empty one; it is now quiet (takes no room) and
draws no label, still counted. A stacked mark that doesn't fit keeps its
line for the caret but draws nothing below the margin (CodeRabbit).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CodeRabbit (local): keep-lines compared its height with the room below the
mid-page section start, so a paragraph that fits on a new page split instead.
It now compares with the next column, or the next page's body when this is the
last column. A stacked mark whose page break before starts a page no longer
gets the previous page's bottom back.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
A continuous section moves the column top down below the text above. at_top()
then held at every new section start, so a paragraph that did not fit was
placed anyway, as on an empty page. A run of one-line continuous sections ran
off the bottom of page 1 (100 of them reached 2,480 pt down a 792 pt page).
The overflow checks for paragraphs now use fresh(): at the top with nothing
above on this page. at_top() still drives the spacing rules, unchanged.
Empty paragraphs that close a continuous section right after another
section's mark took no room. Measured in Word on synthetic files ("Start", N
such marks, "End"): 10 put "End" about 9 cm down page 1; 50, 100 and 200 all
put it on the first line of page 2, the same with 24 pt marks or no paragraph
spacing. So each takes its line while it fits, then stays at the page's
bottom. A mark right after text stays quiet, as measured before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
Noto Sans Thai and its siblings have no Latin letters or digits, so on a
machine whose only Thai fonts are Noto no face covers the test run and
fallback_for_run rightly returns None. The guard now asks whether the Thai
substitute covers the whole run, which doesn't go through fallback_for_run.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
- proof: pass PoisonError::into_inner instead of an identity closure (S1612).
- layout tests: replace glob super::* imports with explicit names (S2208).
- packaging/macos/import-cert.sh: use [[ for the status test (S7688).
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>