The file's office:forms keeps its own head and each form:form with what
WordCraft doesn't model; controls are merged with the file's own. An
index whose generated entries nobody edited is written as read, with
its source settings and entry templates; an edited one is written from
the model. The no-edit gate saves nine samples of our own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
More of what WordCraft doesn't change is written back as it was read:
tables; the document properties it doesn't model (template, statistics,
editing time, printing); the declarations opening the text and the
document's scripts; page layouts and master pages while the page setup
is unchanged, each section starting with the master page it started
with; named sections around unedited paragraphs. Package files and
folders kept markup names (a style's fill picture, a header's embedded
object) are written with it, with their manifest entries.
Files are saved as ODF 1.3; markup kept from an older file is brought to
1.3's meaning (a gradient's or hatch's unitless angle was tenths of a
degree). WordCraft's own drawings state style:flow-with-text, which the
standard leaves open.
odt::tests_more::lossless saves seven samples of our own without edits
and fails on anything lost (nothing is allowed), and checks that edits
keep the saved parts well-formed.
Over the 133-file corpus the round-trip difference falls from 191k to
51k pixels; 95 files come back pixel-identical (62 before).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
A paragraph nothing changed since it was read (by a fingerprint of how it
was modelled) is written with its own markup, unknown elements and
attributes included, together with the automatic styles, common styles,
font faces, namespaces and package files it names. While no style or list
definition changed, styles.xml's office:styles is the file's own (unused
list styles, note and line numbering configuration, attributes WordCraft
doesn't read); drawing definitions and the default graphic style stay
WordCraft's, as drawings are written from the model.
Paragraphs holding drawings, comments, tracked changes or ranged marks,
or starting a page, are written from the model, as is everything edited.
Kept markup lives in memory only (wordcraft_doc::kept, 64 MB at most) and
is never part of the document's content.
Over the 133-file corpus the round-trip difference in LibreOffice falls
from 528k to 191k pixels; 62 files come back pixel-identical (46 before).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
Widow and orphan control a file doesn't state is open in the standard
(it refers to XSL, whose initial value is 2); LibreOffice reads it as
off. It is now a profile case (widowControl), and WordCraft writes it on
its default style either way.
LibreOffice measures tab stops from the paragraph's indent when its
TabsRelativeToIndent setting is on (its default), default stops too
(measured). WordCraft's layout now counts default stops from the indent
for such documents, and writes them back measuring from the indent
instead of from the margin, which moved LibreOffice's default stops.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
User fields (text:user-field-decl, text:user-field-get: LibreOffice's
report templates, 58 documents of the corpus) were read as custom
document properties and written back as meta:user-defined properties
with text:user-defined fields: the look and values stayed, the kind
didn't. Custom properties now remember being user fields
(CustomProp::odf_user_field) and are written back as
text:user-field-decls with their values and text:user-field-get fields.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
Found by comparing WordCraft's own documents in LibreOffice and
bisecting settings.xml (observed only, black-box):
- The settings set's name, ooo:configuration-settings, is a qualified
name: without xmlns:ooo declared LibreOffice ignored every setting
WordCraft wrote (its Word layout rules, tab measuring).
- A set LibreOffice reads must be complete: a setting missing from it
gets its old pre-OpenOffice value, which changes the layout. WordCraft
now writes LibreOffice's current layout defaults (as a new LibreOffice
document has them), overlaid with Word's layout rules for documents
WordCraft lays out as Word does (not for ones opened from ODF), and
its own choices.
- How the space between paragraphs adds up follows a file's
AddParaTableSpacing (false: the larger, as Word); unstated, the ODF
profile's new sumParagraphSpacing (WordCraft: the larger, LibreOffice:
the sum). WordCraft writes it as it lays the document out.
WordCraft-origin documents in LibreOffice: 142k -> 80k differing pixels
over 42 documents; the LibreOffice corpus: no regressions, 40k -> 39.6k.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
LibreOffice's elegant agenda puts a small gradient picture behind cells
(§17.3 style:background-image in a table-cell style). Cells can now
carry a background picture (CellProps::picture: media, own size,
repeat none/tile/stretch, alignment), which layout draws over the
cell's shading and under its text, clipped to the cell (Placed::Clip of
Placed::Image; tagged as an artifact in PDF). The ODT reader reads it
(style:repeat, style:position, the picture from the package) and the
writer writes it back with the picture in the package (once per
picture).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
Found by saving WordCraft's own documents as ODT and opening them in
LibreOffice (42 documents: templates, themes, one per feature).
- Table styles: ODF has no table styles of Word's kind, so each cell now
states what the style gives it (fills, its borders, bold header text,
its paragraph spacing), under the paragraph's own styles as layout
applies them (ECMA-376 §17.7.2; StyleSheet::table_para_patch and
table_char_patch). The style's border mapping moved to
styles::part_borders, shared with layout.
- Tables read from ODT no longer get Word's Table Grid style, whose
single spacing changed their text.
- Columned sections say whether their columns end balanced
(text:dont-balance-text-columns, §20.428, read as its name says; its
value descriptions contradict it). Unstated, the ODF profile decides
(balanceSectionColumns: LibreOffice balances, WordCraft as Word).
SectionProps::balance_columns carries it to layout.
- SmartArt diagrams are saved as a draw:g of their shapes and text (with
an invisible frame keeping the graphic's box), which LibreOffice draws
as WordCraft does.
LibreOffice round-trip corpus: 40k differing pixels over 125 documents
(was 76k), 42 identical; WordCraft-origin documents 277k -> 145k.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
ODF 1.3 §20.206–20.209 define page margins by referring to XSL's
(§7.10), without saying whether XSL's initial value 0 comes with the
reference; LibreOffice uses 2 cm. The margin a page layout leaves out
is now the profile's `pageMargin`: WordCraft 0 (XSL's), LibreOffice
2 cm, or custom (File › Options › Advanced › OpenDocument files,
`file.options` `odf.pageMargin`). A stated margin is the file's under
every preset. With the LibreOffice preset, LibreOffice's CV, sans-serif
business letter and one-page résumé templates match LibreOffice again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
- File › Options › Advanced › OpenDocument files: the preset is a
drop-down like Measurement units and Region; shape line width is in
points (a 0.25 pt hairline showed as 0.00").
- WordCraft's preset keeps a header's or footer's stated spacing when
its style doesn't say whether it is dynamic: with no height stated,
growing into it always dropped the spacing the file states. Custom
profiles can still grow into it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
What ODF 1.3 defines, including XSL's initial values for fo: properties,
is followed whatever the settings: unstated page margins are now 0, and
padding without a border takes room in every ODT document. What the
standard leaves to the application (Part 3 §16.2) is an OdfProfile:
WordCraft's own choices by default, LibreOffice's, or custom values.
The profile fills in only what a file leaves unsaid; stated values win
under every preset (tests_more/profile.rs).
Cases: header height, how its height and spacing combine, dynamic
spacing, tabs from the indent, column gap, shape fill, line and
hairline, page frames against a paragraph, in-line frames against a
paragraph or page, and lines of only pictures.
- doc: odf_profile module; Settings::odf (OdfLayout) replaces odf_layout
- formats: import_with / import_flat_with take a profile
- engine: Prefs::odf, file.options `odf`, opened files follow it
- ui: File > Options > Advanced > OpenDocument files (presets, custom cases)
- cli: WORDCRAFT_ODF=wordcraft|libreoffice
- docs: odf-interpretation.md lists every case (law, open, writer
choices, Google Docs and ONLYOFFICE findings); parity docs, gaps,
ROADMAP updated
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
Form controls:
- Placement: a control hanging from the baseline (as LibreOffice places
those it makes) was saved with no placement. LibreOffice then puts it
at the line's top, so every control row shrank. Its raise is now kept
(`ContentControl::form_raise`, read as in-line frames are) and
written, standing on the baseline when it had none.
- Multi-line fields: these were saved without LibreOffice's `MultiLine`
property, so they showed one centred line. They now carry it.
- Text fields get LibreOffice's flat border.
- Also: `pt()` no longer writes "-0pt".
Custom shapes:
- A shape drawn from its own geometry was saved as WordCraft's path of
it, and a preset WordCraft draws as a rectangle (a cloud) was saved as
a rectangle. The geometry as read (type, path, equations, handles) is
now kept (`ShapeExtra::odf_geometry`) and written back, mirrored as
the shape now is.
- `shape.change` and the ink-to-shape conversion drop the kept geometry,
since the shape is then another kind. `ShapeExtra` holds it as an
`Arc<String>` to keep `Placed::Shape` small.
- The reader's `end` hands skipped elements to `end_skipped`.
Results:
- The forms document round-trips pixel-identical (2,949 pixels before),
with its text the same.
- The shape presets fall from 2,467 to 1,220. The rest is LibreOffice's
hairline outline, which WordCraft keeps as 0.5 pt.
- Across the corpus the round-trip difference falls from 45k to 41k
pixels. 42 of 125 documents round-trip pixel-identical, 92 with their
text the same, and none gets worse.
gaps.md row 19 and the ROADMAP log are updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
Found with LibreOffice's report templates and one-page résumé.
Padding without a border:
- LibreOffice gives a paragraph's padding room on every side, with or
without a border; measured, 0.5 in of padding and no border moves the
text 0.5 in in and down. WordCraft dropped it.
- The reader now keeps it (an invisible border holding the space; a new
`Borders::takes_room()`), and the writer writes it back.
- The layout adds it to the spacing or indent under the document
setting, now renamed `Settings::odf_layout` to cover all of
LibreOffice's layout rules. `ResolvedPara::padding` carries it, so
Word documents are unchanged.
Columns:
- Unequal columns (`style:column` relative widths and indents, on pages
and sections) were made equal. They now keep their widths and are
written back as shares. The writer leaves out `fo:column-gap` beside
them, because LibreOffice ignores a page's column widths when a gap is
stated (checked).
- Columns with no stated gap have none, where they had Word's 0.5 in.
- A paragraph starting a column (`fo:break-before="column"`) now starts
with Word's column break, written back on the paragraph.
The report templates round-trip to 0-11 differing pixels (from up to
9,600), and the résumé to 24 (from 6,286) with its text the same.
Across the corpus the round-trip difference falls from 155k to 82k
pixels. WordCraft's own rendering difference falls from 469k to 428k,
and no document gets worse.
gaps.md row 19 and the ROADMAP log are updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
LibreOffice sizes a line holding only in-line pictures by the pictures
alone. Its font's ascent and descent don't count: measured in
LibreOffice, a 3.6 pt rule's line is 3.6 pt, where WordCraft (following
Word) made it 15.5 pt.
- A new document setting, `Settings::object_lines_fit_objects`, makes
the layout size such lines by the pictures alone. The ODT reader turns
it on, so other documents keep Word's layout. An equation or any text
on the line keeps the font's height.
- A document with no page layout at all (no styles.xml) opened on Word's
Letter page with 1 inch margins. It now uses the region's paper with
LibreOffice's 2 cm margins.
WordCraft's own rendering of LibreOffice's elegant agenda comes close to
LibreOffice's: its pixel difference halves, from 17,428 to about 8,950.
Across the corpus WordCraft's rendering difference falls from 487k to
469k. No document gets worse, and round trips are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
Found with LibreOffice's report templates.
Tables:
- A table filling the margins (`table:align="margins"`) but wider than
them ran off the page. It is now fitted to the text width, its columns
in proportion. Checked in LibreOffice, which fits such a table with or
without relative column widths; the old test's claim otherwise is
corrected.
- A table's own space above and below (`fo:margin-top` and
`fo:margin-bottom` on the table) was dropped. It is now read into new
`TableProps::space_before`/`space_after`, laid out (added to the
spacing of the paragraphs around it, as LibreOffice does) and written
back.
Headers and footers:
- A header taller than its band lost its spacing from the body, moving
the text up. LibreOffice keeps the spacing below the content unless it
is dynamic (`style:dynamic-spacing`, which behaves as Word does). New
`SectionProps::header_gap`/`footer_gap` hold it. The layout keeps it
past a tall header or footer, and the writer writes it back. Word keeps
no gap, so DOCX documents are unchanged.
Across the 125-document corpus, the round-trip pixel difference falls
from 241k to 185k, and WordCraft's own rendering's difference from
LibreOffice's falls from 562k to 484k. No document changes page count or
gets worse; one report template falls from 7,646 to 602 pixels.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
Word's Font › Emphasis mark (a mark over or under each character, used
to stress East Asian text) wasn't modelled. DOCX `w:em` and ODT
`style:text-emphasize` were dropped.
- Model: `CharProps::emphasis` (dot, comma, circle, dot below, or none
to turn off a style's).
- Layout: a mark is drawn over (or under) each character, and the line
is made taller to fit it.
- `format.emphasisMark {"mark": …}` sets it, and Format state reports
it. The catalog lists it under Home › Font.
- DOCX: `w:em` is read, and written in its place in `w:rPr` (after
`w:cs`, before `w:lang`).
- ODT: `style:text-emphasize` maps to it: an accent is the comma, a
disc the dot, a dot below the under-dot.
Also fixes two problems from the previous commit (ODT variables):
- Field updates blanked a SET field's stored result. The layout
already draws nothing for SET, so its result stays as it was, as
`ask_and_set_fields_draw_nothing` pins.
- A clippy lint (manual char comparison) failed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
On save, form controls were sized to their text. Buttons and other
controls WordCraft has no kind for were dropped.
- Sizes: each control keeps the size of its `draw:control`
(`svg:width`, `svg:height`), stored on the content control.
- Buttons, image buttons, labels, file and value-range controls, frames
and generic controls now show their label, locked, as a content
control. Their form XML is kept verbatim and written back as it was,
so LibreOffice's button round-trips as a button.
- A kept control that names a namespace WordCraft doesn't declare is
left out. So is one longer than 64 KiB.
- The `ooo`, `oooc`, `script` and `dom` prefixes that LibreOffice's
controls use are declared, so the content validates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
LibreOffice writes an embedded object's replacement picture in its own
metafile format, which WordCraft can't decode. So an embedded
spreadsheet (`draw:object`, ODF 1.3 §10.4.6) or OLE object
(`draw:object-ole`, §10.4.7) vanished on open, and saving lost it.
Such objects are now kept whole: their files, media type (from the
manifest or the sub-document's body) and replacement picture.
- Display: the replacement picture when WordCraft can show it, else a
placeholder of the object's size drawn in code (ruled for a
spreadsheet).
- ODT save: written back as the same object, with its manifest entries
and original replacement. LibreOffice renders a round-tripped
spreadsheet pixel-identically.
- Document model: the files live in `passthrough` under `odf-object/`,
linked from the picture's `ole` by a marker. The DOCX writer skips
the marker and writes the picture alone.
Flat ODT, a fix to the chart commit: inline sub-documents lacked the
required `office:mimetype`, so the schema rejected flat files holding
charts. Sub-documents now go inline as full `office:document`s (media
type, styles, settings) and are split back into their files on
opening. Replacement pictures and OLE files go inline as base64. A
picture's data is matched to the element that holds it, no longer to
the last `draw:image` before it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
The renderers draw a line from its frame's top left to its bottom right
(DrawingML's `line`; flips turn the frame), but the arrowhead code and
`end_direction` assumed a line from the bottom left to the top right, so
a sloped line's arrowheads were drawn at the other diagonal's corners,
apart from the line. Horizontal and vertical lines hid it. The
arrowheads now start from the line's own ends, pointing along it.
Regression test: `tests::a_lines_arrowheads_sit_at_its_ends`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
A pStyle id the style sheet doesn't define gave an empty style chain, so the
paragraph got the document defaults alone. Word formats it as Normal: a Pandoc
footer naming an undefined Footer style was taller than Word's and pushed the
last paragraph of the page over.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
count_words split only on whitespace, so unspaced Chinese or Japanese
text counted as one word per run. Word counts each Han ideograph and
kana as a word; Hangul (spaced) still counts by tokens. Fixes the status
bar, the Word Count dialog and NUMWORDS, which all use count_words.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* #4848: corporate authors in bibliographies
A Word source whose author is an organisation (Create Source › Corporate Author)
stores it as `b:Corporate`, which the reader ignored: the source opened with no
author, citations fell back to the title and bibliography entries started with
the year. A bare name typed into Author was split into "Organization, W. H.".
- Source gains `corporate`, read from and written as `b:Corporate` (in place of a
name list: a role holds one or the other, ECMA-376 CT_NameOrCorporateType).
- Citations and bibliography entries write it as it is in every style; it gives
the source's tag and its place in an author-sorted list, and untagged sources
are matched by it.
- APA puts its full stop after a group author ("World Health Organization.
(2021).") instead of relying on the initials for it.
- Create Source has a Corporate Author box under Author; Edit Source opens with
it checked when the source has one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6DxS4tKmWSLCiDPvb4G
* Match untagged sources by corporate author before personal author
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M12: preserve custom-property bookmark links (#4767)
* M12: preserve bookmark links across property renames (#4767)
* M12: align custom-property preservation status dates (#4767)
* docs: keep the gaps.md note in the status line and revision table
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* 4757: keep Arabic base letters when removing marks
* docs: keep the gaps.md note in the status line and revision table
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Fix#4750: walk note references and formatting runs together
* docs: keep the gaps.md note in the status line and revision table
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Count table opens in a single linear pass instead of rescanning
earlier events for every lone table-open marker (O(events) instead of
O(lone * events)).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* #1744: WIP
* #1744: Select Table copies and cuts the whole table
Copy of a whole-table selection used to keep only the cells' paragraphs
(copy_blocks flattened a cross-cell range), and Cut only cleared the text.
Now Copy puts the table on the clipboard and Cut removes the table.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* #1744: selected table rows and cells copy as a table; Cut clears their text
Copy of whole rows, cells of a row or a block of cells puts a table of just
those cells on the clipboard (Table::slice). Cut of rows or cells clears their
text and keeps the rows and cells, as in Word (Word check A).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* #1744: review fixes
Table::slice widens every row to a merged cell partly selected (no ragged
copy); an empty selected table or cell copies and cuts; a text selection over
exactly a nested table copies it; one selected_table per Cut; tests for
slice merges/widths/controls, tracked and locked cuts, and the text route.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* #1744: Cut clears every cell Copy takes; Spike stays off for empty cell selections
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* #1744: a copied part of a percentage-width table keeps a point width
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* ROADMAP: tidy merged header
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* ROADMAP: keep only the #1744 rows over main's version
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* ROADMAP: remove conflict markers; keep only the #1744 rows over main's version
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* #1744: rename the range Cut helper clear_range_cells (main added clear_cells)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Remove conflict markers from ROADMAP.md; leave main's header line alone
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* cargo fmt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Import Path in table structure after merging main
---------
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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>
Wall-clock bounds of 2 s failed on loaded runners. Each bound is now 10 s, at least 10x the
fixed code's time and well under the old code's on the same input (measured by running the
pre-fix code under the same load):
- many distinct media: 0.5 s now; the old scan of every image took 131 s.
- object walks: under 0.3 s now; the old walks took 360 s (#3614).
- delete across lone control markers: 3,000 markers (was 1,500); 1 ms now, the old delete
took 18 s at 1,500 and 114 s at 3,000.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
- A text box whose story doesn't fit what is left of the paste's byte
budget gets no story, but later boxes with smaller stories are still
copied while the budget lasts (it used to zero the budget). Each
source story is costed once per paste, so skipping a big story many
times doesn't walk it again. The paste logs one warning with the
number of boxes left empty. wordcraft-doc now depends on log.
- Document::media_index is a private field and MediaIndex a private
type: a lookup cache, not API. The one struct-update literal outside
the crate (a formats test) builds the document field by field.
- kept_control_markers documents that delete_range relies on its
sorted, deduplicated output; delete_range sorts it if it ever isn't,
and control_markers_kept_are_sorted pins it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
Finding 80 (CWE-400): adopting a fragment deep-copied a box's source story
once per text box showing it, including boxes inside the story being copied,
so 4096 boxes (the story budget) pointing at one large story copied it 4096
times. Now: a box showing a story it is inside gets no story (a cycle);
blocks without text boxes are shared with the source (copy-on-write Arcs)
instead of copied; and a paste adopts at most MAX_PASTED_BOX_BYTES (32 MiB,
estimated text + runs + objects) of box content, charged before copying,
after which later boxes keep no story, as when the story budget runs out.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 71 (CWE-407): loops of object_offsets + object_at recounted the
objects before every offset, so walking a paragraph's objects was quadratic
(40k markers: 360 s for the regression test before, 0.2 s after).
Paragraph::objects_with_offsets yields (offset, object) in one pass, and
every such walk in crates/doc uses it: the field scan (field_ranges, run on
every docx load), control events, bookmarks, perm ranges, text_box_anchor,
past_field_ends, typing_pos and the markers a range delete keeps. Also:
perm_ranges keeps open ranges per id instead of searching a list for each
end; kept_control_markers pairs each container with its table wraps once
(was once per marker) and probes a set instead of every range.
Part of #3614: the engine, docx reader and UI callers it lists live outside
crates/doc and still use object_at in loops; they can move to
objects_with_offsets separately.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 48 (CWE-407): P::word scanned and copied the rest of an alphabetic run
for every letter that didn't start a function name, so a 20,000-letter
equation did ~200 million steps outside the parser's step budget (5 parses:
8.3 s before, well under 0.1 s after). The scan now stops one past the
longest name in FUNCTION_NAMES (computed at compile time); a longer run is
never a name, as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Finding 45 (CWE-407): Document::add_media compared each new image with every
stored one, so an import with many distinct small images did quadratic work
(40k images: 213 s before, 0.26 s after). A MediaIndex cache (not saved,
ignored by ==) maps bytes to their first key, is rebuilt when media changed
size behind its back, and every hit is checked against media. Generated
image{n} names also skip taken names once instead of on every add.
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>