feat(editor): IR-powered DOCX editor foundation — faithful single-block render + DocxEditor#234
Merged
Merged
Conversation
…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.
…ared (C#+browser)
…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.
…of + addressing note
…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.
…M1 tests run-formatting survival
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:DocxSession(losslessSave)WmlToHtmlConverterHTML +pagination.tspage boxes{#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
WmlToHtmlConverterSettings.StampAnchors(stampsdata-anchoronp/h*/li/table);HtmlConversionOps.RenderBlockHtml(bytes | DocxSession | handle, anchor, options);DocxSession.LiveDocumentDocumentConverter.RenderBlockHtml+stampAnchorsonConvertDocxToHtmlComplete; npmrenderBlockHtml(),DocxSession.renderBlock(),ConversionOptions.stampAnchorsDocxEditor(npm) — framework-agnostic, pure-TS block editor: faithful render → editable blocks → commit viaDocxSession→ re-render only that block → losslesssave();{ paginated: true }flows blocks into real.page-boxpage boxesdocs/architecture/ir_editor_feasibility.md(design + measured results + usage), CHANGELOG, CLAUDE.mdProven (tests in this PR)
HCO050and browser (npm/tests/render-block.spec.ts).convertDocxToHtml↔DocxSession↔RenderBlock(HCO052) — a DOM block'sdata-anchoris a valid session anchor.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.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)
ReplaceTextdoesn't re-derive the Unid, so no mid-session key churn (matters only across save/reopen).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