Commit Graph
25 Commits
Author SHA1 Message Date
vangel76andClaude Opus 5.5 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
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 KleinandClaude Opus 5.5 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 KleinandClaude Opus 5.5 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
Saeed AzarehgarandClaude Opus 5.5 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
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
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
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
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
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
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
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
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
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
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
236208bc0d DOCX: a style's East Asian / complex-script font no longer replaces the Latin font (#93) (#111)
A Normal style whose rFonts has only w:eastAsia and w:cs (common in
Word-made files) set the run font to that face, so text rendered in
Times New Roman instead of the docDefaults font. Those attributes now
stand in for the Latin font only when the run is hinted as that script
(w:hint), and otherwise the Latin font is inherited, as in Word.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:56:45 -04:00
8907b589ac Tables: draw style borders when w:tblBorders is empty (#143) (#148)
* Ignore the .superpowers/ agent scratch directory

* doc: Borders::overlay merges border sets per side (#143)

* Tables: draw style borders when w:tblBorders is empty (#143)

* docx: fixture pinning empty tblBorders/tcBorders parse contract (#143)

* Tables: border presets mask cleared sides with nil over the style (#143)

* Drop a stray tool entry from .gitignore

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 06:35:04 -04:00
AiBizzAppsandClaude Opus 5.5 a45c8a41de DOCX: keep a .docm's macros and signatures on save; right content types for .docm/.dotx/.dotm (#172)
* DOCX: keep a .docm's macros on save; write .docm/.dotx/.dotm content types

Saving a .docm deleted its VBA project and wrote the plain-docx main
content type under the .docm name, which Word rejects; .dotx was written
as a document, and .dotm could be opened but not saved. io_ext sent every
Word extension to wordcraft_docx::write, which always emitted a .docx
package, and the reader never kept the VBA parts.

- wordcraft_docx::Flavor (docx/docm/dotx/dotm) picks the main part's
  content type; write_as(doc, flavor) writes it, write() stays .docx.
- The reader keeps word/vbaProject.bin (and the vbaData.xml it relates
  to) as opaque bytes in Document::passthrough, the mechanism custom.xml
  already uses. Macro content is never parsed or run. The writer emits
  them, with their relationships and content types, only for .docm/.dotm;
  a .docx/.dotx leaves them out cleanly (as Word does) and the open
  document still holds them for a later Save As .docm.
- Empty projects, missing or malformed VBA data, duplicate or odd
  relationships never panic and never stop the document from opening;
  part sizes stay under the existing package limits.
- .dotm joins the open/save lists, and saving to .docm/.dotx/.dotm binds
  the session to that file like .docx.

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

* DOCX: carry vbaData.xml as opaque bytes, never parse it

The first cut gated word/vbaData.xml through xml::parse on read and on
write. That parser rejects UTF-16 (a valid XML encoding), so a
UTF-16 vbaData.xml was silently dropped and the saved .docm kept
vbaProject.bin without its VBA data; and it is lenient (no end-name
check, auto-closes at EOF), so malformed data passed the gate anyway.
WordCraft is a passthrough for macro parts: it should emit exactly what
the user's file had. Now vbaProject.bin and vbaData.xml are both carried
byte for byte (bounded by the reader's existing size limits); only the
relationship part is read, to find the VBA data. A relationship to a
missing VBA data part keeps the project and writes no dangling link.

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

* DOCX: read UTF-16 XML parts instead of dropping them

xml::parse rejected any part with a UTF-16 BOM, and Package::rels
swallowed that error as "no relationships". A UTF-16
word/_rels/vbaProject.bin.rels (valid per OPC) therefore hid
vbaData.xml, and saving the .docm dropped it and its relationship.
parse now transcodes UTF-16 LE/BE with a BOM to UTF-8 (lossy, bounded
at 1.5x, never panics on odd lengths or unpaired surrogates), so every
part benefits. Unreadable relationship parts are now logged rather than
silently treated as empty; the macro project is still kept.

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

* Web Save keeps the opened Word format (.docm stays .docm)

Browser Save (file.save without a path) always downloaded
<stem>.docx, so an opened .docm/.dotm was written as a plain .docx and
lost its macros on an ordinary Save. Save and the Save As suggestion
now default to the opened file's extension when it is a Word package
(.docx/.docm/.dotx/.dotm, via engine io::is_word_package), else .docx.
Macros are only dropped on an explicit conversion to a macro-free name.

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

* DOCX: carry every part a VBA project relates to, so signatures survive

A signed .docm/.dotm lost its VBA signature on save: the reader kept
only vbaProject.bin and vbaData.xml, and the writer rebuilt
vbaProject.bin.rels with the wordVbaData relationship alone. Under a
"signed macros only" policy the macros then stop running, although the
project bytes (which the signature covers) were unchanged.

Generalised instead of special-casing signature types: the reader now
keeps every internal part vbaProject.bin.rels points to (VBA data, the
legacy/Agile/V3 signatures, anything else) as opaque bytes, with its
relationship type and its content type from [Content_Types].xml, in a
small manifest in Document::passthrough. .docm/.dotm re-emit them all;
.docx/.dotx still drop the whole VBA set. Bounded at 64 parts; targets
outside word/, relationship parts, the project itself, missing parts
and parts the writer produces itself are skipped, never shadowed.

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

* DOCX: match VBA part names case-insensitively; keep colon names relative

Two edge cases in carrying a VBA project's related parts:
- The reader's word/ prefix check was case-sensitive, so a relationship
  to /WORD/vbaData.xml (the same part under OPC's case-insensitive part
  names, and found by Package::get) was skipped and lost on save. The
  directory is now matched case-insensitively and normalised to word/,
  the name the writer uses.
- Stripping word/ from /word/sig:legacy.bin gave Target="sig:legacy.bin",
  which parses as a URI scheme rather than a package part. A target
  whose first segment has a colon is now written as "./sig:legacy.bin";
  ordinary names keep the plain form Word already accepts.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:34:52 -04:00
3fcc1e2694 Lists: honour a list's own level definitions (w:lvlOverride/w:lvl) (#134)
A w:num can redefine any of its abstract list's levels with a whole
w:lvl inside w:lvlOverride. We kept only the start value from it and
numbered with the abstract list's levels, so documents that rely on it
showed the wrong labels: a construction specification (SpecLink) read
"A. PART 1 GENERAL" and "I. SECTION INCLUDES" where Word shows "PART 1
GENERAL" and "1.01 SECTION INCLUDES". Saving such a document also
dropped the definitions, so Word showed the wrong labels afterwards.

Num::level_overrides keeps them; Numbering::level and the list counters
prefer them over the abstract list's levels; the .docx reader reads them
with the abstract levels' parser and the writer writes them back (one
w:lvlOverride per level with its startOverride and/or lvl).

In docxide-pdf's Word reference corpus 6 of 382 documents carry such
definitions; the specification's SSIM goes 13% -> 41%, the others
restate their abstract lists and are unchanged.

Tests: counters use a list's own levels ("" and "%2.%3" in decimalZero
give "1.01", "1.02", "2.01") and fall back to the abstract list for the
rest; level overrides round-trip through .docx beside start overrides.

Co-authored-by: Oliver Zieschang <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:17:17 -04:00
AiBizzAppsandClaude Opus 5.5 5ee2bda8fc M6: TOC follow-ups from #52 review, and a whole TOC heading in LibreOffice (#74)
* M6: close the TOC field in its last entry, not in a text box inside it

`para_inner` took `toc_end_here` only after writing the paragraph's content,
so a nested story in the last TOC entry (a text box) reached `para_inner`
first, took the flag, and wrote the field's end inside `w:txbxContent`.
Take it before the content.

Review follow-up on #52.

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

* M6: Update Table reads a heading above the TOC at its own index

Pass 1 shifted every heading by `entries.len()` to account for the entries
now in the body, but entries go in below the TOC heading, so a heading above
it never moves. Its lookup landed on a TOC entry and took that page (a
Preface on page 1 above a TOC on page 2 showed 2). Shift only headings below
the TOC.

Review follow-up on #52.

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

* M6: start the TOC field in the first entry, as Word does

The writer opened the TOC field at the end of the "Contents" heading.
Word reads that fine, but LibreOffice splits the heading: "Content" as
body text, then "s" as the TOC title (on the next page when the TOC
follows a page break). Word itself writes the heading before the field
and begins the field in the first entry; written that way, LibreOffice
shows the heading whole.

Hold the heading's field back and write its begin/instruction/separate
at the start of the first entry, taken before the paragraph content so
a text box in the heading or an entry can't claim it (as with the end).

The reader moves a TOC field that starts a paragraph right after a TOC
Heading back to the end of the heading, so WordCraft's own files read
back as before and a TOC saved by Word now updates in WordCraft too
(before, Update Table left Word's first entry behind as a duplicate).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:16:48 -04:00
MarekSpodBiedry 6b1f7a2741 DOCX: keep a line break written directly in a paragraph (#96) (#101) 2026-10-10 06:16:08 -04:00
d646d38921 M6: TOC page numbers, and save the TOC as a field Word can update (#52)
* M6: Update Table puts each heading on its real page

The second pass removed the entries and laid the document out again before
looking headings up at `path + entries.len()`, so every lookup landed past
its heading: later entries showed the next heading's page and the last fell
back to page 1. Lay out with the entries still in place, then replace them.

A heading typed after a manual page break also starts with the break, so its
page is now read after any leading breaks rather than before them.

The existing test only had a heading on page 1; the new one puts headings on
pages 2, 3 and 4 (before: 4, 1, 1).

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

* M6: save the table of contents as a TOC field Word can update

The writer saved the TOC heading's field empty (begin, instruction, separate
and end all at the end of "Contents"), with the entries as plain paragraphs
after it. Word showed them as ordinary text: no TOC frame, and no Update
Table or Update Field on the entries.

Write the field so its result spans the entries (closed at the end of the
last entry) and wrap heading and entries in the Table of Contents content
control (w:sdt with docPartGallery "Table of Contents", ECMA-376 17.5.2).

The reader already keeps a multi-paragraph field's start as an empty field
in its first paragraph and the rest as text, so WordCraft reads the TOC back
exactly as before; the new round-trip test checks both the file and that.

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 <shared2@storyteller.ai>
2026-10-09 09:02:22 -04:00
AiBizzAppsandClaude Opus 5.5 5a94aa77d9 M6: footnotes no longer cite themselves in saved .docx files (#53)
references.footnote starts the note's text with a NoteRef to the note itself,
so the editor shows its number. The writer already puts the w:footnoteRef
mark at the start of every note, then also wrote that NoteRef as a
w:footnoteReference, so each note referenced itself. LibreOffice refuses to
open such a file ("source file could not be loaded").

Skip a note's reference to itself while writing that note; the mark stands
in for it. Notes read from Word files never contain one, so they are
unaffected.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:48:51 -04:00
Brandon ThomasandClaude Opus 5.5 c29182e8bc M1: DOCX read/write (OOXML) wired into open/save; floating objects; picture & shape tools; mail merge, envelopes, labels; contextual spacing; export without proofing marks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:46:40 -04:00
Brandon ThomasandClaude Opus 5.5 435194f7c2 M0.9–M0.12: CLI, MCP server, xtask (layers/assets/parity/ci), icon placeholder, AGENTS/ATTRIBUTION/NOTICE, protocol docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:21:35 -04:00