Serbian is officially digraphic — Cyrillic and Latin are both in
everyday use, with Cyrillic the default per BCP 47 when no script is
given. Adds both as separate catalogs, same as the existing
Simplified/Traditional Chinese split handles script rather than
region:
- sr-latn.tsv: the actual translation, written clean-room from the
plain meaning of each English label (never from Microsoft Word's or
another product's Serbian localisation), covering the full existing
catalog (874 entries, same key set as zh-hans.tsv).
- sr.tsv: mechanically transliterated from sr-latn.tsv by a small
script (not committed — a one-off; the mapping is the standard
digraph-aware Serbian Latin→Cyrillic table). Brand/product/format
names (WordCraft, PDF, Discord, Zotero, SmartArt, file extensions…)
and `{placeholders}` are protected from transliteration, same names
that stay in Latin letters in every other catalog. Hand-editing this
file isn't the workflow — change sr-latn.tsv and re-transliterate.
- mod.rs: registers `sr` (Cyrillic) and `sr-latn`. No special-casing
needed in `candidates()` for the script split, unlike Chinese's
region-implies-script fallback — `sr-Latn-RS` already produces
`sr-latn` as a candidate before falling back to plain `sr`, and a
bare `sr`/`sr-RS` (no script tag, the common case) resolves straight
to `sr` since that's the registered default-script code.
- tests.rs: locale-matching coverage for both scripts, full-catalog
parity with the reference language, and bundled-font glyph coverage
for both Cyrillic and Serbian Latin's extended letters (š č ć ž đ),
mirroring the existing Ukrainian/Portuguese tests.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
select.column (Mod+Shift+F8) selects the same x range on a run of lines,
from two positions, two page points (Alt+drag), or by toggling column
mode where the arrow keys extend the block. Copy/Cut give one line per
row, Delete/Backspace remove only the block, character formatting and
Change Case apply per row, typing/paste replace the block. Any ordinary
caret move drops it. F8 keys now reach their shortcuts in the UI.
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
styles.inspector shows/hides the Style Inspector pane and returns the
paragraph style, character style and the direct formatting that differs
from each; styles.inspect is the pure query; styles.inspectorClear
resets one level (paragraphStyle, paragraphFormatting, characterStyle,
characterFormatting) as a single undo step. The pane opens from the
Styles pane, with a clear button per level. Parity floor raised to 87%.
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
New edit.pasteSpecial command ({"as"?, "text"?, "html"?, "rtf"?}). Without "as" it
lists the clipboard's formats and asks the front end for the dialog; with "as" it pastes in
that format. HTML/RTF go through the wordcraft-formats importers, with their lists, styles and
images merged into the document under fresh ids. Respects Track Changes (now also inside pasted
tables) and caps payloads at 16 MB. UI: Paste Special dialog (radio list + result hint),
Mod+Alt+V and Paste menu entry; strings in all four catalogs. Parity 355/405, floor 87.5%.
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Home › Editing › Find › Advanced Find (edit.advancedFind) opens the Find
and Replace dialog. With params it returns every match in the body, or
only inside the selection ("in": "selection"), and "highlight": true
turns on Reading Highlight, which marks all matches on the page and
follows edits until it's turned off. The ribbon's Find button becomes a
menu with Find, Advanced Find and Go To; the Find dialog gets a Reading
Highlight toggle. Parity 355/405.
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
New Table Style and Modify Table Style (Table Design > Table Styles) make
real table styles in the document's style sheet: name, based on, and
formatting for the whole table, the header row and banded rows (borders,
fill, bold, font colour). The table at the caret takes the new style, it
shows in the gallery, and other tools that read table styles by name
(pandoc's custom-style, for one) see it.
- engine: table.newStyle / table.modifyStyle with documented params
(regions take borders, fill, bold, italic, colour, size, font; bandSize;
para); table.style only accepts table styles now.
- doc/layout: table styles gain header-row and row-band cell borders,
row-band character formatting and rows per band, applied by layout.
- docx: tblStyleRowBandSize and firstRow/band1Horz tcBorders and rPr are
read and written, so custom styles round-trip.
- ui: a compact dialog from the Styles menu next to the gallery; table
style tiles redraw when a style changes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Follow-ups from integrating #233 and #239: every tl! string now has a row in every
catalog, and a test pins that loading a face by its legacy family name drops the
fallback cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* 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>
* 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>
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>
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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* M2: styles.apply (Apply Styles) command; parity 355/405
Add Home > Styles > Apply Styles as a pure command. With a style name
or id it runs para.style (one undo step); with no params it opens the
styles pane and returns {"pane": true}. Non-string, empty or
whitespace style values return CmdError::Params. Move Mod+Shift+S
from para.style to styles.apply. Add 8 tests, ja/zh labels, and
regenerate docs/parity.md.
* i18n: pt-BR label for Apply Styles
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>
View › Switch Modes (and File › Options › Dark page) shows the page as dark
grey (80% black, #333333) with every document colour's lightness inverted
and its hue kept, so black text turns white and dark-blue headings light
blue. Pictures are untouched and the caret turns white. Screen only: saving,
printing and PDFs are unchanged. The setting is remembered between runs.
Refs #185.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The control server answered a line that wasn't JSON with an error and kept
reading, so an HTTP request to the port (for example a web page's fetch to
127.0.0.1) could carry a command such as file.saveAs in its body. It now
replies once and closes the connection on the first bad line; HTTP request
lines never parse, so nothing after them runs.
Ctrl/Cmd+click on a hyperlink passed any URL to the system. It now follows
only http, https and mailto links, and shows a status message otherwise, so
a document can't launch file: paths or custom-scheme handlers.
Tests cover JSON requests, closing on bad JSON, an HTTP POST carrying a
command, and the link allow-list. The control protocol docs note the
closing behaviour.
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
WordCraft's interface can now be shown in Simplified Chinese, Traditional
Chinese or Japanese as well as English. The new `language` setting is
`auto` by default: the UI follows the operating system's preferred
languages (Windows display language, macOS preferred languages,
LANG/LC_* on Unix, via sys-locale; WORDCRAFT_LANGUAGE overrides) when
there is a catalog for one of them, and English otherwise. File > Options
> Interface language picks one by hand, and the `ui.language` command does
the same for scripts and agents. The setting persists with the other UI
preferences.
The i18n module is PdfCraft's (catalog format, parser, locale matching),
trimmed to plain entries. English strings in the code are the keys; the
ribbon helpers (group, big, small, split, menu_button, menu items,
tooltips and ribbon locations), tabs, Backstage, Options, dialogs, panes,
the status bar, the context menu and command search translate at draw
time. Command ids, documents, the CLI, MCP and the control channel stay
English. Catalogs hold ~780 entries per language, written clean-room from
the English meaning.
Chinese interfaces put the craft-fonts Chinese face before the Japanese
one so a line never mixes glyph styles; fonts are reinstalled when the
language changes.
Fixes#8.
Claude-Session: https://claude.ai/code/session_019FczntrDNYd6DupyBdpbL5
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <shared2@storyteller.ai>