Skip to content

feat(editor): IR-powered DOCX editor foundation — faithful single-block render + DocxEditor#234

Merged
JSv4 merged 49 commits into
mainfrom
feat/ir-editor-feasibility-poc
Jun 20, 2026
Merged

feat(editor): IR-powered DOCX editor foundation — faithful single-block render + DocxEditor#234
JSv4 merged 49 commits into
mainfrom
feat/ir-editor-feasibility-poc

Conversation

@JSv4

@JSv4 JSv4 commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Summary

Answers — with running, measured code — whether the Docxodus IR can power a performant, format-faithful browser DOCX editor that renders pages with editable blocks. It can, and this PR builds the foundation plus a working editor.

The key reframe (verified at the source level): the IR itself can't be the editor's model — it's internal, immutable, lossy, and has no IR→OOXML writer (the diff engine reconstructs from original XML via IrProvenance). So the architecture ("Option B") is:

  • model-of-record: the live OOXML in DocxSession (lossless Save)
  • rendering: WmlToHtmlConverter HTML + pagination.ts page boxes
  • addressing spine: the shared {#kind:scope:unid} anchor system — the IR's anchors, not the IR, power the editor.

The make-or-break unknown — can a single block render faithfully out of whole-document context? — is proven yes, so an edit re-renders only the changed block (~10 ms) instead of a full ~0.7–2.4 s re-conversion.

What's included

Layer Change
C# core WmlToHtmlConverterSettings.StampAnchors (stamps data-anchor on p/h*/li/table); HtmlConversionOps.RenderBlockHtml(bytes | DocxSession | handle, anchor, options); DocxSession.LiveDocument
WASM/npm DocumentConverter.RenderBlockHtml + stampAnchors on ConvertDocxToHtmlComplete; npm renderBlockHtml(), DocxSession.renderBlock(), ConversionOptions.stampAnchors
Editor DocxEditor (npm) — framework-agnostic, pure-TS block editor: faithful render → editable blocks → commit via DocxSession → re-render only that block → lossless save(); { paginated: true } flows blocks into real .page-box page boxes
Docs docs/architecture/ir_editor_feasibility.md (design + measured results + usage), CHANGELOG, CLAUDE.md

Proven (tests in this PR)

  • Faithful single-block render matches the full-document render per anchor — C# oracle HCO050 and browser (npm/tests/render-block.spec.ts).
  • One Unid scheme across convertDocxToHtmlDocxSessionRenderBlock (HCO052) — a DOM block's data-anchor is a valid session anchor.
  • Latency (HCO052, HC031 — complex 42 KB doc): per-block re-render 10.4 ms session-attached vs 26.5 ms stateless (2.55×); both ~70–230× faster than full re-convert.
  • Full editor loop in Chromium (npm/tests/editor.spec.ts): render HC031 as 90 editable blocks → edit one → only that block re-renders → save+reopen shows the edit and untouched content intact (lossless). Paginated mode renders page boxes with editable blocks.

Verification: .NET suite 2171 passed / 0 failed / 1 skipped; browser specs green; no regressions.

Findings (documented in the spec)

  • Anchors are stable within a live sessionReplaceText doesn't re-derive the Unid, so no mid-session key churn (matters only across save/reopen).
  • HTML stamps more anchors than the projection indexes — table-cell paragraphs are stamped but not individually addressable, so v1 leaves them read-only.
  • Single-block render limits: list-numbering continuation and inline images degrade when a block is rendered in isolation.

Not in this PR (follow-ups)

Worker offload of the editing surface; re-paginate-on-edit (overflow reflow); rich in-block formatting on edit (the MVP replaces an edited block from plain text); table-cell editing via ReplaceCellContent; a React wrapper.

🤖 Generated with Claude Code

JSv4 added 30 commits June 14, 2026 11:27
…ck render

Adds WmlToHtmlConverterSettings.StampAnchors (stamps data-anchor=Unid on
p/h*/li/table) and HtmlConversionOps.RenderBlockHtml(bytes, anchor, options),
which renders one block via a throwaway doc that copies the source's
styles/numbering/theme/settings parts. Proven faithful by HCO050: per-anchor
output matches the full-document render (the oracle). Foundation for the
editor's incremental per-block re-render (docs/architecture/ir_editor_feasibility.md).
…dary

WASM JSExport DocumentConverter.RenderBlockHtml + stampAnchors param on
ConvertDocxToHtmlComplete; npm renderBlockHtml() wrapper and ConversionOptions.
stampAnchors. Browser smoke test (render-block.spec.ts) proves single-block
render matches the full render per anchor across the WASM boundary in Chromium
using the real DOM — the editor's incremental re-render path, verified end-to-end.
…scheme)

