A single wrapping object moved by keep options or vertical justification had four passes to
settle before; one-per-object would have given it two.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found while testing crates/color/src/swatch.rs:94-101 (value_name)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Found while testing crates/textimport/src/tagged.rs:~190-205 (the ParaStyle and CharStyle branches of import: `unescape_value(value.trim())`)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Keeping a 1 x 1 table for every further `<Table>` was unbounded in the one
direction the budget exists to close. `<Table></Table>` is fifteen bytes, so
the 512 MiB a part may decompress to holds some 36 million of them, and a
`Cell` is 1,360 bytes: tens of gigabytes, with every table inside both caps.
The test I wrote said as much without my noticing -- its invariant was
`reserved <= MAX + tables`, and `tables` had no bound.
`budgeted_size` now returns `None` once the budget is spent and the table is
not read at all, so the cells one import reserves are at most the budget,
exactly. The invariant is now `reserved <= MAX_IMPORT_TABLE_CELLS`, with no
term that grows with the file.
The warnings needed the same treatment, for the same reason: a file of 400,000
tables each claiming one row and two columns got a line about every one of
them. At most 20 are named, then one line saying the rest were the same, and
the file is told once that its tables ran out of cells between them.
InDesign always writes a document-level StoryPreference in
Resources/Preferences.xml; DesignCraft only wrote one per story. IDML readers
that take the spread reading order from the document's preference, such as
Okapi's IDML filter, failed on DesignCraft's files without it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reserve column and spanning footnotes when fitting inline table row groups. Continue rows in the next column or frame when the remaining space is too small, and include cell-local footnotes in measured row height.
Fixes#1901
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Show file and message dialogs without blocking the frame
A blocking rfd dialog inside the egui frame runs AppKit's modal loop; a
file dragged from Finder and held over the window then re-enters winit's
handler and aborts the app. Dialogs now go through craft-dialogs: a
command without a path asks with a continuation and returns, the host
shows the dialog at the end of the frame, and the continuation runs with
the answer on a later frame, in the document it was asked for. Commands
given a path stay synchronous. The replace-file question is a message
box from the same queue. Quit and Close wait for a Save As dialog's
answer. clippy.toml disallows rfd::FileDialog and rfd::MessageDialog.
* Raise the open file panel when File > Open is chosen again
* Repin craft-dialogs to the craft-libs PR head
* Queue dialogs asked by different code or documents; hold answers behind newer dialogs
Two asks for the same file dialog were treated as one, so Place for one
document and then Choose Image, or Place again for another document, ran
the first ask's action. An ask is now skipped (and the open panel
raised) only when the same code asked for the same dialog for the same
document. An answer that would replace an egui dialog opened since the
asking (an unsaved-changes question, Preferences) waits until it closes;
Quit counts it as pending.
* Repin craft-dialogs to the craft-libs PR head
* Allow the file dialogs' existence checks in the file guard
* Repin craft-dialogs to storytold/craft-libs main
Found while testing crates/textimport/src/lib.rs:140 (plain) and crates/engine/src/cmd/place_text.rs:372 (place_text)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A PDF export recorded the second it was made in, so exporting one document
twice gave different files although the pages matched -- and the instance id
krilla derives from the bytes followed the timestamp, so nothing about the two
files was comparable.
SOURCE_DATE_EPOCH stands in for the clock when the export is given no date, as
the reproducible-builds specification defines it. A date passed in still wins,
and a value the PDF date format cannot hold -- malformed, negative, or past
the range -- is ignored rather than refusing the export or writing a date no
reader can parse. With the epoch pinned the two files are identical byte for
byte.
Rebased onto the post-split main: export_time and pinned_or live in
export/metadata.rs beside now_unix, which they stand in front of, and the call
site is in export/pipeline.rs. The ROADMAP keeps upstream's 2026-10-17 entry
with this one above it.
A size that was given but is not one a table can have was corrected in
silence: {"rows": 0} made a table with one row, -1 and 1.5 made the default
four, and 10000 was capped at 500 -- so a script asking for a shape the
command cannot honour got a different table and no indication of it.
A size that is absent still takes the default; a size that is present and
outside the range is refused, naming the key and the range.
Rebased onto the post-split main: `table.insert` moved to
table/structure.rs and the tests to table/sizing.rs. The helper is named
`given` rather than `count`, which that module already imports from
table/target.rs for the repeat count of a row or column insert.
A footnote reference clones its note and walks it again, and an XLSX shared
string is copied per cell that uses it, so one 1 MB footnote referenced 100k
times meant around 100 GB. The text is kept at the first reference; later ones
get the mark and an empty note, and the report says so. The spreadsheet reader
stops at 64 MB of text and 400,000 collected cells.
Carries both review fixes:
- a footnote id is recorded only once its text has actually been kept, so a
first reference arriving after the budget was spent no longer counts as
"done" and tell the next reference the text is at a reference that has none;
- the report and the note budget are both bounded. The duplicate warning names
the id, so 400 ids referenced twice made 400 warnings, each pushed after a
Vec::contains scan of all the earlier ones; warnings go through warn() now,
which dedups through a set and stops at 100 distinct messages. And the budget
counted only `text`, which holds a table's anchor and not its cells, so 18
notes of 600 KB of cell text retained 10,800,072 bytes against the 8 MB
budget; note_bytes() walks the tables and their nested tables.
Rebased onto the post-split main: the implementation stays in docx.rs and the
tests moved to docx/tests.rs, so the test additions went there.
Review pointed out that capping each table leaves a file free to hold
many capped tables: three at the per-table limit claim 750,000 cells,
about 1 GB of Cell, with every table inside its own limit. The package
reader's limits count input bytes, which say nothing about how many
cells a row and column count claim.
budgeted_size clamps each table against what the import has left, and
the cells are reserved before Table::new so a cell's own story cannot
spend the budget the table around it is counting on. The budget is
400,000 cells: a Cell carries a whole story and is 1,360 bytes, so that
is about the 512 MiB one part of the file may already decompress to,
and the same count the spreadsheet importer collects at most.
A table is never read as nothing. One row and one column are kept, so
the table stays in the document and still says where its text went, and
a file pays one cell for a table it really does contain -- which its own
size already bounds. The warning says which limit cut the table down.
The arithmetic is tested rather than the allocation: 400,000 cells is
544 MB and three such tables are not a thing to ask CI to allocate. The
sequence test runs 32 claimed sizes, including usize::MAX, and holds the
invariant across all of them.
`Table::new` allocates `rows * cols` cells up front, and the counts reach it
from IDML and DOCX, where they are whatever the file claims. Measured here:
`size_of::<Cell>()` is 1,360 bytes, so the reported IDML case -- one `<Table>`
with 5,000 `<Row/>` and 5,000 `<Column/>`, about 1 KB zipped -- asks for 25M
cells, some 34 GB. That allocation does not fail politely: it aborts the
process through `handle_alloc_error`, which no `catch_unwind` can catch, so
every open document's unsaved work goes with it.
A table is clamped to InDesign's own limits, 10,000 rows and 1,000 columns,
and to 250,000 cells whatever its shape -- the row and column limits alone
would still allow 13.6 GB. Columns are clamped first and rows take what is
left, because a table is far more often long than wide.
The IDML importer walks the clamped slices and reports the difference.
Indexing its rows and columns by the file's own counts would be out of
bounds, which is how a 34 GB abort would otherwise have become a panic.
Rebased onto the post-split main: the constants and `clamped_size` live in
`doc`'s `table` module root, `Table::new` in `table/grid.rs`, and the IDML
table reader in `import/table.rs`.
Review caught a wrong assertion in my own test. `\\?\C:\x` is the
extended-length spelling of `C:\x`: craft-docpath parses it as
Root::VerbatimDrive, is_absolute, and its own suite classifies it
PathKind::Local. The test asserted it was refused, so it would have
failed on Windows -- the one platform it runs on.
A data file can already name C:\secret\x.png, so refusing the long
spelling would have shut out long paths without shutting anything out.
It is now asserted the other way, and the refusal list keeps a device
path in its place.
Each shape was checked against the pinned craft-docpath through
resolve(), which recognises Windows forms on every platform, so the
verdicts are first-hand and not read off the source: Network for the
extended UNC prefix, Device for \\.\pipe, GLOBALROOT, CON and NUL.png,
and Ok for \\?\C:\secret\x.png.