Commit Graph
30 Commits
Author SHA1 Message Date
34511263a7 Customize Ribbon and Quick Access Toolbar (#379) (#384)
* Customize Keyboard dialog (#368)

Tools > Customize Keyboard (tools.customizeKeyboard): pick a category and
a command, see its keys, press a combination to see which command uses it,
then Assign / Remove / Reset All. Also reachable from File > Options
(Keyboard shortcuts: Customize...) and command search.

- engine: KeyMap (assigned key -> command, removed built-in keys), consulted
  before the registry's built-in shortcuts; keys compared in one canonical
  form. Saved maps read leniently: junk entries and unknown commands are
  dropped, never failing the rest of the preferences.
- tools.customizeKeyboard: with params (assign/remove/reset/command/key) it
  only changes or reports the map; without params it opens the dialog.
- ui: key dispatch and menu/tooltip shortcut text use the user map; the map
  persists in ui.json; more keys (Q, F1-F12, 4/6/7, Insert) can be bound.
- i18n strings in every catalog; docs/ui-parity, gaps #24, ROADMAP log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Customize Ribbon and Quick Access Toolbar (#379)

Tools > Customize Ribbon (tools.customizeRibbon): one dialog with two
pages. Ribbon: commands by category next to the ribbon's tabs; show or
hide and reorder tabs, add custom tabs and custom groups (on built-in or
custom tabs) holding any commands, rename, remove, reset. Quick Access
Toolbar: add, remove and reorder its commands, reset.

- engine: RibbonLayout (tab order and visibility, custom groups, the
  toolbar's commands). Saved layouts read leniently: junk, duplicates,
  reserved names and unknown commands are dropped.
- tools.customizeRibbon: with params (hide/show, newTab, newGroup, add,
  remove, rename, move, qatAdd/qatRemove/qatMove, reset, layout) it only
  changes or reports the layout; without params it opens the dialog.
- ui: the tab strip and ribbon follow the layout (custom groups draw the
  commands' own icons, else a lettered tile); the Quick Access Toolbar
  shows the user's commands; right-click any ribbon button to add it to
  (or remove it from) the toolbar or open the dialog; the toolbar's ...
  button opens a menu of common commands and More Commands... (#376);
  File > Options has a second Customize button; layout persists in
  ui.json under `ribbon`.
- Open and Print icons drawn in code; i18n strings in every catalog;
  docs/ui-parity, gaps #24, ROADMAP log, parity checklist.

Fixes #376.

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>
2026-10-11 12:06:17 -04:00
9cf3b6862f Customize Keyboard dialog (#368) (#378)
Tools > Customize Keyboard (tools.customizeKeyboard): pick a category and
a command, see its keys, press a combination to see which command uses it,
then Assign / Remove / Reset All. Also reachable from File > Options
(Keyboard shortcuts: Customize...) and command search.

- engine: KeyMap (assigned key -> command, removed built-in keys), consulted
  before the registry's built-in shortcuts; keys compared in one canonical
  form. Saved maps read leniently: junk entries and unknown commands are
  dropped, never failing the rest of the preferences.
- tools.customizeKeyboard: with params (assign/remove/reset/command/key) it
  only changes or reports the map; without params it opens the dialog.
- ui: key dispatch and menu/tooltip shortcut text use the user map; the map
  persists in ui.json; more keys (Q, F1-F12, 4/6/7, Insert) can be bound.
- i18n strings in every catalog; docs/ui-parity, gaps #24, ROADMAP log.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 12:00:30 -04:00
adcbf52611 Move paragraphs, promote/demote, Go Back and Alt+X like Word (#343) (#351)
- para.moveUp / para.moveDown (Alt+Shift+Up/Down, Ctrl+Shift+Up/Down on macOS): the
  paragraphs the selection touches swap with their neighbour in the same story or cell,
  keeping style and list membership; selection follows; one undo step; refused at the
  container edges, next to a table and across a section break.
- para.promote / para.demote (Alt+Shift+Left/Right, Ctrl+Shift+Left/Right on macOS):
  heading level up/down (Heading 1..9), promoted body text takes the level of the heading
  before it, list items change level like Shift+Tab/Tab.
- edit.goBack (Shift+F5): cycles the caret through the last four edit places.
- text.toggleUnicode (Alt+X; unbound on macOS): hex code <-> character, undoable. The
  canvas no longer types the key's letter after an Alt shortcut ran.
- Catalog rows, translations in every interface catalog, typing/UI parity docs, tests.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 11:49:02 -04:00
39ab3b5433 Field codes like Word's field keys (#339) (#349)
Show field codes instead of results with View > Field Codes (Alt+F9) or
per field with Toggle Field Codes (Shift+F9); a field showing its code is
drawn as { CODE } on light grey. Edit a field's code in a small popup
(double-click a field showing its code, right-click > Edit Field..., or
fields.setCode), and Ctrl+F9 asks for a new field's code. F9 updates
fields and shows the selected ones' results again.

New commands: fields.unlink (Ctrl+Shift+F9) replaces fields with their
text, fields.lock / fields.unlock (Ctrl+F11 / Ctrl+Shift+F11) keep a
result through updates (already saved as w:fldLock), fields.next /
fields.previous (F11 / Shift+F11) select the next or previous field, and
fields.selected reports the fields in the selection. Range fields
(citations) can be locked, unlinked, edited and stepped through too.

The right-click menu gains Update Field, Edit Field... and Toggle Field
Codes when the selection holds a field. Strings are translated in every
catalog; parity catalog, ui-parity, gaps and roadmap updated.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 11:22:25 -04:00
Valentin KleinandClaude Opus 5.5 5e84270e60 Hyperlinks cover only their own text (#413) (#449)
Linking a whole paragraph also linked its paragraph mark, and Enter
copied the link at the split into the new paragraph's mark, so everything
typed in the paragraphs Enter made (above or below) became the link, and
hovering anywhere near the end of the text showed the link's tooltip.

- doc: a paragraph mark never takes a hyperlink (format_range, split_off)
- engine: typing at either end of a link isn't linked; inside it is (Word)
- ui: hover tooltip and Ctrl+click only on the linked text itself

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:50:48 -04:00
Valentin KleinandClaude Opus 5.5 972df1e0c7 Track Changes: formatting revisions (w:rPrChange/w:pPrChange) (#41) (#399)
Formatting changes are tracked like insertions and deletions:
- Model: runs and paragraph marks carry the formatting before a tracked
  change (CharProps::fmt_change), paragraphs their old properties
  (ParaProps::fmt_change) and a list numbering change; tables, rows,
  cells and sections their old properties.
- DOCX: w:rPrChange (runs and paragraph marks), w:pPrChange,
  w:numberingChange, w:tblPrChange, w:trPrChange, w:tcPrChange and
  w:sectPrChange are read and written back, each last in its parent
  (ECMA-376 17.13.5). Nested changes are ignored; ids are renumbered.
- Recording: with Track Changes and Track Formatting on, Session::run
  compares the paragraphs a command changed with the document before it,
  so every formatting command records without per-command code. An
  author's later changes keep the formatting before the first one;
  formatting set back to the original stops being a change.
- Accept/Reject (at the caret, selection, all) keep or restore the
  formatting; Next/Previous and the Reviewing Pane list format changes
  with a "Formatted: Bold, Size: 14" description.
- Markup: change bars beside formatted lines and "Formatted: ..." balloons
  in the markup area, per Track Changes Options.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:32:59 -04:00
e71e2d1c52 Draw tab: draw with pen, pencil and highlighter, erase ink, and hide ink (#315)
* Draw Table and Eraser: draw tables and split or merge cells with a pen (#303)

Insert › Tables › Draw Table and Table Layout › Draw › Draw Table / Eraser.
table.draw takes a pen stroke in page points: a rectangle outside a table
inserts a one-cell table of that size at the paragraph under its start; a
straight vertical or horizontal line inside a table splits the cells it
crosses (new grid column or row; untouched cells span it). table.eraser
merges the two cells beside the border nearest a point. Each stroke is one
undo step. In the app the buttons toggle pen and eraser modes with a live
preview, a crosshair with the tool's icon, and Esc to stop; canvas clicks
don't move the caret while a tool is on. Splitting through part of a
vertically merged cell and erasing beside one are refused with a clear
error. Catalog 384/430 -> 387/430.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Draw tab: pen, pencil and highlighter ink, stroke eraser and Hide Ink (#307)

Ink strokes are floating freeform shapes: a new Freeform geometry (paths in
shape-local coordinates, opacity, and the pen that drew it) on
InlineObject::Shape with ShapeKind::Freeform. draw.stroke inserts one
anchored to the paragraph under the first point (one undo step; points
capped per stroke, strokes capped per page, NaN dropped); draw.erase /
draw.eraser with a point deletes the stroke under it; draw.pen, draw.pencil,
draw.highlighter, draw.eraser and draw.select set the Draw tab's tool in the
session view state; review.hideInk toggles showing ink on screen.

The canvas draws a live stroke while a pen is dragged and commits it on
release; the eraser deletes strokes it passes over (one undo step per drag);
Esc or Select goes back to selecting. Draw tab buttons show the tool in use,
with colour and thickness menus; Review gets an Ink group with Hide Ink.
Ink renders with round caps and joins on screen and in PDF; highlighters are
translucent and behind the text. DOCX writes freeforms as wps:wsp with
a:custGeom (moveTo/lnTo) in a wp:anchor and reads them back, along with
simple custom-geometry freeforms from other files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #315 review fixes: eraser undo test, sanitized tap dots, custGeom path cap, ink docs

- draw.eraser with a point erases through draw.erase: test that it is one
  undo step (undo/redo) and marks the document dirty.
- Render and PDF: a single-point stroke's dot is drawn at the sanitized
  point, not the raw first one.
- DOCX custGeom reading stops once MAX_PATHS paths (subpaths included) are
  collected, across a:path elements.
- docs/file-format-parity.md: ink is saved as custom geometry, not InkML, so
  Word shows it as a freeform.
- widgets test: theme::apply takes an Appearance (#317).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Draw tab: pen, pencil and highlighter ink, stroke eraser and Hide Ink (#307)

Ink strokes are floating freeform shapes: a new Freeform geometry (paths in
shape-local coordinates, opacity, and the pen that drew it) on
InlineObject::Shape with ShapeKind::Freeform. draw.stroke inserts one
anchored to the paragraph under the first point (one undo step; points
capped per stroke, strokes capped per page, NaN dropped); draw.erase /
draw.eraser with a point deletes the stroke under it; draw.pen, draw.pencil,
draw.highlighter, draw.eraser and draw.select set the Draw tab's tool in the
session view state; review.hideInk toggles showing ink on screen.

The canvas draws a live stroke while a pen is dragged and commits it on
release; the eraser deletes strokes it passes over (one undo step per drag);
Esc or Select goes back to selecting. Draw tab buttons show the tool in use,
with colour and thickness menus; Review gets an Ink group with Hide Ink.
Ink renders with round caps and joins on screen and in PDF; highlighters are
translucent and behind the text. DOCX writes freeforms as wps:wsp with
a:custGeom (moveTo/lnTo) in a wp:anchor and reads them back, along with
simple custom-geometry freeforms from other files.

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>
2026-10-10 18:50:37 -04:00
1d7a54f883 Define New Multilevel List and Track Changes Options dialogs (#342)
* Define New Multilevel List and Track Changes Options dialogs (#328)

Home > Paragraph > Multilevel List > Define New Multilevel List edits all nine
levels with a live preview: number text and style, start, restart after a
level, linked paragraph style, alignment, aligned-at and text indent,
what follows the number with an optional tab stop, and the number's font. OK
runs the new list.define command (list.get reads a definition back). List
levels gain a restart-after level and a tab stop, which the counters and
layout honour and .docx reads and writes (lvlRestart, num tab); number
alignment now places the label.

Review > Tracking's launcher opens Track Changes Options: show comments, ink,
insertions and deletions, formatting; balloon mode; insertion and deletion
marks and colours; changed-line bars and their colour; track formatting. They
go through review.trackingOptions, are saved per user with the interface
preferences, and the page drawing honours the marks, colours, hidden
deletions, change bars and inline mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Parity checklist regenerated after merging main into #342

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>
2026-10-10 17:40:39 -04:00
4b450f1b00 Open and save password-protected Word documents (#55) (#281)
* Clipboard pane: collect the last 24 copies and cuts and paste any of them again

Copy and Cut now also keep each item (rich fragment plus plain text) in a
session list of up to 24, newest first. Items over ~4 MB (estimated) are
skipped and the list stays under ~24 MB in total; copying the same thing
twice in a row adds it once. The system clipboard is untouched.

New commands: edit.clipboardPane (toggle the pane, also the Clipboard
group's dialog launcher), edit.clipboardItems (list with previews),
edit.pasteClipboardItem {index}, edit.pasteAllClipboard,
edit.deleteClipboardItem {index} and edit.clearClipboard. Pasting goes
through the normal paste path, so it is one undo step and respects Track
Changes. The pane lists the items with a short preview; clicking one pastes
it at the caret, and each has Delete, plus Paste All and Clear All.

Parity: 378 -> 379 of 428 features.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layout > Arrange > Group: group pictures, shapes and text boxes into one object

Shift+click (or Ctrl+click) adds pictures, shapes and text boxes to the
selection; Group (arrange.group) combines the selected floating objects
into one group object that moves, wraps and resizes as one, with its
members keeping their places and scaling with the group. Ungroup
(arrange.ungroup) splits it back, where the group showed them, and
leaves them selected so Group puts them back together.

- Model: InlineObject::Group with the members' coordinate space (like
  DrawingML a:chExt), so resizing the group scales its members.
- Layout draws the members inside the group's frame; hit testing
  selects the group; text boxes in a group show their text.
- DOCX writes wpg:wgp with a:chOff/a:chExt and each member's a:xfrm,
  and reads Word's groups (including inside mc:AlternateContent and
  nested groups, flattened).
- select.addObject makes multi-selection scriptable (pos or Selection
  Pane index).

Parity 378 -> 379 of 428 (88.6%); floor raised to 88.5%.

Fixes #265

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Paragraph: Asian Typography settings, with kinsoku and mid-word Latin wrapping in layout (#269)

Paragraphs gain the Asian Typography flags (line-breaking rules, wrapping
Latin text in the middle of a word, hanging punctuation, compressing
punctuation at the start of a line, and automatic space between Asian and
Latin text and between Asian text and numbers). They inherit through styles,
round-trip through .docx pPr, are set by the new para.asianTypography
command and appear on a new Asian Typography tab of the Paragraph dialog.

Layout applies the kinsoku lists (no line starts with closing East Asian
punctuation or ends with opening punctuation), breaks between any East Asian
characters when kinsoku is off, and breaks Latin words anywhere when word
wrap is off. Text without East Asian characters breaks as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Manage Styles: list, preview, delete and show or hide styles

Home › Styles › Manage Styles (styles.manage) opens a dialog from the
Styles pane listing the document's paragraph, character, linked and table
styles, sorted as recommended or alphabetically, with a preview line and a
description (type, formatting it sets, what it is based on). From there:
Modify… and New Style… reuse the existing dialogs and come back, Delete
removes custom styles, and two checkboxes show the style in the Styles
gallery (w:qFormat) or hide it from style lists (w:semiHidden).

styles.manage returns the list for scripts (name, type, builtIn,
inGallery, hidden, basedOn, priority, own formatting). styles.delete now
moves paragraphs, runs and tables that used the style to its base (in
every story), rebases styles built on it and takes a custom linked
character half with it; built-in styles are refused and it is undoable.
New styles.setVisibility {style, gallery?, hidden?} sets the flags, which
round-trip through .docx.

Fixes #266

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Create and modify your own table styles, saved in the .docx (#146)

New Table Style and Modify Table Style (Table Design > Table Styles) make
real table styles in the document's style sheet: name, based on, and
formatting for the whole table, the header row and banded rows (borders,
fill, bold, font colour). The table at the caret takes the new style, it
shows in the gallery, and other tools that read table styles by name
(pandoc's custom-style, for one) see it.

- engine: table.newStyle / table.modifyStyle with documented params
  (regions take borders, fill, bold, italic, colour, size, font; bandSize;
  para); table.style only accepts table styles now.
- doc/layout: table styles gain header-row and row-band cell borders,
  row-band character formatting and rows per band, applied by layout.
- docx: tblStyleRowBandSize and firstRow/band1Horz tcBorders and rPr are
  read and written, so custom styles round-trip.
- ui: a compact dialog from the Styles menu next to the gallery; table
  style tiles redraw when a style changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Table styles: every region, a live preview, delete, custom styles first (#146)

Follow-up to the custom table styles work: the New/Modify Table Style
dialog now covers every region a table style formats, shows what the
style will look like, and table styles can be deleted.

- engine: table.newStyle / table.modifyStyle take lastRow (total row),
  firstColumn, lastColumn and bandedColumns regions with the same props
  as the others. New table.deleteStyle removes a custom table style:
  built-in ones are refused, styles based on it are re-based on its own
  base with its formatting folded in (so they look the same), and tables
  using it fall back to Table Grid. Undoable like any command.
- doc/layout: table styles gain fill and cell borders for the first and
  last column and total row, and fill, text and borders for column
  bands; layout applies them in the standard order (column bands, row
  bands, first/last column, header/total row).
- docx: firstCol/lastCol/lastRow/band1Vert shading and cell borders are
  read and written.
- ui: the dialog's "Apply formatting to" lists all seven regions and a
  small live preview table shows the pending changes; Styles menu gets
  Delete Table Style (disabled for built-ins); the gallery shows the
  document's own table styles first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Paste pictures from the system clipboard (screenshots, Copy Image, copied files) (#45)

egui's paste event only carries text, so a clipboard holding just a picture
sent no paste at all on Ctrl+V, and the ribbon/context-menu Paste only knew
WordCraft's own copy ("the clipboard is empty").

Desktop: the app reads the clipboard's picture through a new
Services::clipboard_picture (arboard, already in the build via egui-winit):
copied files first, else image pixels. Mod+V whose release arrives without a
paste event, and Paste from the ribbon or menus, insert that picture through
insert.picture (one undo step); text naming copied picture files inserts the
pictures. Pixel sizes are hostile: at most 16,000 px a side and 50 Mpx, the
buffer must match, files at most 200 MB.

Web: a document paste listener reads image files from the paste event when
there's no text and delivers them through the inbox like dropped pictures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Shape Format: Shape Effects — shadow, glow and soft edges (#275)

Shapes and text boxes get an effects model (outer shadow with colour,
transparency, blur, distance and angle; glow with colour, size and
transparency; soft-edge radius), the shape.effects command (presets,
custom values or null per effect, applied to every selected shape,
undoable, hostile numbers clamped), screen and PDF rendering with the
same banded approximation of a blur, DOCX a:effectLst read/write
(outerShdw, glow, softEdge) with a matching wp:effectExtent, and a
contextual Shape Format tab with Shape Fill, Shape Outline and Shape
Effects (Shadow, Glow, Soft Edges submenus) plus Arrange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Open and save password-protected Word documents (#55)

Opening a password-protected .docx used to fail as a broken zip and showed
a blank document. Encrypted packages (an OLE compound file with
EncryptionInfo + EncryptedPackage) are now detected and decrypted with
ECMA-376 agile encryption, written from the public [MS-OFFCRYPTO] spec:
password key derivation with spinCount, verifier check, data-integrity
HMAC, AES-CBC in 4096-byte segments (AES-128/192/256 with SHA-1/256/384/512
on read; AES-256 + SHA-512, spinCount 100000 on save). Standard (Office
2007) and certificate encryption give a clear "not supported" error.

- docx: crypt module (decrypt, encrypt, is_encrypted, read_with_password),
  PasswordRequired / WrongPassword / Encryption errors; hostile files are
  bounded (stream caps, spinCount <= 10,000,000, checked lengths).
- engine: file.open/file.save take "password"; file.encrypt
  {"password": string|null} sets or removes the save password (pure, so it
  never lands in Repeat or recorded macros); a document opened with a
  password is saved with it again; file.info reports "encrypted".
  Passwords are held in a zeroizing type whose Debug prints ***.
- ui: a password prompt when a user-opened file needs one (scripts get the
  error), File > Info > Protect Document with Encrypt with Password (typed
  twice) and Remove Password; web downloads are encrypted too.
- deps: aes, sha1, sha2, hmac, base64, getrandom (wasm_js in the browser),
  zeroize.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Table styles: Table Grid and Normal Table can't be deleted even when a .docx marks them custom

A .docx can set w:customStyle on any style, which made Word's built-in table styles look custom
and deletable. table.deleteStyle and the ribbon's Delete Table Style now also recognise them by
id and name, so no table is left on a style that's gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Manage Styles: deleting a table style runs Table Design's table style deletion

styles.delete on a table style now calls table_style::delete (#263): its tables take Table Grid,
styles based on it keep their look, and Word's built-in table styles are refused even when a
.docx marks them custom (the Manage Styles list reports them built-in too). Document::restyle no
longer retargets tables' own styles, so there is one place that does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Paste pictures: Mod+Shift+V (Paste Formatting) no longer pastes a clipboard picture

The key-release fallback that pastes the system clipboard's picture treated every Mod+V release as
Paste. It now requires plain Mod+V or the Paste key (no Shift, no Alt), with a unit test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Asian typography: test that kinsoku keeps a manual line break before a closing bracket

Manual line breaks end the line by cluster kind, not by break opportunity, so kinsoku's filter
can't drop one before 」; pin that with a layout test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Encrypted saves: the data-integrity HMAC key is hashSize bytes

MS-OFFCRYPTO §2.3.4.14 makes the HMAC salt as long as the hash (64 bytes for SHA-512); it was
16. Reading already accepted both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n: password dialog and File › Info strings in every catalog

Rows for the Password and Encrypt with Password dialogs, the Protect Document section of
File › Info and the password status messages in zh-hans, zh-hant, ja, pt-br, es, uk, sr and
sr-latn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Open and save password-protected Word documents (#55)

Opening a password-protected .docx used to fail as a broken zip and showed
a blank document. Encrypted packages (an OLE compound file with
EncryptionInfo + EncryptedPackage) are now detected and decrypted with
ECMA-376 agile encryption, written from the public [MS-OFFCRYPTO] spec:
password key derivation with spinCount, verifier check, data-integrity
HMAC, AES-CBC in 4096-byte segments (AES-128/192/256 with SHA-1/256/384/512
on read; AES-256 + SHA-512, spinCount 100000 on save). Standard (Office
2007) and certificate encryption give a clear "not supported" error.

- docx: crypt module (decrypt, encrypt, is_encrypted, read_with_password),
  PasswordRequired / WrongPassword / Encryption errors; hostile files are
  bounded (stream caps, spinCount <= 10,000,000, checked lengths).
- engine: file.open/file.save take "password"; file.encrypt
  {"password": string|null} sets or removes the save password (pure, so it
  never lands in Repeat or recorded macros); a document opened with a
  password is saved with it again; file.info reports "encrypted".
  Passwords are held in a zeroizing type whose Debug prints ***.
- ui: a password prompt when a user-opened file needs one (scripts get the
  error), File > Info > Protect Document with Encrypt with Password (typed
  twice) and Remove Password; web downloads are encrypted too.
- deps: aes, sha1, sha2, hmac, base64, getrandom (wasm_js in the browser),
  zeroize.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Reland: catalog coverage 390/431 (90.5%) in ROADMAP and target-app parity

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Manage Styles: mark table styles with ⊞, which the interface fonts have (▦ has no glyph, #305's check)

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>
2026-10-10 16:16:35 -04:00
29e978d6d8 Re-land #264, #263, #270, #271, #267, #280: clipboard pane, table styles, Manage Styles, Asian Typography, Group, Shape Effects (#323)
The original squash merges of these six PRs were empty. Integrated and tested together on current main.

Co-authored-by: Valentin Klein <vangel1976@googlemail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:09:04 -04:00
88ad402581 Gridlines: the document's own grid spacing, and table gridlines on by default (#69, follow-up to #243) (#283)
View › Gridlines now draws the drawing grid at the document's own spacing:
DOCX keeps it in settings.xml as w:drawingGridHorizontalSpacing and
w:drawingGridVerticalSpacing (ECMA-376 §17.15.1.44/45), which WordCraft now
reads (clamped) and writes (only when it differs from the default), with a
round-trip test. Documents that don't say use the spec's 180 twentieths of
a point (1/8 inch) instead of a fixed 0.5 cm; across and down can differ.

Table Layout › View Gridlines (table.viewGridlines) is on by default, as in
Word, so tables without borders still show their cells.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:37:31 -04:00
Valentin KleinandClaude Opus 5.5 da18ae8933 File › Info: Title, Author and the other property fields keep what you type, and browser saves advance the save stamps (#260, #262) (#268)
The Info page edited a copy of the document properties that was re-read
from the document every frame and only written back when a field lost
focus, so each keystroke vanished on the next frame and nothing was ever
committed. The fields now commit through file.properties as you type
(undoable, one step per visit to a field, marks the document dirty).

The web build's Save writes the bytes itself instead of running
file.save, so it never stamped the modified time, last-modified-by and
revision. Both paths now share Session::stamp_save.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:55:50 -04:00
15f5ad274e Column Selection: Alt+drag selects a block of text (#237)
select.column (Mod+Shift+F8) selects the same x range on a run of lines,
from two positions, two page points (Alt+drag), or by toggling column
mode where the arrow keys extend the block. Copy/Cut give one line per
row, Delete/Backspace remove only the block, character formatting and
Change Case apply per row, typing/paste replace the block. Any ordinary
caret move drops it. F8 keys now reach their shortcuts in the UI.

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>
2026-10-10 13:47:06 -04:00
e71c2b6c5b Style Inspector: styles.inspector pane, styles.inspect query and per-level clearing (#236)
styles.inspector shows/hides the Style Inspector pane and returns the
paragraph style, character style and the direct formatting that differs
from each; styles.inspect is the pure query; styles.inspectorClear
resets one level (paragraphStyle, paragraphFormatting, characterStyle,
characterFormatting) as a single undo step. The pane opens from the
Styles pane, with a clear button per level. Parity floor raised to 87%.

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>
2026-10-10 13:46:39 -04:00
9c3ad84d33 Advanced Find: every match, Reading Highlight, search within a selection (#234)
Home › Editing › Find › Advanced Find (edit.advancedFind) opens the Find
and Replace dialog. With params it returns every match in the body, or
only inside the selection ("in": "selection"), and "highlight": true
turns on Reading Highlight, which marks all matches on the page and
follows edits until it's turned off. The ribbon's Find button becomes a
menu with Find, Advanced Find and Go To; the Find dialog gets a Reading
Highlight toggle. Parity 355/405.

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>
2026-10-10 13:45:35 -04:00
19e2e37ca6 View › Gridlines shows the drawing grid; Table Layout › View Gridlines outlines cells (#69) (#243)
View › Show › Gridlines only outlined table cells, so with no table on the
page it seemed to do nothing. It now draws a light 0.5 cm grid over the
page's text area, inside the margins, in Print Layout.

Table Layout › View Gridlines gets its own command, table.viewGridlines,
with its own view flag, and draws the table cell outlines as before. The
ribbon button, keytip and parity catalog point at it.

Both are screen-only overlays drawn by the canvas; print, PDF and image
exports never show them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 13:44:09 -04:00
a9eb233a9d Track changes: keep other authors' deletions, credit paragraph splits, exact edit snapshots (split from #54) (#125)
* Track changes: a tracked delete keeps other authors' deletions

Deleting a range with Track Changes on marked every run in it as deleted
by the current author, also text another author had already deleted.
That author's deletion was lost: the change list showed it under the
wrong name, and rejecting the second author's changes brought back text
the first author had deleted. Text that is already deleted now keeps its
deletion and its author, like Word.

* Track changes: a tracked paragraph split credits the new mark to its author

Pressing Enter with Track Changes on inserts a paragraph mark by the
current author, but the paragraph after the split took its mark's
revisions from the text at the caret. Inside or at the end of another
author's insertion, the original paragraph mark then showed as inserted
by that author. The paragraph after the split now keeps the original
mark's revisions; only the new mark is the splitting author's.

* Engine: edit snapshots that take commands back exactly

Session::edit_snapshot and Session::restore let a caller run a command
and take it back without a trace: the document, the selection, the undo
and redo stacks, the open typing group, a pending join_next_undo and
the dirty flag come back as they were, and the layout is recomputed.
Edit > Undo cannot do this: it leaves a redo step, loses the redo stack
the command cleared, and closes the typing group.

At the undo limit (500 steps) a command pushes the oldest step out. The
snapshot keeps the oldest steps when the stack is near the limit and
restore puts them back, so the stack is exact there too (up to four
commands between snapshot and restore).

Restore is exact for commands that add undo steps; edit.undo and
file.new/file.open in between are outside its contract (documented).
The snapshot destructures every Session field, so a new field does not
build until someone decides whether a snapshot must cover it.

Session::undo_depth and the snapshot's doc/dirty/undo_depth accessors
tell a caller whether the command changed anything.

* Tests: hostile_params_never_panic leaves out review.readAloud

Read Aloud hands the document's text to the system's speech synthesizer
(say, PowerShell, spd-say or espeak), so running the test made the
computer speak the sample document. Like the file commands, it is left
out of the sweep.

* Undo: evictions at the limit are counted everywhere edit snapshots rely on them

Merging main brought two paths #125's eviction counter didn't know: push_undo (typing parity, #204) dropped the oldest step without counting it, and a failed command (#153) put the whole stacks back but left the count raised. Either made restore() bring back a step that had never left. Both now keep the count right; restore_is_exact_at_the_undo_limit covers each (both fail without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: lpgn <2556951+lpgn@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:07:32 -04:00
10eb8798cd Equations: Word (OMML) import, layout and save, plus an equation editor like Word's (#191)
* M5.5: Word equations (OMML) import, lay out and save like Word

Equations were flattened to plain text on import. Now OMML is read into a
math tree (fractions, scripts, radicals, n-ary operators, delimiters,
functions, limits, accents, bars, boxes, equation arrays, matrices,
phantoms), laid out with the OpenType MATH table (Cambria Math when
installed, else STIX Two Math and other open math fonts), drawn to screen
and PDF, and written back as the original OMML. Display equations sit on
their own centred line. insert.equation parses the linear format into
structure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M5.5: equation editor like Word's — Equation tab, in-place editing, # numbers

Insert › Equation now opens an equation for in-place editing, with the
Equation tab: ready-made equations, Unicode/LaTeX input and conversion
(professional ↔ linear), Normal Text, eight symbol sets, and the eleven
structure galleries (fraction, script, radical, integral, large operator,
bracket, function, accent, limit and log, operator, matrix) with tiles
rendered by the math engine. Typing builds up like Word (a/b, x^2, √, ∑…
then space), Math AutoCorrect turns \alpha, \sqrt… into symbols, Tab moves
between empty slots, arrows/Backspace work through structures, and the
right-click menu has structure commands (fraction type, radical degree,
limit position, matrix rows/columns, remove brackets…). A trailing #(n)
numbers a display equation with the number at the right margin; # alone
numbers it automatically. DOCX output keeps numbers Word-compatible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n: equation editor strings in Spanish, Brazilian Portuguese and Ukrainian

The catalogs that landed on main after #191 was opened lacked the Equation
tab and equation command labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Equations: our own names and order for the built-in equations

Clean room: the ready-made equation list used another product's gallery entry
names and order. Same formulas (and ids), with our own descriptive labels,
grouped by subject (geometry, algebra, calculus, trigonometry, probability).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ROADMAP: equations row matches the landed equation editor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fmt after merge

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>
2026-10-10 09:04:52 -04:00
e4456efb84 Text boxes and floating objects: editing, drag/resize, wrapping, overflow, footnotes (#46)
* fix text boxes: type into a new box, click in and out, Escape leaves it

Insert › Text Box left the caret in the body after the box and clicks inside a
box never reached its story, so its text could not be edited.

- insert.textBox puts the caret in the new box (when anchored in a top-level
  body paragraph, the only place layout shows box text today).
- Layout emits a hit-test-only Placed::TextBox area; story_at prefers a box
  under the point (body text wins over boxes behind it) and ignores box lines
  outside their box.
- Canvas: click enters a box, clicking outside goes back to the body (not while
  editing a header/footer); double-click in a box selects a word there.
- Escape in a box puts the caret just after it (Document::text_box_anchor).
- Editing a box doesn't dim the body or show "Editing header/footer".
- Tests: engine (caret in box, typing, undo, table cell unchanged), layout
  (story_at inside/outside/behind), doc (anchor lookup).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix pasted text boxes sharing the original's text

A copied fragment only carried the text box's part id, so a pasted box showed
the original's story: editing either changed both, edits made after copying
leaked into the paste, and pasting into another document (or Insert › Text
from File) pointed at whatever part had that id there, e.g. a header.

- Fragment carries copies of the text box stories it shows (nested boxes too).
- insert_fragment gives every pasted box a new story of its own; a box whose
  story can't be found keeps none rather than showing an unrelated part.
  Nesting is depth-limited and one paste creates at most 4096 stories.
- Insert › Text from File uses Document::body_fragment, which brings the
  file's text boxes along.
- Tests: doc (own story, copy-time snapshot, other document with clashing
  ids, box in a table, fragment without parts, self-nested box), engine
  (copy/paste/type/undo through commands).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* word count includes text boxes, footnotes and endnotes (Word's default)

The status bar and Word Count dialog counted only the body, so words typed
into text boxes (and notes) were missing.

- Document::counted_stories / word_count_including_notes: the body plus the
  text boxes, footnotes and endnotes it actually shows (nested too); orphaned
  stories (deleted or cut boxes), headers, footers and comments don't count.
- Session::count_notes (default on) with Session::word_count for the status
  bar; review.wordCount takes {"includeTextBoxes"?: bool} and reports it.
- Word Count dialog: "Include text boxes, footnotes and endnotes" checkbox.
- Document::word_count stays body-only (docx Words property, NUMWORDS, stats).
- Tests: doc (used vs orphaned stories, nesting, self-nested box), engine
  (default on, toggle sticks, deleting a box drops its words).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* canvas: select, drag to move and resize pictures, shapes and text boxes

Click a picture or shape (or a text box's border) to select it: a frame with
eight handles. Drag it to move it, drag a handle to resize it (pictures keep
their proportions from a corner, Shift toggles), arrows nudge a floating one
(Ctrl/Alt: 1 pt), Escape cancels a drag. While a text box's text is edited its
frame shows dashed with handles. Typing or Enter with a text box selected
goes into it.

Reusable pieces, no per-kind code:
- layout: Placed::Object, one hit area for every picture/shape/text box
  (replaces the text-box-only item); DocLayout::object_at / object /
  text_box / find_object (page-hinted, no whole-document scans per frame).
- engine: arrange.bounds {width,height,x,y,page} (floating objects keep their
  anchors on the same page; inline ones float where dropped; another page
  re-anchors to the nearest line) and arrange.nudge; objects::min_size shared
  with the UI.
- ui: frame.rs, a document-agnostic transform frame (handles, hit testing,
  drag/resize maths, painting); objects.rs wires it to document objects.
- geom: Rect::expand. control: ui.press / ui.release for half-way drags.

Rendering: nothing is laid out or rasterised while dragging. The preview is
the object's own pixels from the cached page texture (moves, picture
resizes) or a light outline (text box/shape resizes); the drop is one
command, one relayout, one undo step.

Tests: frame maths, layout hit areas, engine move/resize/nudge across
anchors and pages, hostile values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* moving an object anchors it to the paragraph it's dropped on, so text wraps

A floating object only pushes aside text from its anchor paragraph down, and a
move kept the old anchor: drag a text box above its paragraph and the text
there ran straight under it.

- arrange.bounds now anchors the object to the paragraph under its top edge
  (the last one starting at or above it on that page) and positions it
  relative to that paragraph's column and top, as Word does: the text it
  lands on wraps, and it moves with that text. On a page where no paragraph
  starts it anchors to the nearest line, positioned on the page.
- layout: Placed::Object / ObjectHit carry the paragraph origin that column-
  and paragraph-relative offsets start from.
- engine: edit_obj / edit_float change an object at a position (with_obj
  uses it).
- Test: a box dropped over an earlier paragraph anchors there and the lines
  beside it stop short of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* text boxes hide text that doesn't fit, as in Word

Text past a text box's bottom spilled out over the page below it.

- layout: fit_box keeps what fits in a box: whole lines that fit (a line that
  only partly fits is hidden too) and other items starting inside; the first
  line always stays so even a tiny box shows something. Done in layout, so the
  canvas, PNG and PDF agree and hidden text can't be clicked; no renderer
  changes. Reusable for anything laid out into a fixed height.
- BOX_INSET_X / BOX_INSET_Y name the text box internal margins (were 7.2/3.6
  literals).
- Test: overflow hidden, every shown line inside the box, a tall box shows
  everything, a minimum-size box shows its first line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* square wrap flows text on both sides of a floating object

Text only used the wider side of a floating picture, shape or text box; Word's
Square wrap fills both sides when there's room.

- para: row_spans gives the spans of a row around the objects in its way (each
  at least 0.5" wide, else the line goes below); a row cut in two is two Lines
  sharing top and height, the second marked `beside`. Everything per line
  (alignment and justification per side, hit testing, caret, selection,
  drawing, PDF) works unchanged; a row is as tall as its tallest part.
- layout: a row stays on one page; line numbers count rows, not parts.
- Exclusions that don't overlap a line horizontally no longer narrow it.
- Test: text both sides of a mid-column box (same row, left then right,
  hittable), a too-narrow gap stays empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* drop a deleted or cut text box's text from the document

Deleting or cutting a text box left its story in Document::parts: never shown
or saved to .docx, but listed by document.inspect and kept in memory.

- doc: prune_text_boxes removes the text box stories no box shows (nested
  ones too); free without text boxes, one paragraph pass otherwise. Session
  runs it after every editing command. Undo snapshots and the clipboard hold
  their own copies, so undo and cut/paste are unaffected.
- doc: reachable() walks the stories objects lead to; counted_stories (word
  count) and prune_text_boxes share it, and it walks blocks directly instead of
  building paragraph paths.
- Tests: orphaned and nested-orphan boxes go, shown ones (body, nested, in a
  header) stay; delete then undo, cut then paste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* word count's include-text-boxes setting survives a restart

The Word Count dialog's "Include text boxes, footnotes and endnotes" was a
session field, so it reset to on every launch.

- engine: Prefs, the editing preferences the front end saves between runs
  (count_notes moves there; Session::prefs is the one source of truth).
- ui: SavedPrefs pairs UiState with the engine Prefs; WordApp::saved_prefs /
  restore_prefs. UI fields stay at the top level of ui.json, so existing files
  still load (the new `editing` section defaults).
- wordcraft app: saves and restores SavedPrefs instead of UiState alone.
- Tests: an old prefs file loads with Word's default; the setting round-trips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* text boxes and floating objects outside the body; footnotes in text boxes

Text boxes in table cells, headers, footers, footnotes and other text boxes
showed an empty frame (or nothing), and floating pictures and shapes there
weren't drawn. Footnotes inserted inside a text box were never numbered or
placed.

- layout: place_objects, one routine for the pictures, shapes and text boxes
  in a paragraph's lines (floating ones drawn, every one's area, text boxes'
  text), used by body paragraphs and by layout_box (cells, headers, footers,
  notes, box contents). Floats are positioned against an AnchorFrame (column,
  paragraph, margins, page); headers and footers pass where the page is, so
  page/margin-anchored objects land right (a footer is laid out again only
  when it has floating objects); elsewhere they're placed in their container.
  Nested boxes show MAX_TEXT_BOX_DEPTH (doc) levels deep.
- Placed::Object / ObjectHit carry their story; DocLayout::object(&Pos),
  object lookups include headers and footers, header_footer_text_box_at.
  A part's lines are looked up in headers and footers too (caret, typing).
- Footnotes: numbered in reading order with text boxes where they are
  (Document::objects_in_reading_order); a box's footnotes go on the page of
  its line (Document::notes_in_text_box), sharing the body's reservation.
- engine: insert.textBox always puts the caret in the new box; disabled
  inside a text box (as in Word).
- canvas: click into a header/footer's text boxes while editing it; leaving a
  box returns to where it is; body dimming computed once per frame and used
  for both the render and its cache key. objects: only body objects are
  selectable, only body paragraphs' objects movable; frames off the body
  show without handles.
- Tests: boxes in header (inline and page-placed), footer, cell and note show
  text and take the caret; footnotes in a box numbered and placed in order;
  a box in a table cell takes the caret and its text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* word count: a selected object counts the whole document, in the dialog too

With a picture, shape or text box selected the status bar showed the
document's total but Review > Word Count counted the selection (0 words).

- engine: cmd::objects::object_selection, "the selection is exactly one
  object"; review.wordCount counts the document then. The UI's
  objects::selected now uses it (one definition).
- Test: an object selection counts the document; a text selection itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* review: bound nested text box expansion; tidy-ups

Blocker: laying out a text box lays out the boxes inside it, which could fan
out unboundedly (a box whose shapes show itself, or chains of boxes each
showing the next many times) - 30 shapes made 1.7M layout items. Footnote
numbering's walk fanned out the same way.

- doc: BoxBudget, one bound for both: a box isn't expanded inside itself,
  nesting stops at MAX_TEXT_BOX_DEPTH, an outermost box expands at most 64
  boxes inside it (so one hostile box can't starve the next page's header
  boxes) and a pass at most 20,000.
- layout: Ctx carries a BoxBudget for the whole layout; place_objects asks it
  before laying out a box's text. DocLayout::text_boxes reports how many were.
- doc: walk_objects (objects_in_reading_order, notes_in_text_box) uses it too.
- Tests (crafted documents): a box showing itself 30 times lays out once; a
  five-level chain fanning out 30 ways stays within the budget, for layout and
  for the walk; a box inside a box still shows its text. Weakening the budget
  makes them fail (20,000 boxes / 600,001 visits).

Tidy-ups:
- para: row spans in a VecDeque (pop_front, not remove(0)).
- fit_box: a text box's text area excludes both the top and bottom margins.
- theme: Tokens::handle / handle_shadow for the frame handles (light, dark);
  the drag preview's opacity is a named constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* box budget: no overall cap; bound the .docx writer too; tests

An audit of the previous commit found:

- The 20,000-box overall cap starved ordinary long documents: one budget per
  layout, spent by body boxes first, then one per page for header boxes (two
  for footers with floats), so a long document's header boxes went blank on
  every page. It wasn't needed: only nesting grows exponentially, and
  MAX_NESTED caps that per outermost box, keeping the work linear. Removed.
- The .docx writer still expanded a shape's story only depth-limited (a box
  showing itself 30 times saved as ~28,000 nested copies, ~7 MB zipped). It
  now uses the same BoxBudget, and only for text box parts.
- Layout expanded a shape's story whatever its part kind (a shape pointing
  at a header showed the header's text); now text box parts only, as the
  walk does. The story's blocks are cloned only once the budget agrees.
- Translations of "Count words in text boxes and notes" said footnotes only;
  now footnotes and endnotes, in each catalog's own terms.
- Tests: no timing assert or leftovers; a long document with 400 boxes and a
  header box shows all of them on every page; notes_in_text_box follows
  nested boxes once; saving a self-showing box stays small (fails at 7 MB
  without the bound).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Arrange: nudging or dragging an aligned float keeps it in place first

main (#136) reads w:align into Float.h_align/v_align, which overrides the offset; the PR's
nudge only changed the offset and drag only reset the anchors. Nudge now converts an
aligned axis into a column/paragraph offset that keeps the object where it is, and a drag
clears the alignment. Test included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fonts: a lookup that races another thread's load of the same family no longer falls back

FontDb::face fell back to Source Sans 3 when its load_cataloged added nothing, which happens
when another thread loaded the family between the miss and the load. word::resolve memoises
that, so the wrong font stuck for the process; it made layout tests on main flaky
(justified_lines_shrink_spaces_in_word_2013_mode, space_shrinking_is_only_for_justified_text
failed 6 of 15 runs on origin/main). Look again after loading whatever it returns. Regression
test races 8 threads on fresh databases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layout tests: text box and two-sided wrap tests measure what they mean

square_wrap_flows_text_on_both_sides compared xs.last() (the end of a trailing space, which
may hang past the edge) with the edge; compare the last non-space cluster's right edge.
text_box_hides_text_that_does_not_fit compared a tall box's lines with another document's
line count; compare with its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Weeb Labs <weeblabs@VFIEVOX3.Router>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 08:51:38 -04:00
fe7e79ad70 Read Aloud: player with speed control, sentence skipping, skips citations (#190)
* Zotero 1: range fields and custom document properties round-trip

Citation managers' ADDIN fields (Zotero, Mendeley, EndNote) are now kept as
range fields: FieldStart/FieldEnd markers with the result as ordinary,
formatted content between them, possibly spanning paragraphs (a Zotero
bibliography). Before, the result was flattened to plain text and a
multi-paragraph field lost its end.

- doc: InlineObject::FieldStart/FieldEnd, Document::field_ranges,
  balance_field_ranges; CustomProp and custom_props with get/set/remove.
- docx: read ADDIN fields as ranges (field codes up to 2 MiB), write them
  back as begin/instr/separate…end; orphan markers are dropped on save so
  files always hold whole fields. docProps/custom.xml is parsed and
  regenerated (simple types as text, vectors/blobs verbatim) instead of
  passed through, so Zotero's ZOTERO_PREF_n settings are editable.
- engine: file.properties reads and sets custom properties.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Zotero 2: wire-protocol client and document bridge

New crate wordcraft-zotero (L5) speaks Zotero's published word-processor
protocol (port 23116; 32-bit transaction id + big-endian length frames,
JSON payloads, ERR: replies):

- client: sends an integration command (addEditCitation,
  addEditBibliography, addNote, refresh, removeCodes, setDocPrefs) and
  answers Zotero's calls until Document_complete or the connection
  closes (cancelled dialog). Answers go through a callback so the app can
  apply them on its UI thread; run_on_session answers directly.
- Bridge: implements Zotero's Document_*/Field_* calls on a Session.
  Fields are stored as Word's plugin stores them (ADDIN ZOTERO_ range
  fields; prefs in ZOTERO_PREF_n, with Zotero's ReferenceMark field type
  kept as Word's Field in the file), so documents stay Word-compatible.
  Rich text (RTF or HTML) keeps citation formatting; bibliographies get
  a Bibliography style from setBibliographyStyle; note styles create
  footnotes/endnotes, and deleting a lone note citation removes its note;
  placeholder links convert to fields. One transaction = one undo step.
  Field ids stay stable through inserts and deletes.
- Tests: a fake Zotero over a real socket, every call, docx round trip,
  hostile arguments for every method, random frames (proptest).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Zotero 3: Zotero in the app and the CLI, tested with the real Zotero

- ui.zotero.<command> (addEditCitation, addEditBibliography, addNote,
  refresh, removeCodes, setDocPrefs), ui.zotero.status and
  ui.zotero.answer. The socket runs on a background thread; each of
  Zotero's calls is answered on the UI thread against the session, and
  Zotero's questions become a dialog the session waits on. Web builds
  report that Zotero needs the desktop app.
- wordcraft-cli zotero <command> <file> [--save] [--trace] [--port].
- Bridge: a selection Zotero makes to show a citation is put back when
  the session ends.
- ui_shot: {"wait": ms} keeps frames running for background work.
- docs/zotero.md.

Checked against Zotero 10.0.6: refresh of a citation and bibliography
(APA), its "modified citation" question answered in the app's dialog,
RTF results with italics, Bibliography style, prefs round trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Zotero 4: switching between in-text and note styles moves citations

Document_convert and Field_convert now move a citation between the
text, footnotes and endnotes, as Zotero asks when the document's style
changes between an in-text and a note style: into a new note at the
same place, or back into the text where the note mark was (removing a
note that held only the citation; keeping one with other text). Field
ids survive the move, formatting is kept, and each Zotero session stays
one undo step.

Checked against Zotero 10.0.6: unlink citations (removeCodes, with its
confirmation question) and a refresh after switching the document to
Chicago notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Zotero 5: Zotero tab in the ribbon

A Zotero tab (before Help) with Add/Edit Citation, Add Note, Add/Edit
Bibliography, Refresh, Document Preferences and Unlink Citations. The
buttons are disabled while a Zotero command is running and in the web
build. Three new icons drawn in code (document preferences, unlink,
add note); the others reuse the References tab's. Labels and status
messages translated (zh-hans, zh-hant, ja).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Zotero: Add/Edit Bibliography inserted nothing

Zotero reads a newly inserted bibliography field back before filling
it, and unlinks a field whose text is empty, taking it for a
bibliography the user deleted. Our new fields were empty, so Add/Edit
Bibliography ended with Field_removeCode and nothing in the document.
New fields now hold "{Citation}" until Zotero writes the result.

Found by tracing the session with Zotero 10.0.6; regression test added.
wordcraft-cli zotero takes --cmd (run before the Zotero command, e.g.
caret.docEnd) to reproduce sessions from a given caret position.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Zotero: log each command's outcome and Zotero's calls to the app log

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read Aloud player: speed, previous/next sentence, skips citations

Read Aloud now speaks sentence by sentence with the system speech program
(say / espeak / Windows SAPI) on a worker thread, so it can pause, skip
and change speed. A floating player window has previous / play-pause /
next / stop, a 0.5-3x speed slider and "Skip citations & bibliography"
(on by default: Zotero, Mendeley, EndNote and Word citation fields).
The spoken sentence is highlighted and the caret follows it.

New commands: readAloud.playPause/next/previous/stop/speed/
skipCitations/status, and caret.pastCitation (Zotero > Move Past
Citation). Speed and skip preference persist with the UI prefs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cargo.lock: wordcraft-zotero at the workspace version (0.4.0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read Aloud: no unused BASE_WPM warning on wasm

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read Aloud: fuzz review.readAloud in hostile_params_never_panic

#190 makes the engine's speech backend silent (Hold) under cfg(test), so the
#75 skip is no longer needed; assert the backend instead so a test can
never start the system synthesiser.

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>
2026-10-10 08:36:19 -04:00
6c152055b9 Hidden text: no line breaks, hyphens, wrap areas or bookmark text from text that isn't shown (builds on #152) (#173)
* Tables: stop repeating a header row that leaves no room for the next row

A repeating header row plus a row that could not fit below it on an empty
page made place_table loop: after each page break the header was placed
again, so the page was never "at top" when the row was retried, split_row
had nothing to split, and the loop ran its 1,000 iterations per row. A
20-row table with a 640 pt header came out as 19,002 pages.

After a page break the header now repeats only when the row (or its first
split) still fits below it; otherwise the row starts at the top of the
page, as Word does when a header row would fill the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layout: give runs with different links or decorations their own style

Builder::style memoised StyleRuns by a key that folded strike, double
strike and "has a link" into one boolean and left out the underline
colour and baseline position. The display list reads all of these from
the shared StyleRun, so in a paragraph of like-formatted links every link
pointed at the first one's URL in the PDF (and an external link after an
internal one became a bookmark jump), double strike drew as single strike
after a struck run, and underline colours were copied.

The key is now the whole resolved format, so runs share a StyleRun only
when nothing the display list reads differs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PDF and No Markup: leave tracked deletions out of the final text

PDF export defaults to no markup, but it laid the document out with every
deleted run in place and only switched off the strike and colour, so the
PDF printed deleted text as ordinary text ("Keep DELETEDTEXT INSERTED"):
neither the original nor the final document, and a leak of text the
author removed. The No Markup view did the same on screen.

A new LayoutOptions::hide_deleted lays deleted runs out like hidden text
(zero-width markers, so offsets, carets and hit testing keep working). PDF
export sets it whenever markup is off, the session layout sets it when
markup is hidden, and the markup toggles relayout as Show/Hide marks does.
The display list also never draws a deletion as plain text when markup is
off, whatever layout it is given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tables: repeat the header on every page and put the next row under it, as Word does

The earlier fix for the runaway loop dropped the repeated header row on any
page where the header and the next row did not fit together. Word never
does that: measured on Word for Mac, it moves the row to a new page, repeats
the header there and places the row under it even when the row then runs
into the bottom margin (a 300 pt header with 400 pt rows gives one header +
row per page; a 640 pt header with 100 pt rows does the same).

place_table now remembers when the page holds only the repeated header
rows. A row that cannot split is placed there anyway instead of breaking
the page again, which is both Word's behaviour and what keeps the loop from
spinning: every pass now places something. Rows of text taller than the
room under the header still split across pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PDF: keep tracked deletions out of bookmarks and heading tags

Without markup the PDF pages show the final text, but bookmark titles and
the titles of heading structure tags were still built from plain_text(),
which includes deleted runs. A heading "Intro SECRET Part" with "SECRET "
deleted printed as "Intro Part" yet bookmarked as "Intro SECRET Part", and
a fully deleted heading still got a bookmark: text the author removed
leaked into the outline and the tag tree.

Paragraph::final_text() gives the text with deletions left out, and the
exporter titles headings with it unless markup is included. A heading
whose text is all deleted has an empty title and so no bookmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* No Markup: deleted note references take no number and print no endnote

With deletions hidden, a tracked-deleted footnote or endnote reference
disappears from the text, but note_numbers() still counted it. Deleting
the first of two footnote references left the surviving one numbered 2,
and a deleted endnote reference still printed its endnote after the last
paragraph, because the endnote list is taken from the same map.

note_numbers() now skips references inside tracked deletions when the
layout hides them, so the remaining notes number from 1 and endnotes whose
reference was deleted are not emitted. With markup shown nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* No Markup: a deleted floating object leaves no wrap area behind

With deletions hidden, a tracked-deleted floating picture or shape was
not drawn, but place_para had already added its wrap exclusion from
p.objects without looking at the anchor, so the final text still flowed
around an invisible object (indented beside a square-wrapped shape).

Floating objects whose anchor is a tracked deletion are now skipped when
the layout hides deletions; deleted_objects() is shared with note
numbering, which needs the same test. With markup shown nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* No Markup: a deleted soft hyphen no longer breaks the word with a hyphen

With deletions hidden, a tracked-deleted soft hyphen became a zero-width
marker, but hyphenation_points() reads the paragraph's raw text and
accepted marker clusters, so the line could still break there and draw
"ab-" in a narrow column although the final text has no soft hyphen.

When the layout hides deletions, clusters inside a tracked deletion are
no longer hyphenation points (soft hyphens or dictionary points alike).
With markup shown nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* No Markup: tracked deletions add no line-break opportunities

Break opportunities came from running UAX #14 over the paragraph's raw
text, deleted runs included. With deletions hidden, a deleted soft hyphen
or space still let the line break there: "xx ab<del>SHY</del>cdef" in a
narrow column came out as "xx ab" / "cdef" instead of moving the word
"abcdef" to the next line whole.

When the layout hides deletions, the opportunities are computed over the
text without them and mapped back to offsets in the paragraph. With
markup shown, or with no deletions, the computation is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hidden text: no hyphenation points or line breaks in text that is not shown

Hidden text that is not shown (the default, and always in print and PDF)
is laid out as zero-width markers, but break opportunities came from UAX #14
over the paragraph's raw text and hyphenation_points() accepted marker
clusters, so a hidden space or soft hyphen still let the line break there:
"xx ab<hidden SHY>cdef" in a narrow column came out "xx ab-" / "cdef".
Measured in Word for Mac, the hidden soft hyphen and the hidden space are
no place to break: "xx" / "abcdef".

left_out() is now the one test for text the layout leaves out (hidden text
not shown, tracked deletions without markup). The run loop records the
ranges it leaves out, and both the break opportunities and the hyphenation
points come from that, so deletions behave exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hidden text: a floating object anchored in it leaves no wrap area behind

With hidden text not shown, a floating picture or shape anchored in a
hidden run was not drawn (the run is a zero-width marker, so no object
cluster places it), but place_para had already added its wrap exclusion,
so the text still flowed around an invisible object. Measured in Word for
Mac: a square-wrapped picture anchored in hidden text is not printed and
the text runs the full width.

deleted_objects() becomes left_out_objects(), which resolves each anchor's
formatting through the styles (hidden text often comes from a character
style) and applies the same left_out() test as the paragraph layout. Note
numbering keeps counting hidden reference marks, as Word does, so it asks
only about deletions; its behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hidden text: print the footnote of a hidden reference mark, as Word does

A footnote whose reference mark is hidden text was numbered but never
printed: the hidden run became zero-width markers without recording the
note, so place_para never put the footnote at the foot of the page.
Measured in Word for Mac with hidden text not printed, a hidden footnote or
endnote reference still takes its number (the next visible footnote is 2)
and both notes are printed. Endnotes already were, and the numbering
already matched.

A hidden reference mark now records its note like a visible one, so the
footnote goes to the foot of the page holding the line it sits on. A
deleted reference in the final text still records nothing, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PDF: keep hidden text out of bookmarks and heading tags

The PDF never prints hidden text (its layout does not show it), but the
bookmark and heading-tag titles were built from final_text(), which drops
tracked deletions only. A heading "Intro SECRET Part" with "SECRET " hidden
printed as "Intro Part" yet bookmarked as "Intro SECRET Part", and a heading
that is all hidden text still got a bookmark for a heading nobody can see.

Paragraph::text_where() generalises final_text() to any per-run test, and
the exporter titles headings with the runs that are neither hidden (resolved
through the styles, since a character style can hide text) nor, without
markup, deleted. A heading that is all hidden has an empty title and so no
bookmark. Word's PDF export from AppleScript writes no outline, so there is
no Word answer to match; this follows what the pages show, as for deletions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PDF: titles leave out what the layout left out, table formatting included

Bookmark and heading-tag titles resolved hidden text from the paragraph
and run formatting alone, but the layout also applies the table style's
conditional formatting (header row, first column, total row) beneath the
runs. A Heading 1 in a table header row whose style hides it printed
nothing yet still got a bookmark, and a partly hidden heading leaked the
hidden part into its tag.

ParaLayout now records the byte ranges it laid out as nothing (left_out),
resolved exactly as laid out, and the exporter titles a heading from the
text outside them (plus tracked deletions without markup, as before), so
the titles cannot disagree with the pages. text_where() gives way to
text_without() over byte ranges; final_text() is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hyphenation: hyphenate words as laid out, without hidden or deleted text

Automatic hyphenation looked up the words of the paragraph's raw text and
only dropped points that fell inside text the layout leaves out. Words and
the minimum-letters limits still came from the raw text: with "accep"
hidden in "acceptable", the visible "table" could break as "t-able" (the
point after "accept"), and a hidden space split "foot<space>ball" into two
words, so the visible "football" never hyphenated.

Soft hyphens and dictionary points are now found in the same text as laid
out that the UAX #14 break opportunities already use, and mapped back to
paragraph offsets. With hidden text shown and markup on, that text is the
paragraph's own and nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layout, PDF: skip left-out text in one pass, not a scan per char

Building the text as laid out tested every char against every left-out
range, and Paragraph::text_without did the same for PDF titles. Hidden
text in many runs (formatting that alternates, a common result of imports)
made both quadratic: a 100,000-char hidden heading in one-char runs took
13.9 s to convert with the release CLI, 400,000 chars minutes.

The layout now merges adjacent left-out runs as it records them and walks
the ordered ranges with a cursor; text_without sorts its ranges by start
(they may come in any order and overlap) and walks them the same way. Both
are linear: 0.20 s for that heading, 0.81 s at 400,000 chars, with the same
output. No left-out text still means no filtering at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hyphenation: check the limits of each point in constant time

hyphen_points() found each candidate point's letter run by scanning out
from the point and counted the letters on each side by rescanning the run,
and the syllable heuristic rebuilt the rest of the word as a string for
every position. Both are quadratic in the word's length. Ordinary words are
short, but with hidden separators the layout now hyphenates the visible
word, so "hyphenation " repeated 8,192 times with every space hidden is one
90,112-letter word: 2.0 s to convert with the release CLI.

Letter and uppercase counts before each char, the letter run of each char
and the heuristic's next vowel and byte offsets are now computed once, so
every point is checked in constant time: that document converts in 0.09 s.
The output is unchanged: the old functions stay in the tests as the
reference, compared over awkward words and every combination of limits, and
a hyphenated document's PDF text is identical. No maximum word length is
imposed: the work is linear, so a cap would only change output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layout: find the formatting of every object anchor in one walk

left_out_objects() asked props_of_char() for each floating object's anchor,
and props_of_char() scans the runs from the start, so a paragraph with many
anchors in their own runs cost a scan per anchor, quadratic, and paid it on
every layout because it runs before the paragraph cache is consulted:
40,000 hidden anchors took 0.69 s, against 0.05 s for 10,000.

The runs and the object offsets are both in order, so one walk now finds
each anchor's run, resolving a run's formatting once; past the last run it
is the paragraph mark, as props_of_char() gives. With hidden text shown and
markup on nothing can be left out and no work is done at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layout: find a run's style without formatting it as a string

#152 keyed each run's style by its whole format's Debug string, built for every
shaped piece; on a long document with many runs that doubled layout time (3000
paragraphs x 40 linked runs: 12.7 s vs 6.4 s for the old key). Key by a hash of
the commonly varying fields and confirm by comparing formats: 3.8 s. A capped
bucket scan keeps formats that never compare equal (NaN) linear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layout: floats hidden in table cells take no room, table style included

Test the layout_box path the merge extended (a float anchored in hidden text in a
table cell is not placed and leaves no wrap area), and resolve the anchors'
formatting with the table style's character formatting under the runs' own, as
the cell's text is laid out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: linear-time checks compare 8x the input against a 24x limit

At 4x with an 8x limit a busy machine could push a linear layout over the line (8.8x seen once in a full workspace run); at 8x a linear layout takes about 8x and a quadratic one about 64x, so 24x stays far from both.

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>
2026-10-10 08:35:35 -04:00
802ba30441 Desktop and web: ask to save changes before New, Open or Close; AutoSave only files you saved (#151)
* Desktop and web: ask to save changes before New, Open, Close or quit

New (Mod+N, File › New), Open (once a file is picked, recent files,
dropped files), Close (Mod+W) and the window's close button replaced or
closed a document with unsaved changes without asking, and its undo
history went with it: `Session::set_document` drops both, and nothing
checked `dirty` first. Closing or reloading the browser tab lost the
document the same way.

`WordApp::run`, the path every user action takes, now stops New, Open
with a path and Close on a dirty document and shows "Do you want to save
changes to <name>?" with Save / Don't Save / Cancel; the command runs
once it is answered (`ui.saveChanges`, so agents can answer it too).
Save goes through the normal save, or Save As for a document without a
file, and a failed or cancelled save cancels. The window's close button
is held with `ViewportCommand::CancelClose` until the prompt is
answered. Scripts and agents keep the old behaviour: the control channel
(and the MCP bridge over it) now calls `WordApp::execute`, which never
asks, as programmatic calls never open dialogs.

While the prompt is up, keys go to it rather than to the page behind
(Enter would otherwise type a paragraph mark into the document being
saved). On the web, a `beforeunload` listener asks the browser to
confirm leaving with unsaved changes, and Export (PDF/PNG) no longer
marks the document saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* AutoSave: only write to a file the user saved to, and report failures

AutoSave was on for every document with a path, and `file.open` sets
the path, so opening contract.docx and typing one character rewrote it
in place about 2.5 s later. A save drops whatever the readers don't
model (content controls, charts, SmartArt, macros…), so that content was
deleted from the original without the user ever pressing Save, and
undo can't bring it back. Save errors were discarded with `let _ =` and
retried every 2.5 s.

AutoSave now writes only to the file the document was explicitly saved
to (Save / Save As) in this session; a file that was only opened is left
alone until the user saves it. New and Open reset that. A failed
AutoSave is shown in the status bar and logged, and turns AutoSave off
for that file until the next explicit save instead of retrying forever.

The title-bar toggle shows on only when AutoSave actually covers the
document; clicking it on an unsaved or merely opened document starts
Save As, as it already did for a new one. The Options label now says
which documents AutoSave covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Save Changes prompt: Enter clicks the focused button, not always Save

With Cancel or Don't Save focused (Tab), egui turns Enter into a click on
that button, but the dialog then also checked `key_pressed(Enter)` on
its own and overwrote the answer with Save. Cancelling a Close from the
keyboard therefore saved the file over itself and quit, and Don't Save
saved too.

The answer now comes from the button that was clicked; Enter means Save
only when none of the three buttons has focus (the usual case, with the
page behind still holding focus). Escape still cancels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Save Changes prompt: forget it when its document is replaced

A prompt asked about document A kept its pending action after a script
or agent (control channel, MCP) opened document B. Don't Save then ran
the retained New or Open against B and threw away B's edits, under a
question that named A.

`Session` now has a `document_id` that changes only when `set_document`
replaces the document (open, new, mail merge, recover), never on an
edit. The prompt records the id it asked about. Any command that
replaces the document drops a pending prompt, and an answer that arrives
anyway (a click in the same frame, `ui.saveChanges` from an agent) is
refused when the id no longer matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* AutoSave: keep it on when the Open picker is cancelled

`file.open` without a path only asks the UI for the file picker, and it
returns Ok. The AutoSave bookkeeping cleared the saved-file permission
on any successful `file.open`, so pressing Mod+O and cancelling turned
AutoSave off for a document that never changed, until the next explicit
save.

The permission is now cleared when the document is actually replaced,
which `Session::document_id` tells apart from an edit. That also covers
the other commands that replace it (mail merge, recover), which the
id-based check missed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Web: tell the leave-page guard about edits made while drawing

The `beforeunload` guard reads a flag the web shell copied from
`session.dirty` after `logic`. Typing and formatting happen later in
the same frame, in `ui`, so after the first keystroke in a clean
document the flag still said "saved" until the next frame's `logic`.
Closing or reloading the tab in that window left without asking.

The app now reports its unsaved state to the host after both passes
(`Services::on_dirty`, called at the end of `logic` and of `ui`), and
the web shell sets the guard's flag from that instead of copying it
itself. That puts the timing in `ui-egui`, where a native test can
cover it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Save Changes prompt: a save that drops content doesn't count as saved

The prompt's Save accepted any `saved: true`. Save As to a format that
doesn't keep the whole document (a .png of page 1, .txt, .md) reports
that while leaving the document unsaved, so New, Open or Close went
ahead and the document, with its undo history, was gone.

Save now lets the command continue only when the document is clean
afterwards, i.e. it went to a format `file.save` treats as the
document's own file (docx, odt, rtf, json). Otherwise the command is
cancelled, the document stays open, and the status bar says to save it
as .docx. A document opened from a .txt file therefore needs Save As
.docx (or Don't Save) before it can be replaced: the engine already
treats a text save as a copy.

The web shell's download path cleared the unsaved flag for any save,
whatever the format, so a .png or .txt "save" also silenced the prompt
and the leave-page guard. It now clears it only for those formats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Web: a failed download is not a save

The download service returned nothing, and the web shell caught and
only logged a failure to start the download. Save therefore always
cleared the unsaved flag and reported `downloaded`, so the Save Changes
prompt went ahead with New or Open (replacing the document and its
undo history) even when nothing was downloaded, and the leave-page
guard stopped asking.

`Services::download` now returns a Result and the web shell passes its
error through. On an error, Save leaves the document unsaved, shows the
failure in the status bar and returns it, and the prompt cancels the
pending command.

Limit: a browser doesn't tell the page what happens after the download
starts (the user may cancel it or the file may be blocked), so a
download that started is still treated as saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M-i18n: Save Changes prompt and AutoSave strings in pt-br, es and uk

The renamed AutoSave option, its failure message, the Save Changes prompt and
the failed-download message had rows only in ja/zh catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Save Changes and AutoSave: .docm, .dotx and .dotm keep everything (#172)

Since #172 these formats keep macros and content types, and file.save already
treats them as the document's own file. keeps_everything() and AutoSave now
share one list that includes them, so a web download of a .docm marks the
document saved and AutoSave covers a .docm/.dotx/.dotm saved this session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Save As: keep main's suggested name (.docm stays .docm) after the merge

The merge kept the PR's save_as_dialog body, which reverted main's
default_save_name() suggestion back to <title>.docx.

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>
2026-10-10 08:25:45 -04:00
c0ccda288c Find/Replace skips text deleted under Track Changes (#89); a failed command keeps undo history (#153)
* Find/Replace: skip text deleted under Track Changes

With Track Changes on, Replace marks the match deleted and leaves it in
the paragraph text, then inserts the replacement in front of it. The
search helper matched the raw paragraph text, so the next step found that
same deleted "cat" right after the caret and replaced it again: four
Replaces on "one cat two cat" gave "one dogdogdogdog two cat" and
`remaining` never dropped.

`search` (used by Find, Find Next/Previous, Replace, its `remaining`
count and Replace All) now skips any match that overlaps a run marked
deleted, so deleted text is never found, as in Word. A match that would
run across deleted text is skipped too: that string is not in the
document as it now reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Undo: a failed command keeps the redo stack and the oldest undo step

`run` checkpoints a mutating command before running it, and the
checkpoint clears the redo stack (and, once the history is at its limit,
drops the oldest step). When the command then failed, the error path put
back the document, selection and typing group and truncated the history,
but the cleared redo stack stayed cleared. Insert, Undo, then any
command that fails on its parameters (`text.insert {}`,
`para.align {value:"bogus"}`) made the undone work unrecoverable. At the
limit, truncating to the old length also kept the failed command's empty
step and lost the oldest real one.

The checkpoint now hands back what it removed, and the error path
restores both. join_next is deliberately left consumed: the drag that
set it re-arms it every frame, and restoring it would merge the next
unrelated command into the drag's undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Find/Replace: find live text next to a tracked deletion

The previous fix rejected any regex match that overlapped tracked-deleted
text, but `find_iter` had already consumed those positions, so a valid
match overlapping the rejected one was never tried. "aaa" with the first
"a" deleted reads "aa", yet Find "aa" found nothing (raw 0..2 was
rejected and the scan resumed at 2) and Replace All replaced nothing.
Searching the raw text also let deleted characters decide word
boundaries: deleting the space in "cat fish" still matched whole-word
"fish".

`search` now builds each paragraph as it reads, without deleted runs,
and searches each stretch of live text separately with
`regex_automata::Input::range`. A match stays within one stretch, as
before, and `\b`, `^` and `$` see the neighbouring live text, so a
deletion neither creates nor hides a word boundary. `regex::Regex` cannot
bound a search's end without slicing the haystack, which turns the slice
end into a word boundary; regex-automata is the engine `regex` already
uses (0.4.18, already in the lockfile), so no new code is built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Undo: a failed command restores history changed by nested commands

The previous fix had the checkpoint report what it evicted and put that
back on failure, but it only saw the outer command's own checkpoint.
Commands run nested commands, and a nested one that succeeds checkpoints
too. `file.inspect {"remove":["comments","revisions"]}` in a
comments-only document runs `review.deleteComment` (allowed) and then
`review.acceptAll` (refused). With 499 undo steps, the nested checkpoint
evicted the oldest step and the rollback, truncating to 499, kept the
outer command's empty step instead. At 500, only one of the two
evictions came back. Nested undo, redo and version restore change the
stacks in ways a length cannot describe either.

Undo steps are now `Arc<Undo>`, and `run` snapshots both stacks whole
before a mutating command and puts them back on failure, so the
history is exact whatever ran inside. The snapshot copies one pointer
per step, under 1,000 refcount bumps and an 8 KB allocation at
MAX_UNDO, beside the two document clones every mutating command
already makes. Undo and Redo are not mutating commands, so a top-level
Undo still moves its document out of the step without cloning it. Only
an undo nested inside a mutating command (a macro) clones, because the
snapshot still holds the step. This replaces the eviction-return
plumbing, so `checkpoint` is back to its original form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: compare undo timelines without comment timestamps

failed_command_restores_history_changed_by_nested_commands builds the
same session twice and compares their documents. Comments are stamped
with the time to the second, so when the clock crossed a second between
the two builds the documents differed and the test failed (about 1 run
in 12). Clear the stamp in the compared copies; the test still checks
every other part of each document in the timeline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Undo: wrap the typing-parity undo step in Arc after merging main

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>
2026-10-10 06:34:35 -04:00
Brandon ThomasandClaude Opus 5.5 869ac16110 Typing parity with Word: lists, AutoFormat and AutoCorrect as you type (#204)
* Typing parity with Word: lists, AutoFormat and AutoCorrect as you type

Typed side by side with Word (same keystrokes, results read back) and matched it:

- Enter after a list no longer gets stuck: the paragraph that leaves a list goes back to
  Normal, and later Enters make blank paragraphs (the `num: 0` marker counted as a list item,
  so every Enter re-ran "leave the list" and did nothing; the list indent also stuck to every
  following paragraph).
- Enter on an empty nested item moves it up a level before leaving the list.
- Second Backspace at the start of an un-numbered list paragraph returns it to Normal
  instead of joining it to the paragraph above.
- "1. " always starts numbering at 1; "a. " and "I. " start lettered/roman lists; "- " makes
  a dash bullet.
- Undo right after an AutoFormat/AutoCorrect change undoes only that change.
- AutoCorrect: sentence capitals (with abbreviation/initial exceptions), runs on Enter too,
  "word -- word" en dash, "wait..." ellipsis, 1/2 1/4 3/4, ordinal superscripts, web
  addresses become links.
- Border lines: Enter after ---, ===, ***, ___, ~~~, ### draws a bottom border.
- Layout: no line break right after a slash ("and/or", URLs), as in Word.
- ui_shot: UI_SHOT_FULL=1 prints whole replies for scripted checks.

Regression tests in crates/engine/src/tests_typing.rs and the layout tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Typing parity: record Word's observed typing rules, two more tests

docs/typing-parity.md lists what Word does for each key sequence (lists, Backspace, Tab,
AutoCorrect, AutoFormat, slash line breaks) with the test that pins it, the known gaps, and how
to compare without taking over the shared machine. CLAUDE.md/AGENTS.md point at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:52:10 -04:00
MarekSpodBiedry 61e2152250 Ruler: dragging an indent marker is one undo step (#57) (#62) 2026-10-09 08:48:37 -04:00
Brandon ThomasandClaude Opus 5.5 d95e09ba25 Clippy/fmt clean workspace, ROADMAP with honest parity estimate; xtask ci green
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:11:08 -04:00
Brandon ThomasandClaude Opus 5.5 72262a2ea9 M3/M6/M12: PDF export (krilla), TXT/MD/HTML/RTF/ODT import+export, citations & bibliography, index, table of figures, cross-references, tools (repeat, macros, AutoCorrect, compare, accessibility, inspector, protection, versions, Quick Parts)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:57:36 -04:00
Brandon ThomasandClaude Opus 5.5 c29182e8bc M1: DOCX read/write (OOXML) wired into open/save; floating objects; picture & shape tools; mail merge, envelopes, labels; contextual spacing; export without proofing marks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:46:40 -04:00
Brandon ThomasandClaude Opus 5.5 d1a0ed39c0 M7.1: proofing — spelling with suggestions, grammar checks, squiggles, F7, right-click menu, thesaurus; web app; hyphenation dictionary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:38:37 -04:00
Brandon ThomasandClaude Opus 5.5 b8dd46ccb5 M0.7: engine — session, undo, 200+ commands, Word feature catalog, samples
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:47:26 -04:00