Adds RenderBlockHtml(DocxSession|handle, anchor, options) overloads that resolve
the block from the live session document (DocxSession.LiveDocument) without
re-opening bytes or re-assigning Unids over the whole doc. HCO052 proves the
full-render data-anchor resolves unchanged on the session path (convertDocxToHtml
↔ DocxSession ↔ RenderBlock share one Unid scheme) and measures 10.4ms vs
26.5ms/block on HC031 (2.55x), both far under the ~0.7-2.4s full-reconvert path.
…editor-loop test

DocxSessionOps.RenderBlockHtml + DocxSessionBridge JSExport + DocxSession.renderBlock
(npm). Browser test proves the full incremental loop in Chromium: open session →
project → edit a block via ReplaceText → re-render ONLY that block from the live
session → the edit is visible. Note: data-anchor carries the bare unid; DocxSession
ops need the full kind:scope:unid, so the editor maps via the (shared-scheme)
projection index — a Plan 2 refinement is to stamp the full id directly.
…MVP)

Renders a faithful doc with data-anchor blocks, makes projection-addressable
paragraphs/headings contenteditable, and on blur commits via DocxSession +
re-renders ONLY that block from the live session (session-attached). Pure TS,
no framework dep; exported from index + IIFE bundle for the harness.

Browser test (editor.spec.ts) proves the full loop on HC031: render 90 editable
blocks → edit one → only that block re-renders → save (39KB) reopens with the
edit persisted AND untouched content intact (lossless). Findings: block anchors
are STABLE within a live session (ReplaceText doesn't re-derive the Unid), so no
mid-session key churn; cell paragraphs in opaque tables are stamped but not
projection-addressable, so v1 leaves them read-only.
…page boxes

DocxEditor { paginated: true } renders via the converter's PaginationMode +
pagination.ts so blocks flow into .page-box page boxes (margins/headers), and
those page blocks stay contenteditable with the same incremental re-render loop.
Wires only the visible page container (pagination clones blocks; hidden originals
remain in #pagination-staging). Browser test confirms page boxes render, blocks
are editable, and an in-page edit re-renders incrementally. Completes the literal
'render pages and populate with editable blocks' goal. Re-paginate-on-edit
(overflow reflow) is a follow-up.
serializeInlineMarkdown(block) walks an edited block's DOM and emits the
projector's markdown subset (bold=**, italic=*, links=[..](..)), detecting
emphasis via computed style and merging adjacent same-format runs; commitBlock
sends that markdown to ReplaceText instead of plain textContent. Editing a
formatted paragraph no longer destroys its bold/italic/links. Test proves
**bold**/*italic*/[link] survive edit->save->reopen. Roadmap M1 marked done.
… runnable demo

M2: a keydown handler on each block wires Enter -> SplitParagraph(anchor, caret
offset) and Backspace-at-start -> MergeParagraphs(prev, this); it flushes
uncommitted typing first, applies the op, reconciles the DOM from EditResult
deltas (re-render affected blocks, insert/remove nodes, update unid->fullId map,
restore the caret). Test splits a block (+1), merges back (-1, text restored),
round-trips through save.

Demo: npm/examples/editor.html + 'npm run demo' — a standalone in-browser editor
(file open, paginated toggle, lossless save) over a bundled sample. Verified in a
real browser: full doc renders in ~1.2s with editable blocks.

Roadmap M1+M2 marked done; CHANGELOG + usage docs updated.
…strike, styles, undo)

DocxEditor gains format(key,value?) (inline formatting on the selection span via
ApplyFormat, toggling computed state), setParagraphStyle(styleId), undo()/redo()
(DocxSession history + re-render), and queryFormatState(); keyboard shortcuts
Ctrl/Cmd+B/I/U and Ctrl+Z / Ctrl+Shift+Z. The demo ships a ribbon (B/I/U/S/code,
style dropdown, undo/redo) whose buttons preventDefault on mousedown to preserve
the editor's selection. Formatting routes through DocxSession (lossless, supports
underline/color). Editor now defaults fabricateClasses=false so per-block
re-renders stay self-contained (fabricated class names have no page stylesheet) —
caught via live-browser verification. Test covers bold-on-selection, Heading1,
undo; all editor+render-block specs green.
…dent, page break

