Commit Graph

125 Commits

Author SHA1 Message Date
vangel76 2f8f304fc9 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>
2026-10-10 19:35:05 +02:00
Valentin Klein 40c5d51bf9
Mailings: Start Mail Merge and Rules open menus, and Match Fields and Check for Errors show what they found (#259)
* Tests: shapes and pictures can be clicked, resized, dragged and nudged

Fixed by #46 but only text boxes were covered. These walk the editor's
path for shapes (rectangle, ellipse, star, arrow) and pictures: hit test,
select, resize with arrange.bounds, drag to float with Square wrap,
nudge, undo. Covers #142 and #82.

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

* Font list offers every weight a family installs, named as Word names them (#219)

Fonts were listed and matched only by their typographic family name
(name ID 16, e.g. "Roboto"), so a face other than Regular, Bold or Italic
(Light, Medium, SemiBold, Black, Condensed...) never appeared in the font
list, and a document asking for "Roboto Light" fell back to a substitute.

Each face now also carries its legacy family name, the one Word lists and
stores in documents: name ID 1 for static fonts ("Source Sans 3
Semibold"), or the family plus the style words other than Regular, Bold
and Italic for a variable font's named instances ("Inter Light"). The
font list includes these names, has_family and face() resolve them (bold
and italic still choose or synthesise within the faces the name covers),
and asking for a legacy name loads the whole installed family. The
system font scan now also reads fvar, so a variable font's instances are
listed before it is loaded.

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

* Absolutely positioned VML text boxes float instead of sitting inline (#149)

A legacy VML shape (w:pict > v:shape / v:rect) with position:absolute in its
style was read as an inline object, so a tall text box in a header made the
header as tall as the box and pushed the body down the page.

The reader now maps the VML style to the same float model DrawingML anchors
use: left/margin-left and top/margin-top offsets, mso-position-*-relative
reference areas, mso-position-horizontal/vertical alignment, mso-wrap-distance-*
and w10:wrap (square, tight, through, topAndBottom; none or absent is in front
of the text, or behind it at a negative z-index). Shapes without
position:absolute stay inline. Saving writes the boxes as DrawingML anchors
with the same placement.

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

* Reject All removes paragraph breaks inserted with Track Changes on (#229)

Enter under Track Changes already recorded the new paragraph mark as an
insertion, but accepting or rejecting only cleared that flag, so Reject All
left the paragraphs split. Rejecting an inserted mark (or accepting a deleted
one) now joins the paragraph with the next one again; Accept All, Reject All,
Accept/Reject at the caret or over a selection, Next/Previous Change and the
Reviewing Pane all see paragraph marks.

Backspace at a paragraph start and Delete at a paragraph end now record a
tracked deletion of the mark (or remove a mark the same author inserted)
instead of only moving the caret; tracked deletions across paragraphs mark
the paragraph marks too.

DOCX reads and writes paragraph-mark revisions (w:ins / w:del in the
mark's w:rPr), and a tracked mark is drawn in its author's colour when
formatting marks are shown.

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

* Table Layout: Text Direction turns cell text (#226)

The Text Direction button only flipped a flag that layout and rendering
ignored (and docx always wrote it as btLr), so pressing it did nothing
visible.

Cells now carry a text direction (horizontal, top-to-bottom tbRl,
bottom-to-top btLr). The button cycles through them like Word's, for every
selected cell. Layout runs turned text along the cell's height: an exact
row height sets the line length, otherwise the row grows to fit the
unwrapped text. Turned lines are drawn in a turned frame on screen and in
PDF, and clicks, caret, and selection follow the turn. docx reads and
writes w:textDirection with the matching value.

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

* File dialogs no longer freeze the window on Linux (#94)

Open, Save As, Export and Insert/Change Picture used rfd's blocking
FileDialog on the UI thread with no parent window. On Linux the
xdg-desktop-portal call blocked the event loop until the dialog closed,
and the unparented dialog could open behind WordCraft, so the app looked
frozen.

The desktop app now shows rfd::AsyncFileDialog parented to the window
(set_parent with eframe's Frame, no unsafe) and waits for the answer on
a background thread, which hands it back through a channel and wakes the
UI. ui-egui gains a backend-agnostic Services::file_dialog hook: the app
remembers what the dialog was for and finishes it when the answer
arrives (open the file, save, export, insert or change the picture, turn
AutoSave on). "Save changes?" -> Save on a new document now runs the
held-back New/Open/Close only once the later Save As has saved; a
cancelled Save As cancels it. Only one file dialog is open at a time;
further clicks are ignored until it is answered.

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

* Select Recipients opens a recipient list instead of a parameters error (#240)

Clicking Mailings › Select Recipients ran mailings.recipients with no data, so the
status bar showed "`csv`, `path` or `rows` required" and nothing opened.

The button is now a menu: Type a New List… opens a Recipient List dialog (an
editable table of fields and entries), and Use an Existing List… opens the file
picker for a CSV/TSV/text file (desktop: loaded by path; web: the picked file
comes through the inbox and loads as recipients instead of opening as a
document). User-run commands that lack their input open a dialog instead of
failing: Select Recipients and Edit Recipient List open the Recipient List,
Insert Merge Field and Find Recipient open small dialogs of their own. Scripts
and agents still get the command's error, never a dialog.

mailings.recipients and mailings.editRecipients take an optional `fields` list,
so a typed list keeps its column order and rows can be arrays.

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

* Chinese interface: embed the Chinese UI font in releases, fall back to an installed one (#241)

The release workflow pinned storytold/craft-fonts at abb8331, the commit that added the
Japanese fonts. Noto Sans CJK SC came one commit later (8dcdacd), so release builds embedded
only the Japanese faces and the Simplified Chinese interface fell back to BIZ UDPGothic, which
lacks about 157 simplified-only hanzi (页, 选, 删 …) and drew them as boxes. Document text was
unaffected because it uses the system font fallback.

- Bump the craft-fonts pin in the release and FreeBSD workflows to 8dcdacd.
- With CRAFT_FONTS_REQUIRED, a native build now fails when the craft-fonts manifest has no
  Jpan or Hans font, so an outdated pin can't ship silently again.
- When no embedded face covers the interface language, the UI font families get an installed
  CJK font (desktop only): a short per-platform list of files in the system font folders
  (Windows Deng/msyh/simhei/simsun, macOS PingFang/STHeiti, Linux Noto Sans CJK/WenQuanYi),
  header-checked, size-capped, and the face chosen by coverage (a SC face in a pan-CJK
  collection), so egui never sees a file it can't parse.

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

* Add a System interface theme that follows the OS appearance (#115)

The interface theme is now a three-way choice: Use system setting,
Light or Dark, in File > Options > General and in a new Interface Theme
menu in View > Dark Mode. System follows the light/dark appearance egui
reports (winit on macOS, Windows and Linux; prefers-color-scheme on the
web) at launch and live, and falls back to light when the OS reports
none. egui's own theme is now pinned to ours, so an OS change no longer
swaps in egui's unstyled style.

Saved preferences migrate: the old "dark" switch becomes Dark or Light
and is never written again. Light stays the default for new installs.
New ui.theme command (light/dark/system); ui.dark still sets Light/Dark.
The dark page (View > Switch Modes) is unchanged.

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

* Mailings: Start Mail Merge and Rules are menus; Match Fields and Check for Errors show results (#240)

Start Mail Merge opens a menu of the document kinds the engine makes (Letters,
E-mail Messages, Envelopes, Labels, Directory, Normal Word Document) with the
current one checked. mailings.start now rejects kinds it doesn't know and takes
`normal`.

Rules opens a menu of the merge rules the engine has: Merge Record # and Next
Record insert straight away; If…Then…Else… and Skip Record If… open a small
dialog for the field, value and texts. Run by the user without a field (or
without a rule), mailings.rules opens that dialog; scripts and agents still get
the default.

Match Fields shows which recipient-list field fills each address part, and
Check for Errors lists merge fields the recipient list lacks, each in a dialog.
Edit Recipient List is disabled until there is a recipient list.

The new strings, and the ones the Recipient List dialogs added, are in every
interface catalog.

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

* #248: read the installed CJK UI font only when CJK text is shown

A build without craft-fonts read a large system CJK font on the first frame even
for an English interface. Now it is loaded only for a CJK interface language, or
once File > Options shows the language list (names in their own scripts).

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

* #247 on #246: Use an Existing List… goes through the non-blocking file dialog

#246 removed the desktop's blocking pick_open; the recipient list picker now asks
the host's file dialog hook (AfterPick::Recipients → load_recipients), and the
desktop dialog offers a Recipient lists filter (csv/tsv/txt).

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

* i18n: translate the recipient list and file dialog strings (#247, #246)

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 13:08:44 -04:00
Valentin Klein 3684289699
Select Recipients lets you type a list or pick a CSV, instead of a parameters error (#247)
* Tests: shapes and pictures can be clicked, resized, dragged and nudged

Fixed by #46 but only text boxes were covered. These walk the editor's
path for shapes (rectangle, ellipse, star, arrow) and pictures: hit test,
select, resize with arrange.bounds, drag to float with Square wrap,
nudge, undo. Covers #142 and #82.

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

* Font list offers every weight a family installs, named as Word names them (#219)

Fonts were listed and matched only by their typographic family name
(name ID 16, e.g. "Roboto"), so a face other than Regular, Bold or Italic
(Light, Medium, SemiBold, Black, Condensed...) never appeared in the font
list, and a document asking for "Roboto Light" fell back to a substitute.

Each face now also carries its legacy family name, the one Word lists and
stores in documents: name ID 1 for static fonts ("Source Sans 3
Semibold"), or the family plus the style words other than Regular, Bold
and Italic for a variable font's named instances ("Inter Light"). The
font list includes these names, has_family and face() resolve them (bold
and italic still choose or synthesise within the faces the name covers),
and asking for a legacy name loads the whole installed family. The
system font scan now also reads fvar, so a variable font's instances are
listed before it is loaded.

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

* Absolutely positioned VML text boxes float instead of sitting inline (#149)

A legacy VML shape (w:pict > v:shape / v:rect) with position:absolute in its
style was read as an inline object, so a tall text box in a header made the
header as tall as the box and pushed the body down the page.

The reader now maps the VML style to the same float model DrawingML anchors
use: left/margin-left and top/margin-top offsets, mso-position-*-relative
reference areas, mso-position-horizontal/vertical alignment, mso-wrap-distance-*
and w10:wrap (square, tight, through, topAndBottom; none or absent is in front
of the text, or behind it at a negative z-index). Shapes without
position:absolute stay inline. Saving writes the boxes as DrawingML anchors
with the same placement.

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

* Reject All removes paragraph breaks inserted with Track Changes on (#229)

Enter under Track Changes already recorded the new paragraph mark as an
insertion, but accepting or rejecting only cleared that flag, so Reject All
left the paragraphs split. Rejecting an inserted mark (or accepting a deleted
one) now joins the paragraph with the next one again; Accept All, Reject All,
Accept/Reject at the caret or over a selection, Next/Previous Change and the
Reviewing Pane all see paragraph marks.

Backspace at a paragraph start and Delete at a paragraph end now record a
tracked deletion of the mark (or remove a mark the same author inserted)
instead of only moving the caret; tracked deletions across paragraphs mark
the paragraph marks too.

DOCX reads and writes paragraph-mark revisions (w:ins / w:del in the
mark's w:rPr), and a tracked mark is drawn in its author's colour when
formatting marks are shown.

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

* Table Layout: Text Direction turns cell text (#226)

The Text Direction button only flipped a flag that layout and rendering
ignored (and docx always wrote it as btLr), so pressing it did nothing
visible.

Cells now carry a text direction (horizontal, top-to-bottom tbRl,
bottom-to-top btLr). The button cycles through them like Word's, for every
selected cell. Layout runs turned text along the cell's height: an exact
row height sets the line length, otherwise the row grows to fit the
unwrapped text. Turned lines are drawn in a turned frame on screen and in
PDF, and clicks, caret, and selection follow the turn. docx reads and
writes w:textDirection with the matching value.

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

* File dialogs no longer freeze the window on Linux (#94)

Open, Save As, Export and Insert/Change Picture used rfd's blocking
FileDialog on the UI thread with no parent window. On Linux the
xdg-desktop-portal call blocked the event loop until the dialog closed,
and the unparented dialog could open behind WordCraft, so the app looked
frozen.

The desktop app now shows rfd::AsyncFileDialog parented to the window
(set_parent with eframe's Frame, no unsafe) and waits for the answer on
a background thread, which hands it back through a channel and wakes the
UI. ui-egui gains a backend-agnostic Services::file_dialog hook: the app
remembers what the dialog was for and finishes it when the answer
arrives (open the file, save, export, insert or change the picture, turn
AutoSave on). "Save changes?" -> Save on a new document now runs the
held-back New/Open/Close only once the later Save As has saved; a
cancelled Save As cancels it. Only one file dialog is open at a time;
further clicks are ignored until it is answered.

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

* Select Recipients opens a recipient list instead of a parameters error (#240)

Clicking Mailings › Select Recipients ran mailings.recipients with no data, so the
status bar showed "`csv`, `path` or `rows` required" and nothing opened.

The button is now a menu: Type a New List… opens a Recipient List dialog (an
editable table of fields and entries), and Use an Existing List… opens the file
picker for a CSV/TSV/text file (desktop: loaded by path; web: the picked file
comes through the inbox and loads as recipients instead of opening as a
document). User-run commands that lack their input open a dialog instead of
failing: Select Recipients and Edit Recipient List open the Recipient List,
Insert Merge Field and Find Recipient open small dialogs of their own. Scripts
and agents still get the command's error, never a dialog.

mailings.recipients and mailings.editRecipients take an optional `fields` list,
so a typed list keeps its column order and rows can be arrays.

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

* Chinese interface: embed the Chinese UI font in releases, fall back to an installed one (#241)

The release workflow pinned storytold/craft-fonts at abb8331, the commit that added the
Japanese fonts. Noto Sans CJK SC came one commit later (8dcdacd), so release builds embedded
only the Japanese faces and the Simplified Chinese interface fell back to BIZ UDPGothic, which
lacks about 157 simplified-only hanzi (页, 选, 删 …) and drew them as boxes. Document text was
unaffected because it uses the system font fallback.

- Bump the craft-fonts pin in the release and FreeBSD workflows to 8dcdacd.
- With CRAFT_FONTS_REQUIRED, a native build now fails when the craft-fonts manifest has no
  Jpan or Hans font, so an outdated pin can't ship silently again.
- When no embedded face covers the interface language, the UI font families get an installed
  CJK font (desktop only): a short per-platform list of files in the system font folders
  (Windows Deng/msyh/simhei/simsun, macOS PingFang/STHeiti, Linux Noto Sans CJK/WenQuanYi),
  header-checked, size-capped, and the face chosen by coverage (a SC face in a pan-CJK
  collection), so egui never sees a file it can't parse.

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

* Add a System interface theme that follows the OS appearance (#115)

The interface theme is now a three-way choice: Use system setting,
Light or Dark, in File > Options > General and in a new Interface Theme
menu in View > Dark Mode. System follows the light/dark appearance egui
reports (winit on macOS, Windows and Linux; prefers-color-scheme on the
web) at launch and live, and falls back to light when the OS reports
none. egui's own theme is now pinned to ours, so an OS change no longer
swaps in egui's unstyled style.

Saved preferences migrate: the old "dark" switch becomes Dark or Light
and is never written again. Light stays the default for new installs.
New ui.theme command (light/dark/system); ui.dark still sets Light/Dark.
The dark page (View > Switch Modes) is unchanged.

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

* #248: read the installed CJK UI font only when CJK text is shown

A build without craft-fonts read a large system CJK font on the first frame even
for an English interface. Now it is loaded only for a CJK interface language, or
once File > Options shows the language list (names in their own scripts).

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

* #247 on #246: Use an Existing List… goes through the non-blocking file dialog

#246 removed the desktop's blocking pick_open; the recipient list picker now asks
the host's file dialog hook (AfterPick::Recipients → load_recipients), and the
desktop dialog offers a Recipient lists filter (csv/tsv/txt).

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

* i18n: translate the recipient list and file dialog strings (#247, #246)

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 13:07:39 -04:00
Valentin Klein 7dcfa07c15
File dialogs (Open, Save As, Export) no longer freeze the window on Linux (#94) (#246)
* Tests: shapes and pictures can be clicked, resized, dragged and nudged

Fixed by #46 but only text boxes were covered. These walk the editor's
path for shapes (rectangle, ellipse, star, arrow) and pictures: hit test,
select, resize with arrange.bounds, drag to float with Square wrap,
nudge, undo. Covers #142 and #82.

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

* Font list offers every weight a family installs, named as Word names them (#219)

Fonts were listed and matched only by their typographic family name
(name ID 16, e.g. "Roboto"), so a face other than Regular, Bold or Italic
(Light, Medium, SemiBold, Black, Condensed...) never appeared in the font
list, and a document asking for "Roboto Light" fell back to a substitute.

Each face now also carries its legacy family name, the one Word lists and
stores in documents: name ID 1 for static fonts ("Source Sans 3
Semibold"), or the family plus the style words other than Regular, Bold
and Italic for a variable font's named instances ("Inter Light"). The
font list includes these names, has_family and face() resolve them (bold
and italic still choose or synthesise within the faces the name covers),
and asking for a legacy name loads the whole installed family. The
system font scan now also reads fvar, so a variable font's instances are
listed before it is loaded.

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

* Absolutely positioned VML text boxes float instead of sitting inline (#149)

A legacy VML shape (w:pict > v:shape / v:rect) with position:absolute in its
style was read as an inline object, so a tall text box in a header made the
header as tall as the box and pushed the body down the page.

The reader now maps the VML style to the same float model DrawingML anchors
use: left/margin-left and top/margin-top offsets, mso-position-*-relative
reference areas, mso-position-horizontal/vertical alignment, mso-wrap-distance-*
and w10:wrap (square, tight, through, topAndBottom; none or absent is in front
of the text, or behind it at a negative z-index). Shapes without
position:absolute stay inline. Saving writes the boxes as DrawingML anchors
with the same placement.

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

* Reject All removes paragraph breaks inserted with Track Changes on (#229)

Enter under Track Changes already recorded the new paragraph mark as an
insertion, but accepting or rejecting only cleared that flag, so Reject All
left the paragraphs split. Rejecting an inserted mark (or accepting a deleted
one) now joins the paragraph with the next one again; Accept All, Reject All,
Accept/Reject at the caret or over a selection, Next/Previous Change and the
Reviewing Pane all see paragraph marks.

Backspace at a paragraph start and Delete at a paragraph end now record a
tracked deletion of the mark (or remove a mark the same author inserted)
instead of only moving the caret; tracked deletions across paragraphs mark
the paragraph marks too.

DOCX reads and writes paragraph-mark revisions (w:ins / w:del in the
mark's w:rPr), and a tracked mark is drawn in its author's colour when
formatting marks are shown.

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

* Table Layout: Text Direction turns cell text (#226)

The Text Direction button only flipped a flag that layout and rendering
ignored (and docx always wrote it as btLr), so pressing it did nothing
visible.

Cells now carry a text direction (horizontal, top-to-bottom tbRl,
bottom-to-top btLr). The button cycles through them like Word's, for every
selected cell. Layout runs turned text along the cell's height: an exact
row height sets the line length, otherwise the row grows to fit the
unwrapped text. Turned lines are drawn in a turned frame on screen and in
PDF, and clicks, caret, and selection follow the turn. docx reads and
writes w:textDirection with the matching value.

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

* File dialogs no longer freeze the window on Linux (#94)

Open, Save As, Export and Insert/Change Picture used rfd's blocking
FileDialog on the UI thread with no parent window. On Linux the
xdg-desktop-portal call blocked the event loop until the dialog closed,
and the unparented dialog could open behind WordCraft, so the app looked
frozen.

The desktop app now shows rfd::AsyncFileDialog parented to the window
(set_parent with eframe's Frame, no unsafe) and waits for the answer on
a background thread, which hands it back through a channel and wakes the
UI. ui-egui gains a backend-agnostic Services::file_dialog hook: the app
remembers what the dialog was for and finishes it when the answer
arrives (open the file, save, export, insert or change the picture, turn
AutoSave on). "Save changes?" -> Save on a new document now runs the
held-back New/Open/Close only once the later Save As has saved; a
cancelled Save As cancels it. Only one file dialog is open at a time;
further clicks are ignored until it is answered.

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

* Chinese interface: embed the Chinese UI font in releases, fall back to an installed one (#241)

The release workflow pinned storytold/craft-fonts at abb8331, the commit that added the
Japanese fonts. Noto Sans CJK SC came one commit later (8dcdacd), so release builds embedded
only the Japanese faces and the Simplified Chinese interface fell back to BIZ UDPGothic, which
lacks about 157 simplified-only hanzi (页, 选, 删 …) and drew them as boxes. Document text was
unaffected because it uses the system font fallback.

- Bump the craft-fonts pin in the release and FreeBSD workflows to 8dcdacd.
- With CRAFT_FONTS_REQUIRED, a native build now fails when the craft-fonts manifest has no
  Jpan or Hans font, so an outdated pin can't ship silently again.
- When no embedded face covers the interface language, the UI font families get an installed
  CJK font (desktop only): a short per-platform list of files in the system font folders
  (Windows Deng/msyh/simhei/simsun, macOS PingFang/STHeiti, Linux Noto Sans CJK/WenQuanYi),
  header-checked, size-capped, and the face chosen by coverage (a SC face in a pan-CJK
  collection), so egui never sees a file it can't parse.

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

* Add a System interface theme that follows the OS appearance (#115)

The interface theme is now a three-way choice: Use system setting,
Light or Dark, in File > Options > General and in a new Interface Theme
menu in View > Dark Mode. System follows the light/dark appearance egui
reports (winit on macOS, Windows and Linux; prefers-color-scheme on the
web) at launch and live, and falls back to light when the OS reports
none. egui's own theme is now pinned to ours, so an OS change no longer
swaps in egui's unstyled style.

Saved preferences migrate: the old "dark" switch becomes Dark or Light
and is never written again. Light stays the default for new installs.
New ui.theme command (light/dark/system); ui.dark still sets Light/Dark.
The dark page (View > Switch Modes) is unchanged.

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

* #248: read the installed CJK UI font only when CJK text is shown

A build without craft-fonts read a large system CJK font on the first frame even
for an English interface. Now it is loaded only for a CJK interface language, or
once File > Options shows the language list (names in their own scripts).

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 13:05:44 -04:00
Valentin Klein 8b49ea7546
Table Layout › Text Direction turns the cell's text (#226) (#245)
* Tests: shapes and pictures can be clicked, resized, dragged and nudged

Fixed by #46 but only text boxes were covered. These walk the editor's
path for shapes (rectangle, ellipse, star, arrow) and pictures: hit test,
select, resize with arrange.bounds, drag to float with Square wrap,
nudge, undo. Covers #142 and #82.

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

* Font list offers every weight a family installs, named as Word names them (#219)

Fonts were listed and matched only by their typographic family name
(name ID 16, e.g. "Roboto"), so a face other than Regular, Bold or Italic
(Light, Medium, SemiBold, Black, Condensed...) never appeared in the font
list, and a document asking for "Roboto Light" fell back to a substitute.

Each face now also carries its legacy family name, the one Word lists and
stores in documents: name ID 1 for static fonts ("Source Sans 3
Semibold"), or the family plus the style words other than Regular, Bold
and Italic for a variable font's named instances ("Inter Light"). The
font list includes these names, has_family and face() resolve them (bold
and italic still choose or synthesise within the faces the name covers),
and asking for a legacy name loads the whole installed family. The
system font scan now also reads fvar, so a variable font's instances are
listed before it is loaded.

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

* Absolutely positioned VML text boxes float instead of sitting inline (#149)

A legacy VML shape (w:pict > v:shape / v:rect) with position:absolute in its
style was read as an inline object, so a tall text box in a header made the
header as tall as the box and pushed the body down the page.

The reader now maps the VML style to the same float model DrawingML anchors
use: left/margin-left and top/margin-top offsets, mso-position-*-relative
reference areas, mso-position-horizontal/vertical alignment, mso-wrap-distance-*
and w10:wrap (square, tight, through, topAndBottom; none or absent is in front
of the text, or behind it at a negative z-index). Shapes without
position:absolute stay inline. Saving writes the boxes as DrawingML anchors
with the same placement.

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

* Reject All removes paragraph breaks inserted with Track Changes on (#229)

Enter under Track Changes already recorded the new paragraph mark as an
insertion, but accepting or rejecting only cleared that flag, so Reject All
left the paragraphs split. Rejecting an inserted mark (or accepting a deleted
one) now joins the paragraph with the next one again; Accept All, Reject All,
Accept/Reject at the caret or over a selection, Next/Previous Change and the
Reviewing Pane all see paragraph marks.

Backspace at a paragraph start and Delete at a paragraph end now record a
tracked deletion of the mark (or remove a mark the same author inserted)
instead of only moving the caret; tracked deletions across paragraphs mark
the paragraph marks too.

DOCX reads and writes paragraph-mark revisions (w:ins / w:del in the
mark's w:rPr), and a tracked mark is drawn in its author's colour when
formatting marks are shown.

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

* Table Layout: Text Direction turns cell text (#226)

The Text Direction button only flipped a flag that layout and rendering
ignored (and docx always wrote it as btLr), so pressing it did nothing
visible.

Cells now carry a text direction (horizontal, top-to-bottom tbRl,
bottom-to-top btLr). The button cycles through them like Word's, for every
selected cell. Layout runs turned text along the cell's height: an exact
row height sets the line length, otherwise the row grows to fit the
unwrapped text. Turned lines are drawn in a turned frame on screen and in
PDF, and clicks, caret, and selection follow the turn. docx reads and
writes w:textDirection with the matching value.

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

* Chinese interface: embed the Chinese UI font in releases, fall back to an installed one (#241)

The release workflow pinned storytold/craft-fonts at abb8331, the commit that added the
Japanese fonts. Noto Sans CJK SC came one commit later (8dcdacd), so release builds embedded
only the Japanese faces and the Simplified Chinese interface fell back to BIZ UDPGothic, which
lacks about 157 simplified-only hanzi (页, 选, 删 …) and drew them as boxes. Document text was
unaffected because it uses the system font fallback.

- Bump the craft-fonts pin in the release and FreeBSD workflows to 8dcdacd.
- With CRAFT_FONTS_REQUIRED, a native build now fails when the craft-fonts manifest has no
  Jpan or Hans font, so an outdated pin can't ship silently again.
- When no embedded face covers the interface language, the UI font families get an installed
  CJK font (desktop only): a short per-platform list of files in the system font folders
  (Windows Deng/msyh/simhei/simsun, macOS PingFang/STHeiti, Linux Noto Sans CJK/WenQuanYi),
  header-checked, size-capped, and the face chosen by coverage (a SC face in a pan-CJK
  collection), so egui never sees a file it can't parse.

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

* Add a System interface theme that follows the OS appearance (#115)

The interface theme is now a three-way choice: Use system setting,
Light or Dark, in File > Options > General and in a new Interface Theme
menu in View > Dark Mode. System follows the light/dark appearance egui
reports (winit on macOS, Windows and Linux; prefers-color-scheme on the
web) at launch and live, and falls back to light when the OS reports
none. egui's own theme is now pinned to ours, so an OS change no longer
swaps in egui's unstyled style.

Saved preferences migrate: the old "dark" switch becomes Dark or Light
and is never written again. Light stays the default for new installs.
New ui.theme command (light/dark/system); ui.dark still sets Light/Dark.
The dark page (View > Switch Modes) is unchanged.

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

* #248: read the installed CJK UI font only when CJK text is shown

A build without craft-fonts read a large system CJK font on the first frame even
for an English interface. Now it is loaded only for a CJK interface language, or
once File > Options shows the language list (names in their own scripts).

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 13:05:04 -04:00
Valentin Klein 18d3a39278
Chinese interface shows every character instead of boxes on Windows (#248)
* Tests: shapes and pictures can be clicked, resized, dragged and nudged

Fixed by #46 but only text boxes were covered. These walk the editor's
path for shapes (rectangle, ellipse, star, arrow) and pictures: hit test,
select, resize with arrange.bounds, drag to float with Square wrap,
nudge, undo. Covers #142 and #82.

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

* Font list offers every weight a family installs, named as Word names them (#219)

Fonts were listed and matched only by their typographic family name
(name ID 16, e.g. "Roboto"), so a face other than Regular, Bold or Italic
(Light, Medium, SemiBold, Black, Condensed...) never appeared in the font
list, and a document asking for "Roboto Light" fell back to a substitute.

Each face now also carries its legacy family name, the one Word lists and
stores in documents: name ID 1 for static fonts ("Source Sans 3
Semibold"), or the family plus the style words other than Regular, Bold
and Italic for a variable font's named instances ("Inter Light"). The
font list includes these names, has_family and face() resolve them (bold
and italic still choose or synthesise within the faces the name covers),
and asking for a legacy name loads the whole installed family. The
system font scan now also reads fvar, so a variable font's instances are
listed before it is loaded.

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

* Absolutely positioned VML text boxes float instead of sitting inline (#149)

A legacy VML shape (w:pict > v:shape / v:rect) with position:absolute in its
style was read as an inline object, so a tall text box in a header made the
header as tall as the box and pushed the body down the page.

The reader now maps the VML style to the same float model DrawingML anchors
use: left/margin-left and top/margin-top offsets, mso-position-*-relative
reference areas, mso-position-horizontal/vertical alignment, mso-wrap-distance-*
and w10:wrap (square, tight, through, topAndBottom; none or absent is in front
of the text, or behind it at a negative z-index). Shapes without
position:absolute stay inline. Saving writes the boxes as DrawingML anchors
with the same placement.

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

* Chinese interface: embed the Chinese UI font in releases, fall back to an installed one (#241)

The release workflow pinned storytold/craft-fonts at abb8331, the commit that added the
Japanese fonts. Noto Sans CJK SC came one commit later (8dcdacd), so release builds embedded
only the Japanese faces and the Simplified Chinese interface fell back to BIZ UDPGothic, which
lacks about 157 simplified-only hanzi (页, 选, 删 …) and drew them as boxes. Document text was
unaffected because it uses the system font fallback.

- Bump the craft-fonts pin in the release and FreeBSD workflows to 8dcdacd.
- With CRAFT_FONTS_REQUIRED, a native build now fails when the craft-fonts manifest has no
  Jpan or Hans font, so an outdated pin can't ship silently again.
- When no embedded face covers the interface language, the UI font families get an installed
  CJK font (desktop only): a short per-platform list of files in the system font folders
  (Windows Deng/msyh/simhei/simsun, macOS PingFang/STHeiti, Linux Noto Sans CJK/WenQuanYi),
  header-checked, size-capped, and the face chosen by coverage (a SC face in a pan-CJK
  collection), so egui never sees a file it can't parse.

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

* Add a System interface theme that follows the OS appearance (#115)

The interface theme is now a three-way choice: Use system setting,
Light or Dark, in File > Options > General and in a new Interface Theme
menu in View > Dark Mode. System follows the light/dark appearance egui
reports (winit on macOS, Windows and Linux; prefers-color-scheme on the
web) at launch and live, and falls back to light when the OS reports
none. egui's own theme is now pinned to ours, so an OS change no longer
swaps in egui's unstyled style.

Saved preferences migrate: the old "dark" switch becomes Dark or Light
and is never written again. Light stays the default for new installs.
New ui.theme command (light/dark/system); ui.dark still sets Light/Dark.
The dark page (View > Switch Modes) is unchanged.

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

* #248: read the installed CJK UI font only when CJK text is shown

A build without craft-fonts read a large system CJK font on the first frame even
for an English interface. Now it is loaded only for a CJK interface language, or
once File > Options shows the language list (names in their own scripts).

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 13:04:18 -04:00
Valentin Klein f4a694ecf0
Font list offers every installed weight (Light, Medium, SemiBold, Black…), as Word does (#239)
* Tests: shapes and pictures can be clicked, resized, dragged and nudged

Fixed by #46 but only text boxes were covered. These walk the editor's
path for shapes (rectangle, ellipse, star, arrow) and pictures: hit test,
select, resize with arrange.bounds, drag to float with Square wrap,
nudge, undo. Covers #142 and #82.

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

* Font list offers every weight a family installs, named as Word names them (#219)

Fonts were listed and matched only by their typographic family name
(name ID 16, e.g. "Roboto"), so a face other than Regular, Bold or Italic
(Light, Medium, SemiBold, Black, Condensed...) never appeared in the font
list, and a document asking for "Roboto Light" fell back to a substitute.

Each face now also carries its legacy family name, the one Word lists and
stores in documents: name ID 1 for static fonts ("Source Sans 3
Semibold"), or the family plus the style words other than Regular, Bold
and Italic for a variable font's named instances ("Inter Light"). The
font list includes these names, has_family and face() resolve them (bold
and italic still choose or synthesise within the faces the name covers),
and asking for a legacy name loads the whole installed family. The
system font scan now also reads fvar, so a variable font's instances are
listed before it is loaded.

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

* Absolutely positioned VML text boxes float instead of sitting inline (#149)

A legacy VML shape (w:pict > v:shape / v:rect) with position:absolute in its
style was read as an inline object, so a tall text box in a header made the
header as tall as the box and pushed the body down the page.

The reader now maps the VML style to the same float model DrawingML anchors
use: left/margin-left and top/margin-top offsets, mso-position-*-relative
reference areas, mso-position-horizontal/vertical alignment, mso-wrap-distance-*
and w10:wrap (square, tight, through, topAndBottom; none or absent is in front
of the text, or behind it at a negative z-index). Shapes without
position:absolute stay inline. Saving writes the boxes as DrawingML anchors
with the same placement.

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

* Add a System interface theme that follows the OS appearance (#115)

The interface theme is now a three-way choice: Use system setting,
Light or Dark, in File > Options > General and in a new Interface Theme
menu in View > Dark Mode. System follows the light/dark appearance egui
reports (winit on macOS, Windows and Linux; prefers-color-scheme on the
web) at launch and live, and falls back to light when the OS reports
none. egui's own theme is now pinned to ours, so an OS change no longer
swaps in egui's unstyled style.

Saved preferences migrate: the old "dark" switch becomes Dark or Light
and is never written again. Light stays the default for new installs.
New ui.theme command (light/dark/system); ui.dark still sets Light/Dark.
The dark page (View > Switch Modes) is unchanged.

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 13:02:40 -04:00
Dmitrii Khlapov 7b8c38fa62
Contributors: add my own entry (dkhlapov) (#238)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:26:53 -04:00
Dmitrii Khlapov 9643dcb621
Font picker: build only the visible rows, cache font fallbacks (#121, #59) (#233)
Opening the font menu built a row, and so rendered a preview, for every
installed family: each preview loads that family's font files, so the menu
kept repainting until every font on the system was read into memory (1.1 GB
for 305 families here; far more on large collections), which froze the app.
The combo list now uses ScrollArea::show_rows, so only rows in view are built.

Layout asked FontDb for a fallback face once per character the font lacks,
and each lookup sorted every loaded face, so text in a script the font lacks
(Thai) slowed down with every font loaded. Fallback lookups are now cached
per (character, excluded face) until more faces load: 5000 Thai characters
lay out in 5 ms instead of 96 ms with all fonts loaded.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:24:54 -04:00
Valentin Klein 7040751a54
Windows installer: show a finish page after installing (#198) (#257)
The MSI had no UI, so Windows Installer showed only its basic progress box and closed
without saying the install had worked. It now uses WiX's standard WixUI_Minimal dialogs:
a welcome page with the license (MIT or Apache-2.0, built at packaging time from
LICENSE-MIT and LICENSE-APACHE), progress, and a finish page with an optional
"Launch WordCraft" checkbox. The release and Windows ARM64 workflows install the WiX
UI and Util extensions the build now needs. UpgradeCode and component GUIDs are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:23:03 -04:00
Valentin Klein 075b828c3d
Zoomed out, pages sit side by side in rows like Word's Print Layout (#123) (#251)
When the window is wide enough for more than one page at the current zoom,
Print Layout now flows pages left to right in rows (two, three or more per
row as you zoom out) instead of a single narrow column. One page per row is
kept whenever two don't fit, and for One Page / Page Width; Multiple Pages
keeps at least two; Read Mode stays a two-page spread. All clicks, selection,
caret scrolling, object drags, balloons and rulers already map through the
actual page rects, so they follow the new positions. When the column count
changes, the caret's page is scrolled back into view.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:22:46 -04:00
Valentin Klein 61fac127ed
Make Backstage Home and New different pages (#141) (#254)
Home greets you with Blank plus a short row of templates, a "More templates"
link to New, and the Recent list. New shows the whole template gallery with a
search box and no Recent list. New strings are in every interface catalog.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:22:06 -04:00
Valentin Klein ca83ba7be8
macOS title bar: centre the traffic lights and space the Backstage back button (#225, #222) (#255)
AppKit keeps the traffic lights in its standard 28 pt title-bar band at the
top of the window, but the integrated title bar was 38 pt tall, so the lights
sat above its centre line. Moving them would need AppKit calls (unsafe), so
on macOS the title bar is now that band's height, with the search box and
account button shrunk to fit. Content starts after the lights plus the same
gap they keep from the top edge.

The Backstage covers the title bar, and its back button sat right under the
traffic lights. On macOS its top band is now a title bar: the back button sits
beside the lights, centred with them at the same inset as the main title
bar's controls, and the rest of the band drags the window. Linux and Windows
are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:21:41 -04:00
Dmitrii Khlapov 302528d67e
Web: use the browser clock everywhere on wasm (insert weekday, PDF dates, layout timing, UI timers) — follow-up to #214 (#230)
#214 gave now_iso the browser's clock on wasm; the other clock helpers were
still stubs there: Insert Date's weekday was always Thursday, the PDF
creation date was left out, and the layout and UI millisecond clocks
returned 0, so browser AutoSave's timer never fired, the caret didn't blink
and status messages never cleared.

The engine's clock is now one now_unix() (std on native, js_sys::Date in the
browser, as #214 does) that now_iso and the weekday share; the pdf, layout
and ui-egui clocks read js_sys::Date the same way on wasm. Refs #188.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:20:56 -04:00
Valentin Klein 4c4dc14a54
Reject All removes paragraph breaks inserted with Track Changes on (#229) (#244)
Enter under Track Changes already recorded the new paragraph mark as an
insertion, but accepting or rejecting only cleared that flag, so Reject All
left the paragraphs split. Rejecting an inserted mark (or accepting a deleted
one) now joins the paragraph with the next one again; Accept All, Reject All,
Accept/Reject at the caret or over a selection, Next/Previous Change and the
Reviewing Pane all see paragraph marks.

Backspace at a paragraph start and Delete at a paragraph end now record a
tracked deletion of the mark (or remove a mark the same author inserted)
instead of only moving the caret; tracked deletions across paragraphs mark
the paragraph marks too.

DOCX reads and writes paragraph-mark revisions (w:ins / w:del in the
mark's w:rPr), and a tracked mark is drawn in its author's colour when
formatting marks are shown.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:19:52 -04:00
Valentin Klein cc64f3df2d
Add a System interface theme that follows the OS appearance (#115) (#249)
The interface theme is now a three-way choice: Use system setting,
Light or Dark, in File > Options > General and in a new Interface Theme
menu in View > Dark Mode. System follows the light/dark appearance egui
reports (winit on macOS, Windows and Linux; prefers-color-scheme on the
web) at launch and live, and falls back to light when the OS reports
none. egui's own theme is now pinned to ours, so an OS change no longer
swaps in egui's unstyled style.

Saved preferences migrate: the old "dark" switch becomes Dark or Light
and is never written again. Light stays the default for new installs.
New ui.theme command (light/dark/system); ui.dark still sets Light/Dark.
The dark page (View > Switch Modes) is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:18:50 -04:00
Valentin Klein e53b618584
Absolutely positioned VML text boxes float instead of sitting inline (#149) (#242)
A legacy VML shape (w:pict > v:shape / v:rect) with position:absolute in its
style was read as an inline object, so a tall text box in a header made the
header as tall as the box and pushed the body down the page.

The reader now maps the VML style to the same float model DrawingML anchors
use: left/margin-left and top/margin-top offsets, mso-position-*-relative
reference areas, mso-position-horizontal/vertical alignment, mso-wrap-distance-*
and w10:wrap (square, tight, through, topAndBottom; none or absent is in front
of the text, or behind it at a negative z-index). Shapes without
position:absolute stay inline. Saving writes the boxes as DrawingML anchors
with the same placement.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:18:07 -04:00
Valentin Klein 68364f75c8
Tests: shapes and pictures can be clicked, resized, dragged and nudged (#228)
Fixed by #46 but only text boxes were covered. These walk the editor's
path for shapes (rectangle, ellipse, star, arrow) and pictures: hit test,
select, resize with arrange.bounds, drag to float with Square wrap,
nudge, undo. Covers #142 and #82.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:17:06 -04:00
Valentin Klein 004286e4b7
Cross-references point at real bookmarks, and Update Field refreshes them (#227)
insert.crossReference wrote every field as `REF _Ref \h` with a literal
bookmark name and created no bookmark, so the fields resolved to nothing
when updated or opened in Word. Now:

- Headings, figures, tables and equations get a hidden `_Ref<number>`
  bookmark around the target (label and number only for show=number),
  reused by later references with the same extent.
- Bookmark targets use the bookmark's own name and show its text, not
  its name.
- references.updateFields refreshes REF (bookmark text) and PAGEREF
  (bookmark page).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 09:43:15 -04:00
Valentin Klein 6565a0f0ce
Headless: file.setAuthor takes a name; review.trackChanges lists its value param (#90) (#224)
file.setAuthor now documents {"name": string} in the command list, trims the
name, strips control characters, caps it at 255 characters and refuses a blank
name. wordcraft-cli run and headless mcp take --author NAME. review.trackChanges
lists {"value"?: bool} so scripts find the explicit, idempotent form.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 09:43:05 -04:00
Dejan Kocić 85da7e40e0
web: print straight to the system print dialog, not just export (#209)
* web: print straight to the system print dialog, not just export

The web build's Print backstage page only let you save a copy in another
format — there was no way to actually send the document to a printer short
of exporting a PDF and opening it yourself. Add a dedicated Print button
that exports to PDF in memory and opens it in a hidden iframe, then calls
the iframe's own print() once it has loaded, which is the standard way to
drive the browser's native print dialog on a PDF without a download or a
popup window.

- ui-egui: new Services::print hook (web: hand PDF bytes to the browser's
  print flow directly, no download).
- ui-egui backstage: "Print" button on the Print page when the host
  implements the hook; goes through wordcraft_engine::io::save_bytes
  directly rather than the existing save/download command path, since we
  want raw bytes in memory, not a file write.
- wordcraft-web: implement the hook (Blob -> object URL -> hidden iframe ->
  iframe.contentWindow.print() on load).

Desktop builds are unaffected (no Services::print set there, so the
button doesn't appear - file.print still opens the Print page as before).

* web print: make printing the ui.print command, dispatched by id

The backstage Print button now runs `ui.print` instead of exporting and
calling the host hook inline, so the control channel, MCP bridge and command
search reach the same path. Like ui.openFileDialog it is the one explicit
call that opens system UI; without a print hook it is an error. Status
strings get rows in all six catalogs.

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

* web print: drop the previous print's iframe and object URL

Each Print appended a hidden iframe holding a fresh blob URL of the whole
document's PDF and never released it, so copies piled up in the page. The
frame now has a fixed id; the next print revokes its URL and removes it
first, and a failure part-way through cleans up after itself.

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

* web print: test that Print needs the host's print hook

ui.print errors without Services::print and hands the hook a PDF with it;
a failing hook reports why. The Print page shows the Print button only when
the hook is set, and the button prints through the command.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:40:16 -04:00
Valentin Klein 919d74bee6
Shift+F3 runs Change Case again, not Find Previous (#186) (#221)
* Shift+F3 runs Change Case again, not Find Previous (#186)

edit.findPrevious and format.changeCase were both bound to Shift+F3.
Registry::by_shortcut returns the first match, so Find Previous won
and Change Case never ran from the keyboard. Find Previous keeps
Mod+Shift+G. A new test fails if any two commands share a shortcut.

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

* Contributors: credit vangel76 by their GitHub profile name

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:27:11 -04:00
Fabio Lussi 58ae1d5685
Use the browser clock for now_iso on wasm (comment and tracked-change dates) (#214)
Fixes #188.

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@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 09:27:02 -04:00
LloydNicholson 24388e2c24
macOS: open documents from Finder, the Dock and `open -a` (#27) (#223)
* macOS: open documents from Finder, the Dock and `open -a` (#27)

Double-clicking a document, Open With, dropping on the Dock icon and
`open -a WordCraft letter.docx` send the app a kAEOpenDocuments Apple
event instead of command-line paths. winit 0.30 doesn't handle it, so
AppKit answered "WordCraft cannot open files in the Word Document format".

Register the handler with the audited fmv-macos-events crate that PdfCraft
and PhotoCraft already use: it does the Objective-C interop (unsafe, which
this workspace forbids) behind a safe main-thread API and leaves winit's
delegate alone. Opened paths go through `file.open` like File > Open, so
unsaved changes are asked about first; a quit event closes the window,
which asks the same. The registration lives in main() until the event loop
returns, so the event that launched the app is caught too.

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

* macOS open: say in the status bar when a file can't be opened, and leave Backstage after opening

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

---------

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 09:26:52 -04:00
Saeed Azarehgar 5d86dec250
Right-to-left and Persian text support (bidi, shaping, RTL paragraphs, DOCX) (#207)
* feat(doc,docx): complex-script run properties and RTL OOXML round-trip

Model the formatting Word applies to Persian, Arabic and Hebrew text:
cs, font_cs, size_cs, bold_cs, italic_cs and lang_bidi on CharProps,
resolved with Word's fallbacks (ResolvedChar::complex). Size, bold and
italic pair with their complex-script values so a run's size or bold
also covers its Persian characters. Add doc::bidi script helpers, and
keep ZWNJ and Arabic marks inside words for word selection.

DOCX: read and write w:rFonts/@w:cs, w:szCs, w:bCs, w:iCs, w:cs and
w:lang/@w:bidi in schema order; stop using the cs font as the Latin font.
Tests round-trip Persian text with ZWNJ, digits and mixed Latin.

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

* feat(fonts): directional shaping and Persian font fallback

Add shape_run to shape a run in a known bidi direction (Arabic joining,
mirrored brackets). Prefer Persian-capable fonts (Vazirmatn, Noto Naskh/
Sans Arabic…) for right-to-left fallback, pick bold/italic fallback faces,
and map common Persian document fonts (B Nazanin, B Lotus, B Titr,
IRANSans…) to open substitutes.

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

* feat(layout): Unicode Bidirectional Algorithm and right-to-left paragraphs

Run UAX #9 per paragraph (unicode-bidi), shape each level run in its
direction, keep marks and ZWNJ with their letter and a word in one
fallback face. After line breaking, apply rules L1/L2 per line and give
each cluster a visual box; right-to-left paragraphs are laid out from
their start edge and mirrored, so indents, tabs, alignment, list labels,
drop caps, floats and borders sit on the right.

Caret, hit testing and selection use visual geometry (split selections,
caret at bidi boundaries, DocLayout::visual_step for arrow keys). Display
items carry glyph-to-text ranges, which the PDF writer now uses. Plain
left-to-right lines are unchanged. No hyphenation of RTL words.

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

* feat(engine,ui): paragraph direction commands and RTL editing

Add para.ltr / para.rtl (Word's text direction buttons) that change only
the reading order, and make the alignment buttons visual in right-to-left
paragraphs (para.align also takes start/end). Arrow keys move visually in
bidirectional text, Ctrl+arrows follow reading order, and Bold/Italic/
Size/Font cover complex-script text. Ribbon buttons with code-drawn
icons, a mirrored ruler for RTL paragraphs, translations, catalog,
parity and roadmap updates.

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

* chore(contributors): add Saeed Azarehgar to people.toml

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

* fix(engine): para.align with an explicit value sets it, without toggling

Only the alignment buttons toggle back to the start edge when pressed
again. para.align (used by the Paragraph dialog, scripts and agents) now
sets the requested alignment, as seen on the page, in each selected
paragraph's own direction. Previously pressing OK in the dialog with
Center or Right selected reset the paragraph to the start edge.

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

* feat(ui): Direction option in the Paragraph dialog

Add Word's Direction (Right-to-left / Left-to-right) to the Paragraph
dialog's General section. The dialog now shows alignment and Left/Right
indents as seen on the page, mapping them to the paragraph's logical
start/end values on OK, so an unchanged OK no longer flips a
right-to-left paragraph. OK logic moved to apply_paragraph, with tests;
ja/zh translations.

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

* test(layout): allow hanging trailing spaces in the RTL display test

A wrapped right-to-left line's trailing spaces hang past its end edge
(the left margin), as left-to-right lines hang them past the right. The
test failed whenever the loaded fallback fonts made a line end there;
it now checks only visible glyphs.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:26:41 -04:00
Lucio 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
Dr. Gled Guri 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
LloydNicholson 2982e75544
Tables: cell text takes its table style's formatting; derived table styles keep their base (#104)
Word's Table Grid style sets single spacing and no space after
(<w:pPr><w:spacing w:after="0" w:line="240" w:lineRule="auto"/></w:pPr>).
Cell text ignored it and used the document defaults (8 pt after, 1.08
lines in Word's default template), so rows came out ~60% taller than in
Word: an 80-row table took 3 pages instead of 2. A table style based on
Table Grid that adds red bold text showed no red, no bold and no borders.

- Style resolution has a table-style level (resolve_para_in,
  resolve_char_in), in ECMA-376 §17.7.2 order: document defaults, table
  style, paragraph style chain, character style, direct formatting. Any
  property the paragraph style chain sets, Normal included, beats the
  table style; the table style only replaces the defaults. (So a document
  whose Normal sets its own spacing keeps tall rows, as in Word.)
- StyleSheet::table_style merges a table style's based-on chain: pPr, rPr
  and the conditional parts, borders edge by edge. Layout used the first
  style in the chain with any table parts and dropped the rest.
- Layout passes the table style's paragraph and run formatting, with the
  cell's header row / total row / first column formatting on top, to each
  cell paragraph. Conditional run formatting now sits at the table-style
  level too, under the paragraph style, instead of over it.
- DOCX: the wholeTable region's pPr/rPr go to the style's own formatting
  and its cell shading to a new whole-table fill; tblPr shd and tblCellMar
  of a table style are read and written (as wholeTable shading and
  tblCellMar).

New tables use Table Grid, so tables made in WordCraft get Word's compact
rows too.

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 08:57:42 -04:00
Weeb Labs 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
Eduardo Soares 663904a2e2
Import Word 97-2003 .doc files (#28) (#145)
* D0+D1: open Word 97-2003 .doc — container, FIB, piece table text (#28)

New wordcraft-docbin crate (L2) reads the OLE2 container via the cfb
crate (MIT) and parses the FIB and piece table from the [MS-DOC]
specification: text pieces decode as UTF-16LE or Windows-1252 (table
moved to wordcraft-doc::encoding), subdocument CP ranges, and Word's
special characters map to the model's (tabs, breaks, hyphens). .doc/.dot
registered in OPEN_EXTS and the I/O dispatch. Encrypted and Word 6/95
files give clear errors; every length is checked and proptest
truncation/flips never panic.

* D2: .doc formatting — stylesheet, fonts, CHPX/PAPX FKPs, sprms (#28)

Sprm decoder with operand-size rules (incl. sprmTDefTable/sprmPChgTabs
exceptions and the Prm0 isprm table), bin tables + ChpxFkp/PapxFkp page
lookup, STSH styles (StdfBase layout validated against real Word files:
bchUpe == cbStd, sti 0 = Normal) and the SttbfFfn font table in both
extended and self-delimiting flavours. The CP walk now splits runs on
formatting changes and resolves paragraph styles: bold/italic/strike/
underline/caps/hidden, colours (Ico palette + COLORREF), half-point
sizes, super/subscript, fonts, highlights; alignment, indents, spacing,
keep options, outline levels. Unknown sprms are skipped leniently;
an env-gated corpus test opens real files (styles + text asserted).

* D3: .doc structure — sections, headers/footers, tables, lists (#28)

- Sections: PlcfSed/Sepx with 16-bit measurement sprms (sprmSXaPage & co),
  explicit sprmSBOrientation with a width/height fallback; even/odd/first
  header and footer stories from Plcfhdd become document parts.
- Tables: sprmTDefTable grids (operand length fixed to cb+1 per the spec),
  horizontal merges (sprmTMerge), vertical merges (sprmTVertMerge), cell
  shading (sprmTSetShd / sprmTDefTableShd80), cell widths, valign, row
  height/header/cant-split, and table borders (sprmTTableBorders80 Brc80s).
- Lists: PlfLst (LSTF + appended LVLs) and PlfLfo (LFO + LFOLVL overrides)
  resolved into the numbering model; sprmPIlvl/sprmPIlfo mark paragraphs.
- Combined fixture (intro + table + list + 2 sections + header) round-trips
  through Document JSON; hostile proptests now mutate the structured fixture.

* D4: .doc notes, fields, bookmarks and inline pictures (#28)

- Footnotes/endnotes: PlcffndRef/PlcfEndRef reference CPs paired with the
  PlcffndTxt/PlcfEndTxt story ranges; each story walks into a Footnote or
  Endnote part and the 0x02 anchor becomes a NoteRef object.
- Fields: 0x13/0x14/0x15 state machine in the walker — instruction text is
  hidden, the cached result kept; HYPERLINK fields keep their result as
  linked runs, everything else becomes one Field object.
- Bookmarks: SttbfBkmk names + Plcfbkf/Plcfbkl CPs become BookmarkStart/
  BookmarkEnd objects at their anchors.
- Pictures: sprmCPicLocation points into the Data stream; the PICF header
  gives the display size (dxaGoal/dyaGoal scaled by mx/my) and the payload
  is format-sniffed (PNG/JPEG/GIF/BMP; metafiles are dropped with a warn).
- Hostile proptests now truncate/flip a fixture with all of the above.

* D5: .doc import hardening, caps and roadmap (#28)

- Cap inline pictures at 5 000 per document.
- Engine test: .doc/.dot bytes dispatch through the Word 97-2003 reader
  (garbage must error, never panic or open empty).
- ROADMAP: Word 97-2003 import row (60% — text, formatting, styles,
  sections, headers/footers, tables, lists, notes, fields, bookmarks,
  raster pictures).

Real-corpus validation stays opt-in: WORDCRAFT_DOCBIN_FIXTURES=<dir>
cargo test -p wordcraft-docbin corpus_files_open.

* Corpus fixes: compressed-piece FC space, PAPX query point, none-level labels (#28)

Validated against two real files (kept locally under plan/word/fixtures,
gitignored): a 1 MB Lorem-ipsum sample and a 2003 styles/lists/table
sample.

- FKP bin tables and pages address the WordDocument stream in true byte
  offsets; a compressed piece stores its base fc doubled (byte = fc/2),
  so fc_of_cp now unwinds the doubling. Before, every PAPX/CHPX lookup in
  compressed (cp1252) files landed on the wrong entry — tables collapsed
  to one row, lists and heading styles were lost.
- The paragraph-properties query now uses the paragraph mark's own FC
  (inside the paragraph) instead of the FC after it, which picked up the
  next paragraph's FKP entry.
- Fixture papx entries re-keyed to paragraph-start CPs, the real rgfc
  convention.
- Counters::next_label returns nothing for NumFormat::None levels (Word's
  numFmt none shows no label; also fixes a latent docx-reader case).
- Symbol/Wingdings bullet chars map to Unicode glyphs (• ▪ ○ ●).

With this, both corpus files render headings, styles, nested and numbered
lists, the tables and the PAGE field correctly; metafile-wrapped images
and OLE objects stay dropped by policy (logged).

* docbin: bound the text walk by real text; dedupe and cap pictures (#28)

Hostile CP numbers no longer drive the work:
- header/footnote/endnote story ranges from the Table stream are clamped to their
  subdocument (ccpHdd/ccpFtn/ccpEdn) with checked base additions, and every walk stops at
  the last piece's CP; a story spanning [0, 4e9) used to push billions of U+FFFD;
- characters are found by binary search over the pieces (PieceTable::char_at), and an
  unreadable stretch (gap, truncated or out-of-stream piece) is skipped in one step rather
  than one U+FFFD per CP; the old per-character scan was O(chars x pieces);
- the piece CPs are made non-decreasing at parse, so inverted pieces are empty and binary
  search stays sound; surrogate pairs now decode;
- note references are looked up by binary search; the style's CharProps is borrowed, not
  cloned, per character;
- pictures are interned by fc_pic (5000 anchors on one picture share one media entry and
  one copy) and the bytes copied per document are capped at 256 MB.

Each fix has a crafted-bytes regression test; against the previous code the story,
piece-claim, last-piece and many-pieces tests hang (killed at 60 s) and the shared
picture test copies the picture once per anchor (80 GB).

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

* docbin: checked offset arithmetic in lists, sections and table rows (#28)

- list.rs: PlfLst/PlfLfo/LVL offsets (at + 2 + cLst*28, at + 4 + lfoMac*16, off += len,
  ...) saturate, which matters on 32-bit targets (wasm32) where fc + n can overflow usize;
  the LFO override path no longer indexes abstracts directly.
- sections.rs: the Sepx range (fc_sepx + 2 + cb) is checked; Plcfhdd is read only as far
  as the stories a capped section count can use (it used to reserve lcb/4 entries).
- table.rs: sprmTDyaRowHeight = i16::MIN negated in i16 (overflow panic in debug builds);
  now -(v as f32).

Regression tests: the row height test failed before (overflow panic with debug assertions, a 0 pt height without); the list test pins the saturating
offsets (it only overflowed on 32-bit targets).

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

* Format the merged OPEN_EXTS list

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:36:38 -04:00
Dr. Gled Guri 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
Dr. Gled Guri 43c0be5735
Zotero integration: cite while you write, with a Zotero tab (#189)
* 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>

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

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:59 -04:00
AiBizzApps 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
AiBizzApps c34668fefc
Envelopes, Labels, Finish & Merge: ask to save changes first (depends on #151) (#171)
* 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>

* Envelopes, Labels, Finish & Merge: ask to save changes first

The three commands replace the open document with a new one they build
(`set_document`), so the ribbon threw away unsaved work without asking:
`mailings.envelopes` left only the envelope address, and Undo couldn't
bring the document back because the history is reset with it.

The result is a new document, as in Word. In the app (`WordApp::run`)
they now ask "Do you want to save changes?" on a dirty document, exactly
like New: same prompt and pending command, dropped if the document is
replaced meanwhile; a clean document is replaced at once. A merge
written to a file (`mailings.finish` with a `path`) replaces nothing
and never asks. Scripts and agents (`execute`, control channel, MCP,
CLI) are not asked, as with New.

The engine keeps New's semantics: a new document id, no path, and a
fresh undo history. Keeping Undo across the swap was tried and dropped:
undo steps carry only content, so Undo -> Save As -> Redo put the
envelope under the saved file (Save and AutoSave then wrote it there),
and Undo brought the old document back under the new document's id, so
a pending prompt about the result could discard it. Tests cover both
sequences, and that neither Save nor AutoSave can write the result over
the original file.

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:26:12 -04:00
AiBizzApps 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
Brandon Thomas ed5a29f1b9 Fonts: load an installed family's faces all at once, one load at a time
load_cataloged added a family's files one by one, so a lookup on another thread could find the family half-loaded, resolve the closest style it saw (say Bold for Regular) and word::resolve cached that face for the rest of the process. Faces from a family's files are now added under one write lock, family loads are serialised (re-checking once the lock is held), and add_font no longer checks and inserts under separate locks.

This made layout tests flaky under parallel test threads (space_shrinking_is_only_for_justified_text failed 4/20 runs on main; 0/40 with this change). In the app it could give a document opened during the first font lookups a wrong face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:09:52 -04:00
Chiranjeevi U a70042a008
UI: keytips, mini-toolbar, ribbon overflow, macOS typography (#43)
* UI polish: keytips, mini-toolbar, ribbon overflow, macOS typography

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Address review: fix ribbon overflow activation, macOS Alt, token colors, cleanup

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Contributors: credit chiranjeevi-max in the About window

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Ribbon overflow: keep main's ‹ › edge cues (#129); keytip tab badges on main's tab strip

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

---------

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@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 07:55:20 -04:00
LloydNicholson 130c2dab40
HTML import: pictures referenced by a relative path are embedded (#98) (#113)
* HTML import: pictures referenced by a relative path are embedded (#98)

An img whose src was a path next to the HTML file was dropped, leaving
only its alt text; only data: URIs loaded. The HTML importer now takes
an optional image loader (the formats crate stays free of file access
and wasm-safe), and opening an HTML file from disk loads relative
sources from the file's folder. URLs and absolute paths are not
followed, and files that aren't pictures are ignored.

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

* HTML import: relative pictures stay in the folder, read only regular files, within a budget (#98)

Review follow-up for #113. Pictures named by a relative path are now
loaded by LocalImages:

- Only regular files are read. The path is checked before opening (a
  named pipe would block the open), the opened handle is checked again,
  and the read goes through take(limit + 1), so the per-picture limit
  holds even for files that report a length of 0, like /dev/zero.
- The path is canonicalized and must stay inside the HTML file's
  folder, so `..` and symlinks can't reach other files.
- Each file is read once and shared between references, and every
  reference counts against a 1 GB budget for the document, so one large
  picture referenced many times can't make the import read or copy
  without bound. Bytes read from oversized files count too.

The image loader now returns Arc<Vec<u8>>, and make_img takes either a
Vec or an Arc.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 07:50:09 -04:00
ssamstudio b64153f604
Layout: shrink spaces in justified lines like Word 2013+ (compatibility mode 15) (#109)
Word 2013 and later fit more text on a justified line by shrinking its
spaces by up to 20% of their width, so long justified paragraphs broke
differently in WordCraft (a one-page test document's summary took 8 lines instead of
Word's 7). Lines that only fit with shrunk spaces now get them, the
last line of a paragraph included; lines with tabs never shrink.

The behaviour depends on the document's compatibility mode, which is
now read from settings.xml (w:compatSetting compatibilityMode), kept in
Settings::compat_mode and written back, so saving an older file no
longer silently upgrades it to mode 15. A .docx that names no mode is
treated as Word 2007 (12); new documents are 15.

Co-authored-by: ssamstudio <267867645+ssamstudio@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 07:24:48 -04:00
ssamstudio aafc7d058f
Render: draw on-screen text like macOS, on whole pixels (#110)
* Render: draw on-screen text like macOS, on whole pixels

Pages looked lighter and thinner than in Word and other Mac apps:

- macOS thickens glyphs a little when it draws text on screen (font
  smoothing). RenderOptions::text_darkening strokes each outline by the
  amount macOS uses (from Pathfinder's measurements: 1.21% of the pixel
  size, 1.25x that vertically, capped at 0.3 px, none past 72 ppem). The
  editor turns it on for on-screen pages on macOS; PDF/PNG exports and
  other platforms are unchanged.
- Page textures were drawn at fractional positions after scrolling, so
  every glyph was resampled and blurred. They now land on whole device
  pixels at their own size.

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

* Merge follow-ups: page textures use #165's texel alignment; clippy as_chunks in a test

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

---------

Co-authored-by: ssamstudio <267867645+ssamstudio@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 07:24:26 -04:00
naxvog 7c951a5490
Layout: picture line heights, body top below the header, table row heights (#138)
* Layout: picture line heights, body top below the header, effect extents

Headers with a logo or rule picture put the body in the wrong place in
Word documents, for three reasons:

- A line holding only a picture was exactly as tall as the picture. Word
  keeps the run's font ascent and descent: the picture stands on the
  baseline and the line is the taller of the font's line and the picture
  plus the font's descent. Multiple line spacing scales the font's line
  height only, never the picture (a 144pt picture at 1.15 made a 165.6pt
  line; Word's is 146.4pt).
- The body started 6pt below a header that reaches past the top margin.
  Word starts it right where the header ends.
- wp:effectExtent (room Word keeps around a picture for its shadow or
  glow) was dropped. It is now kept on Float::effect, read and written,
  and inline pictures reserve it in their line and are drawn inside it.

The reader also takes w:lineRule (and w:hRule) case-insensitively, as
Word does: `atleast` was read as multiple spacing.

Measured against the Word reference PDFs of docxide-pdf's corpus (365
documents, same open fonts for every run): mean SSIM 39.3% -> 42.1%,
documents passing SSIM 37 -> 51, mean ink overlap 19.5% -> 22.3%, page
count right 301 -> 304; 52 documents better, 6 worse (one header built
from nested tables had its own height error hidden by the old 6pt gap).

Tests: picture lines are the picture plus the font's descent under 1.15
spacing; the body starts where a tall header ends; effect extents grow
the picture's line box and the picture is drawn inside them; effect
extents round-trip through .docx; `atleast` and `EXACT` line rules read.

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

* Layout: table rows keep their cell margins and border bands like Word

Word sizes a table row from the inside of its borders out: the cell's
text starts below the top border and the top margin, and an at-least row
height gets the cells' top and bottom margins and border widths on top
(a 30pt row with 5pt margins and 0.5pt borders steps 40.5pt). We drew
the text at the top margin and took an at-least height as the whole row,
so rows came out short and tables, and headers built from tables, ended
early: a form's header table ended 6pt above where Word starts the body.

Each row now reserves its cells' top border width above their text (the
last row also the bottom border below it), and an at-least height adds
the tallest margins plus border bands in the row. Exact heights are
unchanged.

Measured against the Word reference PDFs of docxide-pdf's corpus (365
documents, same open fonts), on top of the previous commit: mean SSIM
42.1% -> 42.8%, documents passing SSIM 51 -> 54, mean ink overlap 22.3%
-> 23.3%, mean line-break agreement 63.0% -> 63.5%; 42 documents better,
28 worse, mostly by a few points where an earlier offset had cancelled
the short rows.

Test: two 30pt at-least rows with 5pt margins and 0.5pt borders step
40.5pt (was 36.3), with the first cell's text 5.5pt below the table top.

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

* Layout: note the known limit of the picture-line rule

The space Word leaves below a line of only pictures is less than the
font's full descent we give it; two rules tried against docxide-pdf's
Word references (no descent, only the text's descent) each fixed some
documents and broke others. A shortcut: comment records the measured
cases so the rule isn't changed again without Word probes.

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

---------

Co-authored-by: Oliver Zieschang <4963183+naxvog@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 07:24:21 -04:00
Dmytro 0811a80f31
Add Ukrainian (uk) interface translation (#91)
* i18n #85: add Ukrainian interface translation

Translate the existing 771-entry interface catalog and register Ukrainian
for locale detection and persisted selection. Check full catalog coverage,
count-neutral labels, preference persistence, and bundled Ukrainian glyph
coverage. Validate the translation against Ukrainian dictionaries.

Co-authored-by: Codex <noreply@openai.com>

* i18n: Ukrainian rows for strings added on main

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

* i18n: Ukrainian label for Character Border (lands after #60)

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

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:18:14 -04:00
cyfung1031 6a25dc0da9
View: align current page rasters to screen pixels (#165)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 07:18:08 -04:00
999sian da37b7987e
Character border (Home > Font > Character Border) (#60)
* Character border (format.border): w:bdr run property, toggle command, boxed rendering

* Character border: Home > Font ribbon button and icon

* Character border: address review (plain notes), translate ribbon label, fix flaky layout test and clippy nonminimal_bool

* Tests: merge the fixture imports after merging main

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

* i18n: Spanish and Brazilian Portuguese labels for Character Border

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

---------

Co-authored-by: WordCraft agent <agent@localhost>
Co-authored-by: 999sian <999sian@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 07:18:03 -04:00
naxvog 6e73fe40e3
Tables: Word 2013 table edge, and floating tables (w:tblpPr) (#137)
* Layout: place floating pictures and shapes the way Word does

Pictures and shapes anchored in a .docx often landed in the wrong place:
the reader folded every margin-relative position into "margin", ignored
<wp:align>, and kept only distL as the distance from text. Floats in
headers, footers and table cells were not drawn at all.

Document model (doc):
- Anchor gains the left/right/top/bottom/inside/outside margin areas,
  the anchor character and the anchor line
- Float gains h_align/v_align (FloatAlign: start, center, end, inside,
  outside), which take precedence over the offsets, and separate
  dist_top/dist_bottom (dist stays the left/right distance)

DOCX (docx): read and write wp:align, every relativeFrom value and
distT/distB/distL/distR.

Layout (layout):
- float_rect positions against each reference area, with alignment
- floats in headers, footers, table cells and text boxes are placed,
  drawn (behind-text ones under the text) and wrapped around; headers
  and footers get a page frame so page-relative floats land in page
  coordinates, also when the header's height is measured for the body
  top
- a paragraph's first line pushed below a top-and-bottom float is drawn
  (and hit-tested) where it was laid out
- floats follow their paragraph to a new column or page, and are placed
  at the paragraph's actual top (space before counted once, contextual
  spacing respected)
- wrap areas use each distance as given; the old 9pt minimum is gone, so
  a float with distance 0 now touches its text as in Word
- a list item's label is counted once (Ctx::next_label) and passed into
  every layout of its paragraph (Ctx::para_labelled), so laying it out
  again around floats doesn't advance the list counters twice; a
  paragraph whose top moved is only laid out again when the page has
  something to wrap around

Engine: switching an inline object to a wrapping style gives it Word's
default 9pt side distance; the position presets clear any alignment.

Approximations: inside/outside margins and alignments ignore mirrored
margins (treated as left/right); character- and line-relative positions
fall back to the column and the paragraph top.

Tests: docx fixture and round trip for alignment, reference areas and
distances; layout tests for alignment, the pushed-down first line,
floats moving to page two (with space before), header and cell floats,
header floats not changing the body top, hostile distances, and list
items counted once when laid out again (contextual spacing, page break
before, beside a float in a table cell).

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

* Tables: place a table's edge by the document's Word compatibility mode

Word 2013 and later (compatibilityMode 15) put a left-aligned table's
border at the margin plus its indent, moved in by half the first cell's
left border; earlier modes line the first cell's text up with the margin
instead, the border one cell margin further out. We always did the
latter, so in Word 2013+ documents (43% of docxide-pdf's Word reference
corpus) every table and its text sat a cell margin (usually 5.4pt) too
far left.

Settings::compat_mode keeps the document's mode: read from
w:compat/w:compatSetting, 12 when a document has no settings or doesn't
state one (as Word treats it), 15 for new documents, and written back
instead of a fixed 15. The legacy shift now uses the first cell's own
left margin.

Measured against docxide-pdf's corpus (365 documents, same open fonts):
mean SSIM 40.4% -> 42.1%, documents passing SSIM 40 -> 50, passing ink
overlap 142 -> 154; 71 documents better, 6 worse (tables nested in a
cell whose text Word puts at the outer cell's edge, which docxide-pdf
doesn't match either).

Tests: a mode-15 table's text sits a cell margin and half a border
inside the margin, a mode-12 table's at it; the mode round-trips
through .docx and defaults to 12 without settings.

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

* Layout: floating tables (w:tblpPr) stand at their position, text wraps

Floating tables were read as ordinary tables: laid out in the text flow
with no regard to their position, and the text after them waited below
instead of running beside them. 71 of the 382 documents in docxide-pdf's
Word reference corpus have them, typically forms anchored to the text
and the margin.

TableProps::float (TableFloat) keeps w:tblpPr and w:tblOverlap: what x
and y are measured from (column, margin or page; text, margin or page),
offsets or alignments, distances from text and whether it may overlap
other floating tables. The .docx reader and writer carry it.

A floating table at the top level of the body is placed at its position
(tblpY below the text where it stands, by default) and takes no room in
the flow; its area plus its distances from text joins the page's wrap
areas, so the following text runs beside it or below it. One that may
not overlap moves below the floating tables in its way, keeping (in Word
2013+ layout) its distance from the text's left edge; in earlier modes
an offset places the first cell's text, as an indent does. A table in
the flow starts below the floating objects in its way. Floating tables
inside table cells and headers are still laid out in their flow.

Measured against docxide-pdf's corpus (365 documents, same open fonts),
on top of the compatibility-mode commit: mean SSIM 42.1% -> 43.9%, mean
line-break agreement 63.5% -> 66.6%, documents passing SSIM 50 -> 55,
page count right 303 -> 305; 33 documents better, 4 worse by at most
3.1 points.

Tests: a full-width and a narrow floating table that may not overlap
stack, the narrow one keeping 9pt from the text edge, and the next
paragraph runs beside it; floating table positions round-trip through
.docx.

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

* Layout: a floating table taller than a page runs across pages

A floating table was always placed whole at its position, so one taller
than the page's body ran past the bottom margin and its rows were lost:
a Bulgarian programme whose whole body is one floating table came out as
3 pages instead of Word's 6.

Such a table now flows across pages like a table in the text (rows
splitting and header rows repeating as usual), from the floating table's
own left edge; the position is worked out by float_table_x, which the
whole-table placement shares.

In docxide-pdf's Word reference corpus that document has Word's page
count again with all its rows; nothing else changes.

Test: an 80-row floating table runs onto a second page, every row drawn
above the bottom margin from the table's left edge (was one page).

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

* Tidy floating-table code: one alignment helper, named steps, shared mappings

No change in behaviour; makes the floating-table code easier to read.

- FloatAlign::from_ooxml/ooxml (added with floating pictures) replace the
  copies of the same mapping in the w:tblpPr reader and writer, and
  TableFloat::dist_from_text the copies of its clamping.
- place_floating_table and float_table_x place by offset or alignment with
  align_in, like floating pictures do, instead of their own copies.
- The overlap rule uses Rect::inset/intersects and names its condition
  (keeps_text_distance) instead of a one-line chain of comparisons.
- below_floats names the step that starts a table in the flow below the
  floating objects in its way.
- The floating-table functions move next to place_table, which calls them,
  from after `mod tests;` at the end of the file.

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

---------

Co-authored-by: Oliver Zieschang <4963183+naxvog@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 07:08:20 -04:00
naxvog c7efead47b
Layout: place floating pictures and shapes the way Word does (#136)
Pictures and shapes anchored in a .docx often landed in the wrong place:
the reader folded every margin-relative position into "margin", ignored
<wp:align>, and kept only distL as the distance from text. Floats in
headers, footers and table cells were not drawn at all.

Document model (doc):
- Anchor gains the left/right/top/bottom/inside/outside margin areas,
  the anchor character and the anchor line
- Float gains h_align/v_align (FloatAlign: start, center, end, inside,
  outside), which take precedence over the offsets, and separate
  dist_top/dist_bottom (dist stays the left/right distance)

DOCX (docx): read and write wp:align, every relativeFrom value and
distT/distB/distL/distR.

Layout (layout):
- float_rect positions against each reference area, with alignment
- floats in headers, footers, table cells and text boxes are placed,
  drawn (behind-text ones under the text) and wrapped around; headers
  and footers get a page frame so page-relative floats land in page
  coordinates, also when the header's height is measured for the body
  top
- a paragraph's first line pushed below a top-and-bottom float is drawn
  (and hit-tested) where it was laid out
- floats follow their paragraph to a new column or page, and are placed
  at the paragraph's actual top (space before counted once, contextual
  spacing respected)
- wrap areas use each distance as given; the old 9pt minimum is gone, so
  a float with distance 0 now touches its text as in Word
- a list item's label is counted once (Ctx::next_label) and passed into
  every layout of its paragraph (Ctx::para_labelled), so laying it out
  again around floats doesn't advance the list counters twice; a
  paragraph whose top moved is only laid out again when the page has
  something to wrap around

Engine: switching an inline object to a wrapping style gives it Word's
default 9pt side distance; the position presets clear any alignment.

Approximations: inside/outside margins and alignments ignore mirrored
margins (treated as left/right); character- and line-relative positions
fall back to the column and the paragraph top.

Tests: docx fixture and round trip for alignment, reference areas and
distances; layout tests for alignment, the pushed-down first line,
floats moving to page two (with space before), header and cell floats,
header floats not changing the body top, hostile distances, and list
items counted once when laid out again (contextual spacing, page break
before, beside a float in a table cell).

Co-authored-by: Oliver Zieschang <4963183+naxvog@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 07:07:36 -04:00
Geronimo Castaño 7d592ce19a
Add Spanish interface language, and make ribbon overflow visible (#129)
* Spanish interface language (es)

Adds a Spanish catalog beside the Chinese and Japanese ones and registers
it in LANGUAGES, so it appears in File ▸ Options ▸ Interface language and
follows Spanish system locales (es-ES, es-MX, es-419 …).

Neutral Spanish: tú for sentences, infinitives for commands, sentence case.
Written from the English meaning; no Microsoft Word localisation was
consulted or copied. Covers the 771 keys of the existing catalogs, plus the
two ribbon-edge tooltips added in the next commit.

The bundled-catalog and every-command-translated tests pass.

* Ribbon: show and scroll past the edge when a tab overflows

The ribbon scrolls horizontally, but its scrollbar is hidden, so groups that
run past the window edge were clipped with no sign they existed. Spanish
labels make the Review and Home tabs wider than the window at 1440 pt.

Overflowing rows now show ‹ and › chevrons at the edges. Clicking one scrolls
the row by 160 pt; the chevron disappears at each end. The tooltips are in
the Spanish catalog and show in English in other languages until translated.

* i18n: Spanish rows for strings added on main; ribbon scroll labels in every catalog

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:07:30 -04:00
DWAA1660 02e2a51bb4
LaTeX (.tex) import and export (#7) (#11)
* LaTeX (.tex) import and export (#7)

Open and save LaTeX source as a document format, next to Markdown and
HTML. Export writes a standalone article (fontenc, ulem, xcolor,
multirow, amsmath, hyperref, loaded only when used): unnumbered sections,
itemize/enumerate, quote, center/flushright, verbatim, tabular with the
document's column widths and merged cells, equations as $..$ / \[..\],
character formatting, colours and links; metadata goes to \hypersetup.
Import reads the everyday subset: preamble \title/\author, sectioning
(\part/\chapter shift levels), paragraphs, lists with labels, quote,
alignment environments, verbatim-like environments, tabular with
\multicolumn/\multirow/\cellcolor and p{} widths, inline and display
math, formatting commands and declarations, colours, links, refs,
citations, footnotes, accents, TeX ligatures and special characters.
Unknown commands keep their arguments' text; layout and definition
commands are dropped with their arguments. Nesting is depth-limited and
input is capped, so hostile files can't overflow the stack.

The flow model gains an Equation inline so equations survive LaTeX round
trips; the other text formats write their linear text as before.

Wired into open/save dispatch, the file dialog filter, Backstage Export,
the web app, CLI/MCP help, docs and the Linux desktop entry.

Fixes #7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FczntrDNYd6DupyBdpbL5

* #7: bound LaTeX column specs and equation export (review)

- spec_widths: `*{n}{…}` repeats recurse with a depth argument and stop at
  MAX_NEST, so a deeply nested column spec no longer overflows the stack or
  exhausts memory. Column counts saturate and widths stop at MAX_COLS.
- Column specs read from the source are capped at MAX_SPEC (4096) chars.
- balanced() looks groups up in a table of closers built in one stack pass,
  so linear_to_latex no longer rescans to the end of the string for every
  unmatched `(` (quadratic: 50k took seconds). spec_widths uses it too.
- env_name restores the position when `\begin{` / `\end{` has no `}`
  within 64 chars, so the `{` is no longer lost.
- Regression tests: the 100k-deep `*{1}{` spec and a 200k-`(` equation in
  deep_nesting_is_bounded; unclosed_begin_keeps_its_brace and
  spec_widths_repeats_are_bounded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyVbT6Vv4BwWZ4hY8973qD

* #7: linear-time labels, brackets and \begin{; no commands from the document in exports

Applies @HaringIbon's review suggestions:

1. Parser::anchor keeps at most MAX_ANCHOR_RUN (64) bookmarks in a row with
   no text between them; text() looked back past every one, so a file of
   labels imported in quadratic time. Labels separated by text are all kept.
2. linear_to_latex_at checks for the `/` of a fraction before copying the
   group out, so deeply nested matched brackets export in linear time.
3. env_name searches only the next 65 bytes for `}`, so a run of unclosed
   `\begin{` imports in linear time.
4. An equation's `\name` is written as a command only when it is a known
   symbol, function or font/spacing command (MATH_COMMANDS); anything else
   becomes `\backslash name`. Two carets are never written side by side in
   equations or anchor names, since TeX reads `^^5c` as a backslash. Compiling
   an export can no longer `\input` files or `\write18` named by the document.

Regression tests: three cases added to deep_nesting_is_bounded, plus
latex_export_carries_no_commands_from_the_document and
latex_labels_in_a_row_are_read_in_linear_time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyVbT6Vv4BwWZ4hY8973qD

* Merge follow-ups: FTable.borderless (from #102) in the LaTeX importer; rustfmt

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:57:01 -04:00
naxvog bb88278b98
PDF: put the shown text of fields and note numbers in the text layer (#133)
* PDF: fields and note numbers put their shown text in the text layer

A field, a note reference or an equation is shaped as one cluster that
stands for the object's placeholder character (U+FFFC) in the paragraph
text. The display list took a glyph run's text from the paragraph, so a
footer's "Page 1 of 5" went into the PDF's text layer as "Page  of ":
copying, search and screen readers got the placeholder. 76 of the 365
PDFs written from docxide-pdf's Word reference corpus had it; Word's
have none.

ParaLayout::shown keeps, by cluster index, the text such a cluster
draws (filled by Builder::shape_atomic), and the display list takes that
text for those clusters. The drawing doesn't change.

Now none of the 365 PDFs carries the placeholder; mean line-break
agreement with Word 62.9% -> 63.4% (the scorer reads the text layer).

Test: a footer's PAGE and NUMPAGES fields read "Page 1 of 2" and
"Page 2 of 2" in the exported PDF's text (was "Page  of ").

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

* Tests: a REF field's result is extractable PDF text (from #112 by @LloydNicholson)

Co-Authored-By: LloydNicholson <LloydNicholson@users.noreply.github.com>

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

---------

Co-authored-by: Oliver Zieschang <4963183+naxvog@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 06:56:55 -04:00
LloydNicholson e60df31183
M6: footnotes and endnotes from Word files show their number (#103)
Every note Word writes starts with a w:footnoteRef / w:endnoteRef mark,
the note's own number:

  <w:footnote w:id="1"><w:p><w:r><w:rPr><w:rStyle w:val="FootnoteReference"/></w:rPr><w:footnoteRef/></w:r><w:r><w:t> First footnote text.</w:t></w:r></w:p></w:footnote>

The reader skipped the mark, so imported notes read " First footnote
text." with no number. Notes made by references.footnote already start
with a NoteRef to the note itself, which layout numbers like the body's
reference (numFmt from footnotePr/endnotePr included) and the writer turns
back into the mark (#53).

- read_notes reserves the note's part id before reading its text, and the
  mark becomes a NoteRef to that note, with the mark run's formatting. A
  mark outside a note, or of the other kind (w:endnoteRef in a footnote),
  is ignored.
- The writer still writes the mark exactly once per note. When the first
  paragraph holds the note's reference to itself, the mark goes there with
  that reference's formatting (so rStyle FootnoteReference survives the
  save) instead of a plain superscript run at the start.

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:56:50 -04:00