Word's TOC over entries in columns keeps its TOC Heading title in a one-column
section above a blank paragraph that ends it; Update Table moves the field into
that blank paragraph. Replace (references.toc with settings) put the new title
under the break, inside the two-column section, and Remove kept the blank
paragraph and its break.
Observed black-box in Word for Mac 16.114.3 on a synthetic document: Insert
Table of Contents keeps the title, the blank paragraph and the break and only
changes the field; Remove Table of Contents takes the title, the blank
paragraph and its break with the entries.
Replace now keeps the title above the break (renamed when a title is given)
and rebuilds the field and entries in place; Remove takes the title and the
blank section-ending paragraph with the table. A paragraph of another style
above the break stays.
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>
* #5290: Page Up/Down move by a page in Outline, Draft and Web Layout
These views lay the document out as one long page, so caret.pageUp/pageDown looked for a previous or next page, found none and left the caret where it was. Move by the text area height of the caret's section instead, the same distance Print Layout moves.
* #5290: Page Up/Down pick the line past the caret, and page from a hidden outline selection end
Choose the destination among the lines past the caret by height first (DocLayout::line_toward), so narrow right-indented paragraphs can't keep the caret on its own line when the step is short. The above/below test is shared with vertical().
In Outline view, a selection that ends in text a collapsed heading hides pages from where the outline shows it (outline::shown_pos, pulled out of after_command), keeping the anchor. Avoid f32::clamp, which panics on a NaN page height.
Word brings a document's alternative format chunks (w:altChunk) in where they
stand when it opens the file; WordCraft left them out, so a document made of
one (as generators and mail-merge tools write them) opened empty.
- docx: read_with/ReadOptions with a ChunkImporter for formats other than
WordprocessingML; nested Word documents are read here (at most 4 deep,
256 chunks and 512 MB of chunk bytes a file, XML depth counted on).
- doc: Document::adopt (moved from paste) brings another document's media,
lists, styles, notes, comments and revisions along under new ids; pictures
in groups and charts are relinked. Insert > Text from File uses it and now
keeps the file's pictures and lists.
- engine: chunks in MHT, HTML (in their charset), RTF and plain text go
through the format importers.
- formats: MHT web archives open (MIME, quoted-printable/base64, charsets,
pictures by Content-Location or cid). HTML reads style element rules
(simple selectors, cascade, budgets), paragraph margins, indents, line
height, cell widths and borders; an opened page takes Word's web-page
defaults (Times New Roman 12 pt, single lines, black headings, automatic
spacing). html::decode knows the single-byte Windows code pages.
docxide-pdf suite: croatian_regulations_altchunk goes from one empty page to
Word's 8 pages, SSIM 2.7% -> 78.7%; no other document changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CodeRabbit on #5202:
- AGENTS.md, ATTRIBUTION.md, README.md, docs/architecture.md: without
CRAFT_I18N_DIR English spelling falls back to an installed Hunspell English
or craft-proof's built-in Moby list, and only pattern hyphenation is off
(Bulgarian, Turkish and Russian hyphenate by rule); they said English
wasn't spell-checked. (CLAUDE.md carries the same sentence; left for the
maintainer, as agents don't edit it.)
- NOTICE: craft-i18n's patterns are language-data/<lang>/hyphenation.pat,
not en-us.pat, pl.pat, pt.pat, de.pat, it.pat.
- docs/spelling-dictionaries.md: the Flatpak's dictionaries folder is
~/.var/app/ai.storyteller.wordcraft/data/wordcraft/dictionaries; the host's
folders are outside its sandbox.
- printing.yml: the repository checkout doesn't persist credentials.
- tests.rs no_language_data: also probes the American/British variant table,
so a craft-i18n checkout missing some files skips the dialect tests
instead of failing them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
Polish/Portuguese/Czech review commands, the custom dictionary file and the two typing squiggle
tests pass without CRAFT_I18N_DIR at 48ed6f8. English dialects, regions' English and Hyphenation
Options still need the variant table and patterns, so they keep the skip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
- wordcraft-proof is now `package = "craft-proof"` from craft-libs (local
path for now; TODO in Cargo.toml for the git+rev pin after the craft-libs
PR merges). `wordcraft_proof::` paths are unchanged.
- crates/proof and assets/{spelling,hyphenation} deleted with their
ATTRIBUTION.md rows; one row now points at craft-i18n's Language data;
NOTICE paths updated.
- craft-i18n.rev (and the flatpak source) moved to the craft-i18n commit that
adds language-data/ (CI sets CRAFT_I18N_REQUIRED=1, so the pin must have it).
- xtask layers: proof removed. Engine tests carry their own copy of the
synthetic Czech fixture (they reached into crates/proof/src).
- Docs: architecture.md, CLAUDE.md/AGENTS.md layer table, README build note
(CRAFT_I18N_DIR), localization-tr path, ROADMAP progress log.
Before/after wordcraft-cli on a 17-language proofing document: issues,
suggestions, manual hyphenation, PDF text and PDF bytes identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
A hyperlinked TOC wrote each entry's page number as plain text inside the
link. Word writes it as a PAGEREF _Toc… \h field, so Update Field in Word
moves the number with its heading; with plain text the numbers stay what
they were when the file was saved.
Entries that link to a bookmark now end with that field (result: the page
number). Entries without a link and \n levels are unchanged. The DOCX span
test looks for the TOC's own end, the last one, since PAGEREF fields now end
inside it.
Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* fix(engine): saturate page parameters on 32-bit and wasm (#3676)
The file.exportPng and edit.goto paths cast a u64 page to usize and subtracted one. On 32-bit and wasm, page 2^32 truncated to 0 and the subtraction wrapped to page 1 (a debug panic).
Add p::page_index, which saturates instead of wrapping, and use it in both engine paths. The MCP render path already has the same guard on main; the new tests also pin that behavior.
Regression tests cover the helper, Go To, PNG export and the MCP render path.
* fix(engine): guard caret.set page cast too (#3676)
caret.set takes a 0-based page; pg as usize truncated on 32-bit and wasm and could select page 0. Use the same checked conversion and add a regression test.
---------
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>
Row, column and cell settings applied to the caret's row, column and cell only, so
selecting the whole table and unticking "Allow row to break across pages" changed
one row and the others still split across pages.
- table.properties applies row height and rule, allow break and header row to every
selected row, the column width to every selected column, and the cell width and
vertical alignment to every selected cell (the caret's with nothing selected), in
one undo step.
- The dialog shows "Allow row to break" and "Repeat as header row" as mixed when the
selected rows differ; left alone they change nothing, clicked they set every row.
- The row-height rule box is named by "Specify height" for screen readers.
Claude-Session: https://claude.ai/code/session_01LvG6DxS4tKmWSLCiDPvb4G
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M2 #4903: step each selected run from its own font size
* 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>
* #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>
The shared dictionary slot reports no file while a dictionary given
with install_dictionary is in use, so this test had accepted null
alongside the folder's file, for installs from tests running at the
same time. That also let it pass if file.options stopped reporting the
folder's file at all. The tests' install helpers and this test now take
turns on one lock, and each language must report the folder's file
again. An install from before the test doesn't matter: changing the
folder reloads every slot.
main split Czech's suggest_in into one helper per round for the
cognitive_complexity limit (#4501). On this branch that code is the
shared affix_suggest::suggest_in (36 of 30), so cs/suggest.rs stays the
thin wrapper and the shared function gets the same split. The candidates
are offered in the same order; a dump of 13,081 calls over every
language's test dictionary matches the old engine except one: the typo
round counts the lower-cased letters, so "İ" (two letters lower case)
now gets "I" in Polish, where the old count stopped one letter short.
* #3680: Read Aloud tidies in one linear pass and cuts space-free sentences
Finding 56. tidy() repeated whole-string replacements until nothing
changed; each pass removed one level of nested empty brackets, so 40,000
pairs took ~50 s in a debug build. It is now one pass that drops a closing
bracket's empty pair as it goes, checked against the old algorithm on
20,000 generated strings. Sentences with no space to split at are cut at
MAX_UTTERANCE (1,200) characters, and each sentence is sliced from a char
vector instead of skipping from the paragraph's start.
Fixes#3680
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* #3692: Paste Special reads only a regular picture file, bounded
Finding 24. A copied path naming a device such as /dev/zero reports a
length of 0, so the size check passed and std::fs::read read forever.
The path and the opened handle must both be regular files, and at most
the picture limit + 1 bytes are read. A named pipe swapped in between the
check and the open can still block the open (no portable non-blocking
open without platform flags); noted in the code.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* #3692: macOS signing import decodes the certificate into a private temp folder
Finding 25. The .p12 bundle was written to a predictable name in the
shared temp folder, where a planted link could redirect it. It now goes
into a mktemp -d folder under the same temp root, removed on exit. The
keychain is unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* #3666: engine file reads open once and check the handle; a FIFO is refused without blocking
read_file_capped checked the path's metadata and then read the path
again, so a file swapped or grown after the check was read whole;
read_file_head opened the path with no type check, so saving to a FIFO
.xml path (the Word 2003 sniff) blocked until a writer appeared. Both
now go through open_regular_file (path checked before opening, handle
checked after, opened once) and a bounded read; read_regular_file uses
the same pieces. Tests: both helpers refuse a FIFO without blocking
and a folder; read_file_capped refuses content over the cap.
Fixes#3666
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* #3692: Paste Special reads picture files through the engine's bounded read
Ultrareview finding 9: paste_picture kept a third copy of the regular-file
bounded read; it now calls wordcraft_engine::io::read_regular_file (public,
with a wasm stub).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* #3666: open a checked file non-blocking so a swap to a named pipe can't hang
CodeRabbit: a path replaced by a FIFO between the regular-file check and
File::open blocked in open, before the handle check could refuse it. On Unix the
open now uses O_NONBLOCK (no effect on regular files; libc is already in the
lockfile) and the handle check refuses the pipe. The FIFO tests' cleanup writers
open non-blocking too, so a regression fails the test instead of hanging it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* #3666: review — Paste Special refuses a picture file whose handle reports more than the cap before reading it
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
* #3692: review — macOS signing keychain lives in the private mktemp folder too; removed if the import fails
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
* #3666: review — libc comes from the workspace dependencies
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
* #3680: review — Read Aloud logs a warning when its sentence cap leaves text unread
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
* #3692: review — engine speech timing test gets a margin a busy CI machine can't break
tidy_is_linear_on_nested_brackets: 2 s -> 10 s. The one-pass tidy takes ~13 ms on a loaded
machine; tidy_by_replacing (the old tidy, kept in the tests) took 293 s on the same input.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* With Regular expressions on, Replace with reads $1…$n, ${n}, ${name}, $name,
$0/${0}, $$, \1…\9, \n (paragraph mark, splits the paragraph through the ^p
path), \t and \\, alongside the ^ codes. Named groups resolve through
meta::Regex::group_info(); missing, unknown and out-of-range groups expand to
nothing. Plain and wildcard Replace are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* #4177: review — special:false keeps regex group references
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* #4177: review — \$ and \^ in regex Replace keep the character as typed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A row content control that the cut crosses reopened below the
converted rows with its w:id, so the saved file had two controls with
the same id. Table::split_off (#4162) clears the id and ends the
vertical merges the same way, so Convert to Text and Split Table
follow one rule. Rows without cells leave an empty paragraph.
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.