New DocxSession public API (rippled through all 8 layers):
- FormatOp.VertAlign -> w:vertAlign (superscript/subscript/baseline); auto-rides
  the existing ApplyFormat JSON path (no new bridge method).
- SetParagraphFormat(anchor, ParagraphFormatOp{Alignment, IndentDelta,
  PageBreakBefore}) -> w:jc / w:ind\@w:left (twips delta, clamped, sibling-preserving)
  / w:pageBreakBefore, with a CT_PPr SetPPrChildInOrder schema-ordering helper.

Editor: format('superscript'|'subscript'), setAlignment(), indent(), pageBreakBefore();
demo ribbon gains x²/x₂, L/C/R/J, indent, page-break buttons. Editor commands route
through DocxSession (lossless), re-rendering only the affected block.

Tests: C# DS200-DS202 (vertAlign set/clear, jc, pageBreakBefore + accumulating indent);
browser M5b (center->text-align, indent->margin, superscript-><sup>); verified live.
.NET 2174 passed/0 failed; 8 browser specs green. Lists (bullets/numbered) scoped as
the Mlists milestone — needs a numbering-definition factory (Raw can't reach that part).
…ctory)

DocxSession.ApplyListFormat(anchor, ListFormat.None|Bullet|Decimal) promotes a plain
paragraph to a real list item (the existing SetListLevel/RemoveListMembership only work
on existing list items). New Internal/NumberingFactory ensures the NumberingDefinitionsPart
exists and find-or-creates a spec-valid 9-level bullet/decimal abstractNum+num tagged by a
fixed marker w:nsid — idempotent across undo/save/reopen (no cache); it flushes the part
itself (PutXDocument) since the session's Save only persists projected parts. Rippled
through all 8 layers; editor toggleList('bullet'|'decimal') toggles via GetListMembership;
demo ribbon gains • / 1. buttons.

Tests: C# DS210-DS212 (promote+reuse, decimal->none, save/reopen round-trip); browser Mlists
(bridge promote+membership+remove, editor toggle re-renders). .NET 2177 passed/0 failed.

HONEST LIMIT: the op writes a correct, lossless list (valid in Word; GetListMembership
confirms), but WmlToHtmlConverter does not yet render the list MARKER glyph in the HTML
preview — a converter ListItemRetriever gap, separate from the list op (follow-up).

Completes all 7 requested controls (super/sub, alignment, indent, page break, bullets, numbering).
The earlier 'list marker not rendered' finding was a TEST ARTIFACT: HC031 repeats
the 'Video provides…' paragraph, so the live find-by-text matched a different,
non-bulleted block. Verified precisely (by anchor): the bullet marker (Symbol
U+F0B7) + hanging indent render in BOTH the full convert and the single-block
(incremental) path — the session-attached render copies the numbering part so the
converter's ListItemRetriever resolves the marker.

C# DS213 asserts the marker glyph + text-indent on the single-block render; the
browser Mlists test now asserts margin-left + the marker glyph on a unique block.
Removed the throwaway diagnostic; corrected roadmap/CHANGELOG (lists render fine).
.NET 2178 passed/0 failed; 7 browser specs green.
… item

Two issues found in manual testing of the DocxEditor block editor:

1. Numbering didn't continue (every numbered item showed "1."). The editor
   re-rendered an edited list item in isolation — single-block render has no
   whole-document numbering context — and on remount failed to re-wire list
   items because the session's persisted unids diverged from a re-derived
   scheme. Fix: open the editor session with persistAnchorIds:true (stable
   anchors across re-render) and route any list-affecting edit through a full
   remount instead of a single-block swap, so the converter assigns continuing
   numbers (1., 2., 3.) with real document context.

