Found while testing crates/textimport/src/tagged.rs:~190 (the `n.starts_with("0x")` branch of import: `u32::from_str_radix(&n[2..], 16)`)
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/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 page-based export leaves out the text its frames cannot fit, and said
nothing about it: an imported 58-page form lost whole sentences -- a
data-consent clause, a statutory rule, the last row of two tables -- with
`warnings` empty while preflight reported 19 overset stories.
The PDF and deck exports now report it, in the words preflight uses and with
the same count, naming the page and the frame. A reflowable export carries the
whole story, so it has nothing to report.
Carries the review fix: a spread export puts the whole spread on the sheet, so
choosing page 2 of a facing-pages document exports page 3 as well and page 3's
overset is as absent from the file as page 2's would be. The selection is
widened to the spreads it touches first. A page on another spread is still not
reported, so the filter keeps doing what it is for.
Rebased onto the post-split main: the PDF command is in export/pdf.rs, the
deck one in export/formats.rs, and preflight.rs is unchanged by the split. The
helper sits before export_pdf rather than between first_page_ref's doc comment
and its function, which is where the pre-split patch had put it.
- An option took the next token whatever it was, so `--export --scale` wrote a
file called `--scale` in the working directory. A token that cannot be a
value is refused, and where the value is a path the message offers the
shell's own way out (`./-name`). A negative number can still be a value, and
`-inf`/`-nan` still reach the check that reports them.
- An argument that is not UTF-8 panicked inside `std::env::args`. It is a
usage error now, named, since such a path cannot reach the engine anyway.
- `<sub> --help` was read as an unknown option by every parser but `script`'s.
It answers from the usage block, on stdout, and nothing else happens.
Rebased onto the post-split main, where the fix had to grow one part. Upstream
has since added a check that an export option sits before its `--export`, and
it consumed the following token whatever it was, so `--scale --export out.png`
was reported as the *order* of the options -- advice to move an option that is
already where it belongs -- when what is wrong is that `--scale` has no value.
That check now skips a value only where the next token can be one, and stands
aside entirely when a value is missing so the value error speaks. Its own test,
`run_export_options_come_before_their_export`, still passes.
The CLI's tests moved to tests/it/cli.rs, so the test additions went there.
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`.
My own previous commit introduced this: moving the bare "serif" needle
after the sans list to let "Microsoft Sans Serif" read as sans also let
"gothic" win in "Sweet Gothic Serif", a real serif family, which review
caught.
The rule is narrower than either ordering. A name holding "serif" is
serif, and that outranks the sans indicators; the only exception is the
phrase it ends: "sans serif" and "sans-serif" are the name of a sans
face. So the phrase is removed before the word is looked for, which
keeps both spellings sans and gives every explicitly serif name back.
Korean serif names stay ahead of all of it, so "Noto Sans Myungjo" and
"Myungjo Gothic" are unchanged.
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.
A story whose custom or above-line anchored objects wrap its own later
lines is composed first without those wraps, then again with each
object where the previous pass placed it. That loop stopped after a
fixed MAX_PASSES = 4 extra compositions. Each object's wrap pushes down
the anchors of the objects after it, so in a chain of such objects the
k-th object only lands where it belongs once the k - 1 wraps before it
are in place. From roughly the fifth chained object on, the loop ran
out before the story settled: the final composition placed the object
in one spot but wrapped the text around where an earlier pass had put
it, and lines printed straight over the object.
Bound the loop by the story's own wrapping objects instead: n + 1
passes for n such objects (inline objects never wrap their own story
and are left out). Composition 0 has no own wraps, so object 1 is
already where it belongs; by induction pass k places object k + 1
correctly, pass n places the last one, and pass n + 1 wraps the text
around it, after which the positions cannot move. The existing settled
check still stops a story early as soon as its objects stop moving,
so stories that settle sooner cost nothing extra. MAX_PASSES stays as
an absolute safety net, raised to 64, for layouts that keep moving
their objects (keep options, vertical justification) and for stories
with very many anchored objects.
Test: chained_anchored_objects_all_wrap_their_own_storys_lines anchors
eight 100 x 60 pt bounding-box objects six lines apart in one story,
each hanging from its anchor line, and checks that every object sits
on its anchor line and every line it hangs over moves clear of it.
Without this change it fails at the seventh object (a line 790 pt down
sits at x 0 inside the object's band at 718 pt); with it, it passes.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
Review pointed out that the CSS generic "sans-serif" matched the bare
"serif" needle first and classified as serif. The same ordering hit real
family names: "Microsoft Sans Serif", "MS Sans Serif" and "Lucida Sans
Serif" are sans faces that a Word import can name, and epub's own
generic_family already maps Microsoft Sans Serif to sans-serif.
So rather than special-casing the one hyphenated spelling, the bare
"serif" word now sorts after the sans needles. The Korean serif names
stay ahead of everything, which is what keeps "Noto Sans Myungjo" and
"Myungjo Gothic" serif; the test pins those two along with both "sans
serif" spellings.
Review of #1755 caught a real over-reach in my own patch. When a family
cannot be resolved, db.face hands back a bundled face, and I was reading
"Sans" out of its name: FALLBACK_FAMILY is "Source Sans 3", so every
unclassified missing font took the sans chain -- picking Malgun Gothic on
Windows over the Batang fallback that was already there. DEFAULT_FAMILY,
"Source Serif 4", would have forced the other way.
A stand-in face says nothing about what the document asked for, so
style_of_substitute returns None for both and only a face that really
names itself gets a vote. The test pins both families by their constants;
with the old body it fails with Some(true) against None.
Review follow-up: the spine runs the height of the taller page. Make the
narrower right page shorter too, so a line that stopped at the page
listed first would fail the test.
Review follow-up: bound right to left, a spread lists its right page
first, so averaging the first page's right edge with the second page's
left edge put the line halfway between the outer edges when the pages
differ in width. Order the two pages by position and use the edges that
meet. Test: a right-to-left spread whose right page is narrower.
`len` and `is_empty` were added for a test that ended up counting reads
through `io::touched` instead, so they were dead code -- and the gate runs
`cargo clippy --workspace --all-targets -- -D warnings`, which turns
`dead_code` into a failure. Hosted CI was red on this PR.
My local check had been `cargo clippy -p designcraft-engine --all-targets`
without `-D warnings`, which is why I missed it; re-ran it the way the gate
does.
Found by the review bot, confirmed against the CI log.
In a spread with two or more pages the paper is one rectangle and
nothing marked where the pages meet. Draw a hairline at each shared
page edge, the full height of the taller page, in the guides pass: it
shows on screen only, hides with the other guides and in the preview
modes, and exports never see it since the render crate draws no chrome.
Tests: a three-page facing document gets one spine between pages 2 and
3 and none at outer edges; the line goes with guides off, in Preview
and in Presentation.
The policy check now reads the compiled AppSearch and RegLocator rows for the
remembered-folder properties (registry root, key, value name and type for both
scopes) and runs the DIR, FOLDER, PATH and legacy exe actions in sequence order
against seeded values, so their precedence and the preset-folder guard cannot
regress. It also replays the wizard folder actions in sequence order (a
remembered folder beats the new-install default, a typed folder beats both) and
requires the machine restore action between the scope page and costing.
The pre-signing check matched any folder named Program Files, so it rejected
a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit
root for an x86 package and accepted a different app name. Build the expected
folder from the package architecture's resolved root and the policy name.