2. Enter at end-of-line of a numbered item did nothing. The generated marker
   renders as a number/bullet run plus a suffix tab; ConvertRun tagged the
   number run with data-list-marker but the suffix tab (rendered via the
   tab-width path in TransformElementsPrecedingTab, not ConvertRun) was
   untagged, so its character inflated the caret offset past the paragraph
   length — SplitParagraph returned OffsetOutOfRange and the keystroke was
   silently dropped. Fix: tag the marker wrapper span (number/bullet glyph +
   suffix tab) with data-list-marker when the tab run carries
   PtOpenXml.ListItemRun, so the editor's caret/offset math (isInMarker)
   excludes the whole marker.

New browser test editor.spec.ts Mlists2 (numbered continuation + Enter adds a
continuing item). Editor-only feature surface; stable converter/library public
API unchanged. HcTests+DocxSession 371/371 and RenderBlock 6/6 green; all 8
editor specs green.
Committing a list item happens on blur, and the commit re-rendered (replaced)
the item's DOM node. When the user clicked straight from one bullet to another,
that node replacement ran during the blur — which cancels the browser's
in-flight focus transfer, so focus fell to <body> and typing into the next
bullet did nothing (a fresh empty item was the common case: "trouble typing
into subsequent numbered bullets").

Fix: do NOT re-render a list item on a text commit. A plain text edit never
changes the item's number, and the DOM already shows exactly what the user
typed with the correct marker, so commitBlock now only syncs the session
(ReplaceText) + bookkeeping for list items and leaves the node in place. Plain
(non-list) blocks still re-render in place for canonical HTML — verified via
real clicks that focus stays on the newly-clicked block.

New browser test editor.spec.ts Mlists3 drives REAL mouse clicks + REAL
keyboard across three numbered items (one with text, two empty): asserts focus
lands on each clicked bullet, typing into each works, numbering stays 1/2/3,
and save round-trips. Full editor suite 9/9 green. Editor-only; no
library/converter API change.
… handler

Two fixes from manual testing of nested numbered lists.

1. Nested lists. Indenting a list item (ribbon indent button, or Tab /
   Shift+Tab) called SetParagraphFormat — shifting the paragraph margin but
   leaving ilvl unchanged — so numbering stayed flat (1,2,3) while items just
   moved sideways ("nested lists no bueno"). DocxEditor.indent() now detects a
   list item and routes to DocxSession.SetListLevel(±1) (the op already existed
   and was exposed in the bridge; the editor just wasn't calling it). Tab /
   Shift+Tab on a list item nest / un-nest it. Numbering nests correctly
   (1, 2, [sub-level 1], 3) at the deeper indent; Shift+Tab restores the flat
   sequence.

2. JsonSerializerIsReflectionDisabled crash. RenderBlockHtml's bridge catch
   handler serialized an anonymous type (new { error = ex.Message }), but the
   trimmed WASM build disables reflection-based System.Text.Json, so the handler
   itself threw — surfacing as a bare "Uncaught Error:
   JsonSerializerIsReflectionDisabled" and masking the real RenderBlockHtml
   failure. Fixed by building the error JSON reflection-free via
   JsonEncodedText.Encode, matching the documented contract (HTML starts with
   '<', errors are a JSON object) so the editor degrades gracefully.

New browser test editor.spec.ts Mlists4 (Tab nests / Shift+Tab un-nests,
numbering + indent verified). Full editor suite 10/10 green. Editor + WASM
bridge only; no library/converter public API change.
…tyle

DocxSession.ApplyFormat stamps inline code as <w:rStyle w:val="Code"/>, but on
a document that never defined a "Code" style that reference is a phantom — Word
and the converter render the run as plain text, so the editor's </> ribbon
button appeared to do nothing even though the run was correctly split out.

ApplyFormat now ensures the style exists when op.Code is true via a new
Internal.StyleFactory.EnsureCodeCharacterStyle (mirroring NumberingFactory):
find-or-create a character style id "Code" with a Consolas run font, leave any
existing "Code" style untouched, and flush via PutXDocument since the session's
Save only persists projected parts. A clean run now renders Consolas; a run
with a hardcoded direct font keeps it (direct formatting outranks a character
style, as Word resolves it).

Test DS214_ApplyFormat_Code_CreatesMissingCodeCharacterStyle. ApplyFormat's
signature is unchanged, so the WASM/npm bridge picks the fix up transparently.
…gle-Docs-exported docs

Smoke-testing DocxEditor against a Google-Docs-exported .docx (float twips,
all-nil per-paragraph borders, explicit w:val="0" run props, bidi marks)
surfaced four edit-path failures, fixed here:

- SetParagraphFormat: read existing w:ind/@left via AttributeToTwips (tolerant
  of non-integer twips like 12.996749877929688) instead of a throwing (int?)
  cast that left indent dead on every paragraph.
- WmlToHtmlConverter.CreateBorderDivs: a new HasVisibleBorder guard skips an
  all-nil/none w:pBdr (an invisible Google-Docs border) so the left indent
  stays on the <p> instead of being relocated onto a wrapper div the editor's
  single-block re-render never updates.
- ApplyFormat Toggle: turning bold/italic/strike ON now normalizes an existing
  explicit-off element (<w:b w:val="0"/>) to on, instead of no-op'ing because
  an element already exists.
- editor.ts: exclude bidi formatting marks (U+200E/U+200F) from caret-offset
  math so Enter at end-of-line isn't dropped and splits land on the right
  character (mid-paragraph was off-by-one).

Tests: DS215/DS216/DS217 + an editor.spec.ts bidi-Enter regression; full
suite green. Also bundles the in-progress symbol-font mapping (SymbolFontMapper,
StyleFactory) and business-letter editor spec already staged on the branch.
Spec for preserving all run properties (color/size/family/underline/strike/
super-sub/etc.) when a block is edited, via a minimal prefix/suffix text-diff
through the existing ReplaceTextAtSpan primitive instead of the lossy markdown
re-serialization. Editor-only change.
JSv4 added 19 commits June 18, 2026 22:21
Hardens the in-browser block editor and the converter against the gaps
surfaced by smoke-testing a complex (python-docx-authored) S-1 document.

- List nesting now works on SOURCE-document lists, not just editor-created
  ones. Real-world lists (style-inherited numbering, single-level abstractNum
  — e.g. python-docx "List Bullet") were a silent no-op on Tab. SetListLevel
  materializes a direct numPr from the pStyle chain, NumberingFactory.
  EnsureLevelDefined synthesizes the missing levels AND upgrades multiLevelType
  off singleLevel (which the converter force-flattens to ilvl 0), and
  ListItemRetriever no longer collapses a nested bullet as a numbered-list
  "continuation". Test: DS054b.
- Block commit/split/merge/swap route through a guarded replaceNode helper
  that re-checks parentNode and tolerates the re-entrant blur->commit
  node-detach race — no more uncaught NotFoundError under programmatic focus.
  Test: editor-gaps GAP5.
- Paginated mode drops the #pagination-staging subtree after the one-shot
  measurement pass, so data-anchor is unique and no stale copy lingers.
  Test: editor-gaps GAP6.

Also lands the previously-staged smoke-test fixes documented under
[Unreleased]: paginated page-break render (GAP1), table-cell text editing
with inert structural keys (GAP3), and pagination-toggle edit preservation
(GAP4).

Verified: full .NET suite (2192 passed) and Playwright suite (213 passed).
… borderless tables

Two converter/render improvements surfaced while smoke-testing the editor on a
realistic (python-docx-authored) S-1 and drafting an SEC Form S-1 cover page.

PERF — single-block render (`RenderBlockHtml`) ~6.5x faster on a large style
gallery, removing the perceptible delay when editing/leaving table cells.
Profiling a doc whose styles.xml is 164 styles / ~434KB found a keystroke-commit
re-render cost ~650ms in WASM, almost all of it two steps repeated every commit:
  - MarkupSimplifier re-walking the copied style-definition parts (~70ms; that
    pass only strips rsids, which never reach the HTML), and
  - re-cloning the whole style gallery into a throwaway doc (~26ms).
Fixes, both behind the existing RenderBlockHtml API (no WASM/npm/bridge change):
  - internal `SkipFormattingPartsSimplification` flag (WmlToHtmlConverterSettings/
    SimplifyMarkupSettings) skips the rendering-neutral style-part simplification
    for the single-block path; and
  - DocxSession caches the throwaway "formatting shell" (parts + empty body,
    serialized once) and reuses it, rebuilding only when a cheap content signature
    of the style/numbering parts changes (i.e. only on a format op that adds a
    style/numbering/level, never on a text edit, so it survives typing).
Measured: RenderBlockHtml 149ms -> 11ms (Debug, 13.5x); browser edit-commit
~650ms -> ~100ms. Output is byte-for-byte unchanged. Tests: HCO053 (flag on/off
byte-identical, incl. paginated), HCO054 (shell path == stateless + consistent),
HCO055 (a mid-session ApplyListFormat rebuilds the shell -> invalidation).

FIX — converter crashed (ArgumentNullException) on a borderless table
(w:tblBorders / cell borders with w:val="none" and no w:sz), aborting the entire
conversion. This is the standard multi-column layout in real filings (an S-1
cover's registrant-facts row and counsel block are borderless tables), so such
documents wouldn't render at all. Both FormattingAssembler.ResolveInsideBorder
and WmlToHtmlConverter.ResolveCellBorder special-cased only "nil", letting a
"none" border reach a (int)/(decimal) cast of the absent (optional) w:sz. Both now
read w:sz null-safe (missing = 0 width); output unchanged for borders that carry
w:sz. Test: HCO056.

Verified: full .NET (2198 passed) and Playwright (213 passed) suites green.
…c factory

Smoke-testing the DocxEditor against an SEC Form S-1 cover page surfaced four
missing capabilities; all are now first-class, lossless, and OOXML-schema-valid:

- Font size: FormatOp.FontSizePts (points -> w:sz/w:szCs half-points; <=0 clears).
- Paragraph borders / horizontal rules: ParagraphBorderEdge +
  ParagraphFormatOp.{TopBorder,BottomBorder,ClearBorders} (w:pBdr), and
  DocxSession.InsertHorizontalRule (empty bottom-bordered paragraph; single/
  double/thick + weight).
- Table insertion: DocxSession.InsertTable(anchor, pos, rows, cols,
  TableInsertOptions{Borderless, CellContents row-major, CellAlignment}) ->
  returns created cell-paragraph anchors. Borderless emits explicit w:val="none".
- New blank document: DocxSession.CreateBlankDocxBytes() (new
  Internal/BlankDocumentFactory) -> a complete blank DOCX that opens in Word.

Rippled through every layer: DocxSession -> DocxSessionOps -> DocxSessionJson
(hand-written WASM wire parsers) -> DocxSessionBridge ([JSExport]) -> npm
types.ts/session.ts/editor.ts/index.ts + the editor.html demo toolbar. Editor
adds setFontSize/insertHorizontalRule/insertTable + the DocxEditor.openBlank
"New document" factory; demo gains New / Size / rule / table controls.

Tests: DocxSessionS1FeaturesTests DS201-DS210, incl. DS210 which builds an
S-1-style page with all four features and asserts OpenXmlValidator reports zero
schema errors. Full suite 2209 passed / 0 failed / 1 skipped. The full cover
page was drafted end-to-end through the editing surface and renders faithfully;
also verified through the live DocxEditor with a lossless save round-trip.
See docs/architecture/s1_smoke_test_features.md.
Intra-paragraph line breaks committed as a raw \n in w:t, which Word renders
as a space (the editor's pre-wrap preview hid the divergence). MarkdownPayloadParser
now maps the GFM hard break '  \n' to a w:br run (mirrors the read-side
WmlToMarkdownConverter); DocxEditor handles Shift+Enter via native insertLineBreak
and serializes <br> -> '  \n'. Blank lines still split paragraphs. Tests: DS211-213
(C#) + editor-linebreak.spec.ts (browser).
Enter was inert in cells (GAP3), so a cell could hold only one line. The engine
already splits a cell paragraph correctly (the new w:p stays in the w:tc, grid
unchanged); DocxEditor now routes Enter-in-cell to that split and re-renders the
two cell paragraphs in place, each independently formattable. Grid-changing keys
(cross-cell Backspace-merge, Tab) stay inert. Unblocks the S-1 value-over-label
rows and multi-line address columns. Tests: editor-cell-multiparagraph.spec.ts
(new) + GAP3 updated.
The engine + DocxEditor.insertHorizontalRule(weight, style) already supported
double/thick border styles, but the demo's rule buttons both hard-coded single,
so a true double rule (the S-1's signature top divider) was unreachable from the
toolbar. Add a double-rule button. Regression test editor-rule-style.spec.ts locks
the capability (double border renders + survives save/reopen).
InsertTable split the content width equally; ColumnWidths (twips, one per column)
now drives w:tblGrid/w:gridCol + per-cell w:tcW, sizing the table to their sum. A
mismatched count is rejected (no silent equalize). Unblocks the S-1 filing-header
wide-left/narrow-right row. Rippled engine -> DocxSessionJson -> npm types/editor.
Tests: DS214/DS215 (C#) + editor-table-colwidths.spec.ts (browser).
New DocxSession ops InsertTableRow/InsertTableColumn/DeleteTableRow/DeleteTableColumn
addressed by a cell-paragraph anchor: insert clones the reference row/column widths
(w:tblGrid kept consistent) and starts empty; deleting the last row/column removes the
table. v1 assumes a rectangular grid (no gridSpan). Rippled engine -> DocxSessionOps ->
DocxSessionBridge -> npm session + DocxEditor (insertTableRow/Column, deleteTableRow/Column)
+ a floating table toolbar in the demo. Tests: DT201-207 (C#, schema-valid) +
editor-table-edit.spec.ts (browser). Drag-to-resize columns deferred (ColumnWidths covers
proportions at insert).
Replace the freetext prompt("rows x cols") with a hover-to-pick rows x cols grid
(up to 8x10) + borderless toggle; clicking a cell inserts that table at the caret.
Regression test editor-demo-grid.spec.ts drives the real demo (editor.html now served
in the harness) and confirms 3x3 picks insert a 3x3 table.
A selection spanning multiple paragraphs now applies format/setFontSize/setAlignment/
indent/pageBreakBefore/setParagraphStyle to every block in range (was: only the active
block). Inline ops apply to each block's slice; paragraph ops per block. Spanned blocks
resolved via Range.comparePoint (robust to boundaries that normalize onto a wrapper, which
Range.intersectsNode mishandles). Single-block behavior unchanged. Test:
editor-multiblock-format.spec.ts.
The demo size control was a <select> capped at 48pt; engine setFontSize was always
unbounded. Replace with a numeric input + preset datalist (8..96) accepting any value
(apply on change/Enter) that reflects the current selection's size. Test:
editor-demo-fontsize.spec.ts (typing 72 sets a 72pt run).
…ine breaks, multi-block, etc.)

Update CLAUDE.md DocxSession/DocxEditor surface, docx_mutation_api.md (markdown subset
w:br + table ops), and ir_editor_roadmap.md M7 -> done (except cell merge).
… after a table

Two engine fixes from a second S-1 cover-page smoke test:

- SplitParagraph dropped w:pBdr onto the new paragraph, so pressing Enter
  inside an empty horizontal rule stacked another rule and bordered the
  body text below. It now strips w:pBdr from the new paragraph ONLY when
  the split paragraph is empty (a pure rule); a bordered paragraph that
  has text still splits with the border on both halves.
- InsertTable left a table as the final body element (</w:tbl></w:sectPr>)
  with no trailing paragraph, so nothing could follow an end-of-body table
  (and it violates Word's keep-a-paragraph-after-a-table convention). It
  now appends an empty w:p after the table when what follows isn't already
  a paragraph; no extra one is added when a paragraph already follows.

Tests: DocxSessionS1FeaturesTests DS216/DS217 (split border) and
DS218/DS219 (table trailing paragraph).
…Borders + demo button)

Once a paragraph had a horizontal-rule border there was no way to remove
it through the editor: the ribbon only ADDS rules, and applyParagraphFormat
didn't accept clearBorders (the engine/wire already did). Adds
DocxEditor.clearParagraphBorders() — clears borders on the active block,
or every block of a multi-block selection, and re-renders fully (a border
change adds/removes the wrapping border <div>) — and a "clear rule" (─✗)
button to the demo.

Browser test: editor-clear-borders.spec.ts.
…w it

insertTable inserted after the active block, so building a table from a
blank line stranded that blank line above the table. When the caret is on
an empty paragraph (outside a table), the table is now inserted BEFORE it,
so the empty paragraph becomes the editable line below the table — no stray
line above, a reachable line below (it also serves as the engine's
keep-a-paragraph-after-a-table line for this case). Non-empty blocks are
unchanged (table inserted after).

Browser test: editor-table-empty-source.spec.ts (covers this with the
trailing-paragraph engine fix).
… cache)

The size field has to take focus to be typed in, which blurred the
contenteditable block and collapsed the selection, so setFontSize could
only size a whole paragraph. DocxEditor now caches the last real
(non-collapsed) selection per block via a selectionchange listener —
refreshed whenever a selection sits in a block, cleared when a caret is
collapsed inside a block so it never goes stale, removed on close() — and
setFontSize falls back to it when the live selection has been stolen by a
toolbar control.

Browser test: new case in editor-demo-fontsize.spec.ts (size only "BIG"
of "BIGsmall").
…ling paragraph, sub-range size)

CHANGELOG entries + CLAUDE.md surface notes for the four findings from the
second S-1 cover-page smoke test: HR border no longer inherited on
Enter-split, clearParagraphBorders(), a paragraph is kept after a table,
table-on-empty-line placement, and the font-size combobox sizing a
sub-selection. LibreOffice 25.8 opens the saved DOCX and renders it
consistently with the converter.
…e undo, table toolbar)

A third S-1 cover-page smoke test surfaced three editor-side defects. The
OOXML/save was always correct (LibreOffice renders the saved docs faithfully);
all three are client-side rendering/UX bugs:

- Splitting/merging a bordered paragraph (e.g. Enter inside a horizontal rule)
  left the new borderless paragraph rendered INSIDE the rule's border <div>,
  drawing the rule's line under the typed text. The engine already strips the
  border on split (DS216); the bug was the incremental render's in-place node
  swap. splitAtCaret/mergeWithPrevious now remount when a border wrapper is
  involved so CreateBorderDivs regroups the border boxes correctly. Added
  inBorderWrapper() helper.

- Font size applied via Enter in the demo combobox took two Undo presses to
  revert (applyFontSize was bound to BOTH `change` and keydown-Enter, firing
  twice) and logged a benign addRange warning (the second call re-selected a
  swapped-out block). Enter now commits via blur() (single change); selectRange
  skips a detached range defensively.

- The demo's floating table toolbar overlapped the first row of a table near
  the page top; it now measures its height + the sticky header and flips below
  the table when there's no room above.

Tests: new editor-border-bleed.spec.ts + a single-undo case in
editor-demo-fontsize.spec.ts. Full Playwright 226/226 and .NET 2225/0/1-skip green.
…ove, delete-block, grid align)

Closes the four omissions the round-4 S-1 smoke test flagged.

- Font family: new FormatOp.FontFamily -> w:rFonts (ascii/hAnsi/cs),
  inserted in CT_RPr schema order (after an optional w:rStyle); "" clears
  so the run inherits the style/default. DocxEditor.setFontFamily(name)
  (multi-block + last-selection cached, mirroring setFontSize) + a curated
  demo font dropdown. Rippled DocxSession.ApplyFormatToRun ->
  DocxSessionJson.ParseFormatOp (fontFamily field) -> types.ts FormatOp ->
  editor.ts.
- Rule above: DocxEditor.insertHorizontalRule(weight, style, position)
  gains position "above"|"below" (default below); "above" uses the
  bridge's already-supported "before" (Position.Before). Demo Above/Below
  toggle honored by all three rule buttons. Reaches the S-1 heavy top bar
  between the filing table and "UNITED STATES".
- Block delete: DocxEditor.deleteBlock() via the already-bridged
  DocxSession.DeleteBlock + remount, guarded (inert inside a table / when
  it is the only editable block). Demo trash button.
- Grid-picker alignment: an L/C/R selector (default left = the document
  default) replaces the hardcoded centered cells.

Tests: C# DS220/221/222 (w:rFonts set / schema order + OpenXmlValidator
clean / "" clears) and browser editor-fontfamily, editor-rule-above,
editor-delete-block, and the extended editor-demo-grid alignment specs.
Full .NET 2228/0 + Playwright 229/0 green. editor-fontfamily exercises the
font-family browser round-trip and needs a networked `npm run build` to
rebuild the WASM (its engine is proven by DS220-222 regardless).

Multi-block selection remains deferred (per-block contenteditable hosts
need a single-root rearchitect).
@JSv4 JSv4 merged commit e9a1552 into main Jun 20, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant