fix: serif fallback for serif-like fonts#2870
Merged
harbournick merged 1 commit intomainfrom Apr 21, 2026
Merged
Conversation
0ffd0c0 to
2723d47
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
2723d47 to
be3c418
Compare
be3c418 to
4a75eeb
Compare
Contributor
|
🎉 This PR is included in @superdoc-dev/react v1.2.0-next.32 The release is available on GitHub release |
Contributor
|
🎉 This PR is included in vscode-ext v2.3.0-next.34 |
Contributor
|
🎉 This PR is included in superdoc-cli v0.7.0-next.35 The release is available on GitHub release |
Contributor
|
🎉 This PR is included in superdoc v1.26.0-next.34 The release is available on GitHub release |
Contributor
|
🎉 This PR is included in superdoc-sdk v1.6.0-next.33 |
harbournick
added a commit
that referenced
this pull request
Apr 21, 2026
* chore: merge stable into main (#2858)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* docs(pm-adapter): note why w:bar is not normalized
Word does not render w:bar borders on display (only preserves them on
save). SuperDoc currently matches that behavior by omitting 'bar' from
the normalized sides list. Add a note at the source of truth so future
contributors don't re-add it without a real use case.
* feat: support rendering of column line separators (#2088)
* feat: support column line separators
- Extracts 'w:sep' tag according to OOXML spec
- Renders one separator for each page column ('w:col' / 'w:num') in DOM painter
Closes #2067
* test: add docx file with line separator between columns enabled
* fix: rendering color of separators
* fix: reuse ColumnLayout and SINGLE_COLUMN_DEFAULT
* fix: ensure column width larger than separator width
* chore: lint
* chore: fix imports and missing SINGLE_COLUMN_DEFAULT usage
* chore: reuse type
* fix: render column separators per region on pages with continuous breaks
Two related fixes so the new column-line-separator feature works correctly
on pages where a continuous section break changes column layout mid-page.
1. isColumnConfigChanging now compares withSeparator. Before, a sep-only
toggle (count+gap unchanged) returned false, so no mid-page region was
created and the toggle was silently dropped. Applied in both
section-breaks.ts and the inline fallback in index.ts.
2. constraintBoundaries captured during layout are serialized onto a new
page.columnRegions contract field. renderColumnSeparators now iterates
regions and draws each separator bounded by its yStart/yEnd instead of
painting a single full-page overlay. When no mid-page change occurs,
columnRegions is omitted and the renderer falls back to page.columns
(unchanged behavior).
Verified by loading a fixture with 7 scenarios (2-col, 3-col, unequal
widths, separator on/off, continuous breaks toggling the separator).
Pages now show per-region separators tiled correctly; a 3-col region
followed by a 2-col region no longer paints a shared full-page line.
Out of scope here, tracked for follow-up: widths/equalWidth are still
dropped at pm-adapter extractColumns, so unequal-width separators render
at the equal-width midpoint; body-level w:sep is dropped at v2
docxImporter; there is no w:sep export.
* test: cover DomPainter renderColumnSeparators
13 unit tests over the separator renderer. Splits coverage into the
fallback path (page.columns only) and the region-aware path
(page.columnRegions).
Fallback path: pins the 2-col and 3-col geometry, and each early-return
guard (withSeparator false/undefined, single column, missing margins,
no columns at all, pathologically-small columnWidth).
Region path: verifies per-region yStart/yEnd bounding, mixed regions
(some draw, some skip for withSeparator=false or count<=1), zero-height
regions, and that columnRegions wins when both it and page.columns are
present.
Previously renderColumnSeparators had zero DOM-level coverage — the
region-aware refactor in the prior commit relied entirely on
layout-engine tests that never exercised the DOM output.
---------
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
* fix(sdk): raise asyncio StreamReader buffer in Python AsyncHostTransport (#2760)
* fix(sdk): raise asyncio StreamReader buffer in Python AsyncHostTransport
The Python async transport spawned the host CLI without passing a `limit=`
to `asyncio.create_subprocess_exec`, so its stdout `StreamReader` inherited
asyncio's default 64 KiB buffer. Every host response is written as a single
newline-delimited JSON line, so any `cli.invoke` whose serialized result
exceeds 64 KiB (e.g. `superdoc_get_content` on larger documents) caused
`readline()` to raise `ValueError: Separator is not found, and chunk
exceed the limit` inside `_reader_loop`. The exception was caught by the
generic reader-loop handler and pending requests were rejected with the
misleading `HOST_DISCONNECTED` error — even though the host process was
still alive and healthy.
Pass `limit=` to `create_subprocess_exec` and expose it as a new
`stdout_buffer_limit_bytes` constructor option on `AsyncHostTransport`,
threaded through `SuperDocAsyncRuntime` and `AsyncSuperDocClient`. The
default of 64 MiB safely covers the host's own 32 MiB
`DEFAULT_MAX_STDIN_BYTES` input cap with room for ~2x JSON expansion.
`SyncHostTransport` is unaffected — it uses raw blocking `subprocess.Popen`
which has no asyncio buffer limit.
Adds a `TestAsyncLargeResponse` regression suite that:
1. Round-trips a 200 KB response through the default-configured transport.
2. Pins that an explicitly tightened `stdout_buffer_limit_bytes` still
reproduces the original failure mode, guaranteeing the option is
wired through to `create_subprocess_exec`.
* fix(sdk): tear down host process on async reader-loop failure
AsyncHostTransport._reader_loop caught reader exceptions by rejecting
pending futures and flipping state to DISCONNECTED, but never killed
self._process. Because dispose() early-returns on DISCONNECTED, any
reader-loop failure left an orphaned host subprocess running with no public API to reap it. This is a pre-existing bug, but the previous
commit made it easier to trip by exposing stdout_buffer_limit_bytes:
any caller who sets it below their real response size hits the orphan path.
Route both the buffer-overflow and generic-error branches through a
new _schedule_cleanup helper that fires _cleanup() as a separate task
(it can't be awaited inline — _cleanup cancels and awaits the reader
task itself). _cleanup kills the process, waits on it, rejects pending,
and only then transitions to DISCONNECTED, so a subsequent dispose()
is a safe no-op instead of leaking the host.
Also catch asyncio.LimitOverrunError / ValueError separately and
surface HOST_PROTOCOL_ERROR with a "raise stdout_buffer_limit_bytes"
hint plus the current limit in details. The previous HOST_DISCONNECTED
code pointed users at the wrong problem since the host was still alive.
Extends TestAsyncLargeResponse to assert HOST_PROTOCOL_ERROR, verify
the hint is in the message, confirm the subprocess is actually reaped
(returncode set, _process cleared), and that dispose() after an overflow is a safe no-op.
* refactor(sdk): dedupe stdout_buffer_limit default and add wiring test
Address review follow-ups on the async transport buffer-limit option.
- Hoist DEFAULT_STDOUT_BUFFER_LIMIT_BYTES (64 MiB) to module scope in
transport.py and reference it from AsyncHostTransport, the async
runtime, and AsyncSuperDocClient so the default lives in one place
instead of three copies of 64 * 1024 * 1024.
- Add a short "raise if a single host response can exceed this size"
comment on the client.py parameter so callers see the guidance at
the public API boundary, not buried in transport.py.
- Rename test_response_above_default_64kb_buffer to
test_response_above_asyncio_default_streamreader_limit. 64 KiB is
asyncio's default, not the SDK's (which is now 64 MiB), so the old
name read backwards after this PR.
- Add test_client_threads_stdout_buffer_limit_to_transport: builds
AsyncSuperDocClient with a custom limit and asserts the value
reaches AsyncHostTransport. Without this, a silent drop of the arg
in client.py or runtime.py would leave the existing overflow test
passing while the public API reverts to the asyncio 64 KiB default.
* fix(sdk): mark transport DISPOSING synchronously on reader teardown
Round-2 review follow-ups:
- _schedule_cleanup now flips state to DISPOSING before scheduling the
cleanup task. Previously, between the reader returning and the async
_cleanup running, _ensure_connected's CONNECTED fast path would still
accept invoke() calls; they then blocked on a future the dead reader
could never resolve until watchdog_timeout_ms (default 30s).
- Narrow the buffer-overflow catch to readline() only and drop
asyncio.LimitOverrunError from the tuple. readline() re-raises
LimitOverrunError as ValueError (it is not a ValueError subclass on
any supported CPython), so the previous broad except could
reclassify unrelated ValueErrors from dispatch as a buffer-limit
error with a misleading remediation hint. Comment corrected to match.
- Re-export DEFAULT_STDOUT_BUFFER_LIMIT_BYTES from superdoc/__init__.py
so consumers tuning the option don't import from the implementation
module.
- Tighten test_host_crash to assert HOST_DISCONNECTED specifically and
verify process teardown via the new _schedule_cleanup path.
- Strengthen the dispose-after-overflow assertion to actually verify
the no-op claim (state stays DISCONNECTED, _process stays None, a
second dispose is also safe). Replace the timing-sensitive
process.returncode read with await process.wait().
* fix(sdk): serialize teardown across reader, _kill_and_reset, and dispose
Round-2 follow-up — addresses the residual race that the synchronous
DISPOSING flip didn't cover.
Before: `_kill_and_reset()` (called from `_send_request` on stdin write
failure or watchdog timeout) `await`ed `_cleanup` directly. If a
reader-triggered `_schedule_cleanup` was in flight, both ran
concurrently and raced on `_reject_all_pending`'s read-then-clear of
`self._pending` (futures added between snapshot and clear were leaked)
and on `process.kill()`/`reader_task.cancel()`. `dispose()` similarly
short-circuited on DISPOSING without waiting for the in-flight cleanup
to finish — the caller saw "disposed" before the host was fully torn
down.
Now:
- `_kill_and_reset` and `dispose` both check the cleanup-task slot and
`await` an in-flight cleanup rather than starting a parallel one.
Single-flight teardown across all three entry points.
- `_cleanup` clears `self._cleanup_task` in `finally` when it owns the
slot, so introspection doesn't surface a stale done handle and the
next teardown gets a fresh slot.
- `dispose()` after a reader-triggered cleanup now blocks until that
cleanup finishes, restoring the "host fully torn down on return"
contract.
Tests:
- `test_schedule_cleanup_dedupe_guard_drops_reentrant_call` — second
`_schedule_cleanup` does not replace the in-flight task slot.
- `test_overflow_during_dispose_does_not_schedule_cleanup` — `_stopping`
suppression is honored.
- `test_kill_and_reset_awaits_in_flight_cleanup` — `_kill_and_reset`
observes the existing task instead of running a parallel `_cleanup`.
- `test_dispose_waits_for_in_flight_cleanup` — `dispose()` blocks until
reader-triggered cleanup completes before returning.
95 transport tests pass; 5 consecutive runs with PYTHONASYNCIODEBUG=1
show no flakes.
* fix(sdk): close residual races in async transport teardown
Two correctness regressions and three test gaps surfaced in the
final-pass review of the cleanup-task lifecycle.
**1. _ensure_connected race (HIGH).** The synchronous DISPOSING flip
in _schedule_cleanup did not gate _ensure_connected, so a concurrent
connect()/invoke() reaching _start_host during the DISPOSING window
would reassign self._process and self._reader_task. The pending
cleanup task then read those slots after its first await and killed
the freshly-spawned process. Fix: drain self._cleanup_task at the top
of _ensure_connected via asyncio.shield (so a cancelled caller doesn't
abort the in-flight cleanup).
**2. Cancellation propagation race (HIGH).** _kill_and_reset and
dispose() awaited the cleanup task without asyncio.shield. When the
caller (e.g. an invoke task at the watchdog branch) was cancelled,
asyncio cancelled the awaited cleanup task too — _cleanup did not
catch CancelledError around process.wait(), so teardown stopped before
clearing _process / setting state. dispose() then saw DISPOSING with
_cleanup_task=None and returned without finishing teardown, leaking
the host process. Fix: wrap the awaited cleanup in asyncio.shield in
both call sites; restructure _cleanup so it captures handles and sets
state synchronously up-front, before any awaits, so observable state
is always consistent.
**3. Move _stopping guard into _schedule_cleanup.** The previous
test_overflow_during_dispose_does_not_schedule_cleanup was tautological
— it set _stopping=True and then re-checked the same condition in the
test body before calling _schedule_cleanup, so the call never ran and
the assertion passed trivially. Move the guard into _schedule_cleanup
itself (it's the correct authoritative location anyway), remove the
now-redundant call-site checks in _reader_loop, and rewrite the test
to call _schedule_cleanup unconditionally with _stopping=True. The
test now actually exercises the production guard.
**4. Multi-pending-invoke overflow test.** Codex round-2 gap that
remained open. Locks down that _reject_all_pending fails ALL pending
futures with HOST_PROTOCOL_ERROR plus the actionable hint, not just
the one whose response overflowed.
**5. Async reconnect-after-buffer-overflow test.** Sync transport
already had test_reconnect_after_failure; async only covered reconnect
after explicit dispose. Validates that reader-triggered cleanup leaves
the transport reusable for a fresh invoke without wedging
_cleanup_task / _connecting / _process.
Plus: replaced asyncio.sleep(0) with asyncio.Event-based
synchronization in lifecycle tests (Codex/Opus medium — sleep(0) is
implementation-defined under uvloop / Python scheduling changes); two
new tests directly cover the round-3 races
(test_ensure_connected_drains_in_flight_cleanup_before_spawn,
test_kill_and_reset_caller_cancellation_does_not_cancel_cleanup).
99 transport tests pass; 5 consecutive runs with PYTHONASYNCIODEBUG=1
show no flakes; new tests pass under -W error::ResourceWarning.
---------
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
* docs: add michaelreavant to community contributors (#2866)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* docs: add JoaaoVerona to community contributors (#2859)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
* fix: prevent text overlap when multi-column sections span pages (SD-1869) (#2676)
* fix: use actual font size for tab runs instead of hardcoded 12pt default
The tab measurement code hardcoded `maxFontSize: 12` for tab-created lines and tab runs, causing incorrect line height calculations when the surrounding text used a different font size. This also adds `fontSize: '0'` to line styles to eliminate the CSS strut that caused baseline misalignment between normal-flow and absolutely-positioned (tab-aligned) children.
* fix: prevent text overlap when multi-column section spills across pages
When a multi-column section's content spans multiple pages and columns have
unequal heights, the next section's content was positioned at the shorter
column's cursor Y instead of below all column content. This caused visible
text overlap on page 2.
Add maxCursorY to PageState to track the deepest Y reached across all
columns. startMidPageRegion now uses this value so the next section begins
below all column content.
* fix: eliminate CSS strut misalignment between tab-segmented and normal lines
Lines with tab stops use absolute positioning for text segments, which
bypasses the CSS strut. Normal-flow lines inherit the browser's default
16px font-size, creating a strut that shifts text down ~1px via baseline
alignment. This made tab-indented first lines appear shifted up relative
to continuation lines.
Zero the line container's font-size to remove the strut, and restore
explicit font-size on the three child elements that inherit rather than
set their own: empty-run caret placeholder (lineHeight), math run
wrapper (run height), and field annotation wrapper (16px fallback).
* test: add regression test for maxCursorY overlap fix (SD-1869)
Verifies that mid-page column transitions start below the tallest
column when columns have unequal heights. Uses a 3-col → 2-col
transition to exercise the maxCursorY tracking without triggering
the new-page guard (columnIndexBefore >= newColumns.count).
* fix: use browser default font size for math run fallback
run.height is a layout heuristic that can reach 80–100px for tall
expressions (fractions, equation arrays). Using it as the wrapper's
font-size made the plain-text fallback render at that size. Swap to
BROWSER_DEFAULT_FONT_SIZE (16px) — MathML has its own scaling, so the
value only affects the textContent fallback path.
---------
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
* chore(deps): update dependency rollup to v4.60.2 (#2863)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
* feat(corpus): seed worktree pulls from the primary repo via hardlink (#2867)
Pulling the full corpus (~450 .docx files, ~100 MB) on every new
worktree is wasteful: the bytes are identical to the primary repo
already sitting on disk. Detect when we're in a git worktree via
`git rev-parse --git-common-dir`, then hardlink any files the primary
has into the worktree's corpus dir before hitting R2.
Only files the primary is missing go over the network. --force still
re-downloads from R2 (we unlink the destination before writing so the
primary's inodes never get clobbered). --no-seed opts out for users
who want the old behaviour.
* feat(math): implement m:box and m:borderBox converters (#2750)
* feat(math): implement m:box and m:borderBox converters (closes #2605)
Made-with: Cursor
* fix(math): parse full ST_OnOff values in borderBox converter
Made-with: Cursor
* fix(math): fall back to mrow when borderBox hides all sides with no strikes
Made-with: Cursor
* fix(math): address review findings for m:box/m:borderBox
- isOn now checks m:val === undefined instead of !el.attributes so
elements with namespace-only attributes are still treated as on,
matching the ST_OnOff default per §22.9.2.7.
- convertBorderBox returns null for empty m:e, consistent with
convertBox and convertFunction (no empty <menclose> wrappers).
- m:box JSDoc now reflects that boxPr semantics (opEmu, noBreak,
aln, diff, argSz) are silently dropped — not "purely a grouping
mechanism".
- Registry comment drift fixed: m:box and m:borderBox moved into
the Implemented block.
- Tests: strike direction mapping (BLTR→up, TLBR→down, V), full
ST_OnOff matrix (1/true/on/bare/0/false), tightened assertions to
exact-string equality, pinned the current boxPr-drop behavior.
* feat(math): polyfill MathML <menclose> via CSS
MathML Core (Chrome 109+, 2023) dropped <menclose> — no browser paints
it natively. Without this, m:borderBox content imports correctly but
renders invisibly. Ship a small CSS polyfill that maps every notation
token to borders or pseudo-element strike overlays:
- box / top / bottom / left / right → CSS border sides
- horizontalstrike / verticalstrike → ::after gradient layer (H or V)
- updiagonalstrike / downdiagonalstrike → layered gradients via CSS
custom properties so X patterns stack correctly
Wired through the existing ensure*Styles pattern in renderer.ts. Zero
bundle cost, no runtime polling, fully semantic (the DOM still says
<menclose notation="box">).
* fix(math): correct diagonal strike directions in menclose polyfill
CSS linear-gradient direction keywords confusingly produce stripes
perpendicular to the direction vector:
- "to top right" progresses toward the top-right corner, which makes
the visible color stripe run top-left to bottom-right ("\")
- "to bottom right" progresses toward the bottom-right corner, which
makes the stripe run bottom-left to top-right ("/")
The polyfill had them swapped, so updiagonalstrike rendered as "\"
and downdiagonalstrike as "/" — the opposite of what Word shows and
what MathML 3 specifies. Swap the direction keywords and add a
comment so the next reader doesn't re-flip them.
* fix(math): wrap borderBox content in <mrow> for horizontal row layout
MathML Core does not define <menclose>, so Chrome treats it as an
unknown element and does not run the row-layout algorithm on its
children. Each child rendered with display: block math and stacked
vertically — a multi-element expression inside a borderBox (e.g.
Annex L.6.1.3's a² = b² + c²) became a column of letters.
Wrap the content in an inner <mrow> before appending to <menclose>.
<mrow> is in MathML Core, so the row layout runs on its children and
everything stays inline. The outer <menclose> remains the polyfill
target for borders and strikes.
* test(behavior): cover m:borderBox + menclose polyfill end-to-end
Loads the 30-scenario fixture (sd-2750-borderbox.docx) and asserts:
- every scenario produces the expected notation attribute in DOM order
- multi-child content (Annex L.6.1.3: a² = b² + c²) renders as a
horizontal row — width > 1.5× height, inner <mrow> present, 5 children
- ST_OnOff variants (1/true/on/bare/0/false) resolve correctly through
the full import path, not just the unit converter
- m:box silently drops boxPr (opEmu/noBreak/aln/diff) and emits <mrow>
- the menclose CSS polyfill stylesheet is injected into the document
Runs across chromium/firefox/webkit. Complements the 53 unit tests by
exercising the cross-package path: OMML import → pm-adapter →
painter-dom → rendered MathML.
---------
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
* fix: remove toolbar window listeners on unmount (#2783)
* fix(super-editor): remove toolbar window listeners on unmount
* fix: setup window listeners on onMounted and onActivated
* test: assert Toolbar window listener cleanup
* fix(super-editor): unmount toolbar Vue app on SuperToolbar.destroy
Without this, the Toolbar component's onBeforeUnmount hook never runs
when SuperDoc is destroyed, so the window resize/keydown listeners this
PR registers are never removed. Verified in the dev app: dispatching a
resize event after destroy still fired the toolbar handler; with the
unmount call the handlers are cleanly removed.
Also:
- drop dead teardownWindowListeners() call inside setupWindowListeners
(addEventListener is idempotent for the same type+listener pair)
- add afterEach(vi.restoreAllMocks) to Toolbar.test.js so spies don't
leak across tests if an assertion throws
---------
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
* docs: add ArturQuirino to community contributors (#2868)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* docs: update Angular integration examples for modern APIs (#2781)
* docs: uptade Angular example to use modern angular features, like input and viewChild signals
* docs: update Angular documentation to instruct how to use in version prior to 17.1
* docs(angular): correct signal-API version requirements
viewChild() landed in 17.2 (not 17.1) and is stable only from Angular 19;
input() is 17.1+ and stable from 19. Verified via tsc against
@angular/core@17.1.0: 'has no exported member named viewChild'.
Also aligns onReady log string with React/Vue docs.
---------
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
* chore(deps): update dependency @anthropic-ai/claude-agent-sdk to v0.2.114 (#2862)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
* chore(deps): update vue monorepo to v3.5.32 (#2865)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
* fix(math): preserve italic on variables inside function-wrapped limits (SD-2538) (#2871)
* fix(math): preserve italic on variables inside function-wrapped limits (SD-2538)
Word wraps lim_(n→∞) as m:func > m:fName > m:limLow. convertFunction
was calling forceNormalMathVariant with querySelectorAll('mi'), which
recursed into the <munder> produced by m:limLow and stripped italic
from every variable inside m:lim.
Scope forceNormalMathVariant so it walks only non-structural children
and never overwrites an existing mathvariant — preserves m:sty/m:scr
authored styling on the function name and leaves nested limit,
subscript, superscript, matrix cells, etc. untouched.
* docs(llms): mention native math equation rendering
* test(math): add behavior-level regression for SD-2538
Pins the end-to-end shape of the fix by loading math-limit-tests.docx and
asserting that every <mi> inside a lim-based <munder>'s limit expression
has no mathvariant attribute — i.e. variables in m:lim stay italic when
m:limLow is wrapped in m:func > m:fName. Matches the canonical shape
produced by Word's own OMML2MML.XSL.
* refactor(math): apply review feedback on SD-2538 fix
- Rename STRUCTURAL_MATHML → MATH_VARIANT_BOUNDARY_ELEMENTS; the intent is
"where the walk stops", not "which elements are structural".
- Drop the inner walk closure and the Array.from snapshot — the outer
function already recurses cleanly; neither earned its keep.
Tests:
- Fold the SD-2538 unit test into the adjacent "m:limLow in m:func" test;
the fixture shape was identical, only the assertions differed.
- Extend "m:limUpp in m:func" with the symmetric mathvariant assertions so
the 'mover' entry of the boundary set is pinned, not just 'munder'.
- Add a preserve-branch test (m:sty=i on function name) to pin the
!hasAttribute guard — previously no test would have failed if we'd
removed it.
- Behavior test: assert exact count (5 munder + 1 mover) instead of >0,
and cover both limLow and limUpp in the same sweep.
Review context: Word's own OMML2MML.XSL confirmed as parity target. The
subtree-opaque concern from Codex was checked against the XSL output for
m:func > m:fName > m:sSub (f_i(x)) and matches our post-fix behavior.
* fix(math): restore Array.from on HTMLCollection iteration
The review-driven removal of Array.from broke type-check in CI:
HTMLCollection is not iterable under the default lib.dom.d.ts (needs
`dom.iterable`), so `for…of root.children` fails TS2488. Bun's looser
runtime type-check let it slide locally. Restoring Array.from with a
comment explaining why.
* feat(toolbar): auto-derive font-option style from label/key (SD-2611) (#2874)
* feat(toolbar): auto-derive font-option style from label/key (SD-2611)
Consumers passing { label, key } to modules.toolbar.fonts got a dropdown
where every row rendered in the toolbar's UI font — rows only render in
their own typeface when each option carries props.style.fontFamily, and
the API silently expected callers to know that shape. This adds a small
normalizer that fills in props.style.fontFamily (from key or label) and
data-item, so the minimal { label, key } shape just works.
* docs(toolbar): align fonts API docs and FontConfig type with runtime behavior
The docs said `key` is 'font-family CSS value applied to text', but the
runtime applies `option.label` — the font dropdown doesn't set
`dropdownValueKey`, so ButtonGroup falls back to `option.label` when
emitting setFontFamily. The sample `{ label: 'Times', key: 'Times New Roman' }`
also violated the active-state matcher (which compares `label` to the first
segment of the document's font-family), so the 'active' chip never lit up.
Rewrites the field descriptions to match what each value actually does,
fixes the example to follow the label-equals-first-key-segment convention,
and surfaces the optional `props.style.fontFamily` preview override.
* SD-2447 - fix: tab stop computation and alignment for TOC styles (#2731)
* fix: toc not loading on paragraphs
* fix: tab stop computation and alignment for TOC styles
* fix: tests
* fix: don't decode every child twice
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
* fix: build
* refactor: removed unused code
* chore: small code tweaks
* chore: small code tweaks
* chore: removed unused code
* fix(layout-engine): tab-alignment parity, TOC schema invariant, regression tests
Follow-ups from PR review:
- measuring/dom: sync sequentialTabIndex with explicit tabIndex values (mirror remeasure.ts).
- Document getAlignmentStopForOrdinal as a Word-compat heuristic, not ECMA 17.3.3.32 behavior.
- tableOfContents encoder: normalize every non-paragraph child into its own paragraph
so the schema invariant (paragraph*) holds for mixed inline + paragraph input, not just
the all-inline case.
- Add regression tests: 3-tabs + 2-alignment-stops asymmetric case, mixed start+end stops
preserving legacy defaults-after-rightmost behavior, tightened TOC leader assertion.
* test(behavior): add TOC tab-alignment regression for SD-2447
Playwright spec that loads a TOC1 fixture and asserts leader endpoints align,
page numbers right-justify, and leader start positions vary with title length
(proving the first tab falls on the default grid, not the right stop).
Fixture is a Word-native 14KB DOCX with a right-aligned dot-leader tab stop
on TOC1 style — the same paragraph shape as the reported Keyper document.
---------
Co-authored-by: Gabriel Chittolina <gabrielchittolina1@gmail.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
* refactor(config): consolidate track-changes config under modules.trackChanges (SD-2608) (#2847)
* refactor(config): consolidate track-changes config under modules.trackChanges (SD-2608)
track-changes config lived in two places (config.trackChanges for visibility,
config.layoutEngineOptions.trackedChanges for mode/enabled), unlike every other
module which sits under config.modules.*. consumers had to learn three keys
for one module. this adds modules.trackChanges as the canonical path and
keeps both legacy keys working as deprecated aliases with one-time warnings.
- new normalizer in core/helpers resolves canonical + legacy with precedence
new > legacy > derived default, and mirrors values back to legacy paths so
the ~14 internal reads keep working untouched
- accepts all four TrackedChangesMode values (review | original | final | off)
to preserve existing pass-through of layoutEngineOptions.trackedChanges
- suppresses re-warning on a second normalization of the same config object
so write-through values don't look like new legacy usage
- JSDoc, Mintlify docs, and the one SuperDoc.test.js legacy use-site updated
* feat(track-changes): add pairReplacements mode matching Word/ECMA-376 (SD-2607) (#2849)
* feat(track-changes): add pairReplacements mode matching Word/ECMA-376 (SD-2607)
when track changes are on and a user replaces text, SuperDoc groups the
insertion and deletion under one shared id so accepting or rejecting takes
one click (Google-Docs-like). Microsoft Word and ECMA-376 §17.13.5 treat
every <w:ins> and <w:del> as an independent revision with its own required
w:id. a consumer wants their UI to match Word.
this adds modules.trackChanges.pairReplacements (default true preserves
current behaviour). when false, each insertion and each deletion is an
independent change, addressable and resolvable on its own.
- importer: buildTrackedChangeIdMap accepts { pairReplacements }; when
false, skips the adjacent w:del+w:ins pairing so each Word w:id maps to
its own UUID
- insertTrackedChange: no shared id on replacements in unpaired mode
- getChangesByIdToResolve: returns just the single matching mark in
unpaired mode (no neighbor walk)
- wiring: SuperDoc.vue -> editor.options.trackedChanges -> Editor.ts ->
SuperConverter.trackedChangesOptions -> docxImporter
no exporter change needed — <w:ins>/<w:del> are already written per-mark
with their own w:id in both modes. no public API shape change.
* fix(track-changes): address review findings on pairReplacements (SD-2607)
- always walk adjacent same-id marks in getChangesByIdToResolve so a single
logical revision split across multiple segments resolves as one unit; the
unpaired case is handled implicitly because ins/del now have distinct ids
- align changeId with the insertion mark's id so comment threads and the
optional comment reply attach to the same thread in unpaired mode
- simplify id-minting: one primary id anchors the operation; the deletion
mints its own fresh id only when unpaired AND it's a replacement. the
Document API write adapter now gets unpaired revisions when the flag is
off without any adapter-level change
- add trackedChanges?: {...} to EditorOptions so consumers don't need casts
- add an unpaired-mode example snippet to the docs
- extension test now covers the headline guarantee: in unpaired mode,
acceptTrackedChangeById(insertionId) resolves only the insertion, and the
deletion is still independently rejectable by its own id
* fix(types): tighten JSDoc signatures so type-check passes (SD-2608)
the normalizer and tracked-change mapper emit strict typedefs but several
helper signatures were relying on implicit any, and the NORMALIZED_MARKER
symbol indexed an under-typed config object. CI type-check rejected these
once @ts-check saw the full types.
- normalize-track-changes-config.js: explicit JSDoc param types on
helpers, Record<string, any> on config so module/track-chains resolve,
symbol cast for the NORMALIZED_MARKER access
- trackedChangeIdMapper.js: nested walk now propagates pairReplacements
instead of emitting an under-typed WalkContext
* refactor(track-changes): rename pairReplacements → replacements enum (SD-2608)
pairReplacements leaked editor-speak and forced consumers to mentally
invert a boolean to get the Word-style model. rename to a self-documenting
enum that sits alongside future per-behavior siblings under modules.trackChanges.
- public surface: `modules.trackChanges.replacements: 'paired' | 'independent'`
default `'paired'` (no behavior change for existing consumers)
- plumbing: Editor.options.trackedChanges.replacements → SuperConverter
trackedChangesOptions → buildTrackedChangeIdMap; parallel path through
trackedTransaction → replaceStep so user-driven replacements (the Firefox
coalescing path) also honor the flag
- replaceStep now passes id: undefined to markDeletion in 'independent'
mode so the deletion mints its own id — closing the UI-typing gap
- docs: configuration.mdx ParamField + example use the enum; track-changes
extension doc gets a new "Revision model" section explaining paired vs
independent with snippets
- tests: normalizer, mapper, extension, and a new behavior spec
(tracked-change-independent-replacement.spec.ts) exercise the enum
end-to-end across chromium/firefox/webkit; harness + fixture accept a
`replacements` URL param so tests can flip the flag
* docs(track-changes): migrate from extensions to modules (SD-2608)
with SuperDoc moving away from ProseMirror and extensions no longer
appearing in the public nav, track changes reads better as a first-class
module page alongside comments. the new modules/track-changes.mdx
consolidates what consumers actually need: configuration, revision model
(paired vs independent), the Document API (editor.doc.trackChanges.*),
editor commands, events, permissions, and DOCX import/export.
- new: apps/docs/modules/track-changes.mdx modeled on modules/comments.mdx
- nav: docs.json lists modules/track-changes after modules/comments
- redirect: /extensions/track-changes → /modules/track-changes so
bookmarked URLs and external links keep working
- deleted: apps/docs/extensions/track-changes.mdx (was hidden anyway)
- updated: modules/overview, extensions/overview, and superdoc/configuration
cross-links point at the new page
* docs(track-changes): correct docs against codebase + lead with Document API (SD-2608)
audited every claim on modules/track-changes.mdx against packages/ and fixed
the drift. the Document API is now the primary surface; legacy editor
commands are grouped under a single deprecated section.
corrections:
- event payload is flat ({ type: 'trackedChange', event, changeId, ... }),
not nested under { type, comment }. fields trackedChangeType, trackedChangeText,
deletedText, trackedChangeDisplayType, author, authorEmail, date, importedAuthor
come straight from comments-plugin.js:1134
- event values pulled from shared/common/event-types.ts: add, update, deleted,
resolved, selected, change-accepted, change-rejected
- permissions include RESOLVE_OWN / RESOLVE_OTHER (accept) alongside
REJECT_OWN / REJECT_OTHER (permission-helpers.js:3-12)
- trackChanges.list() returns .items (not .changes) per DiscoveryOutput
- removed disableTrackChangesShowFinal() — not implemented
- mark names confirmed: trackInsert / trackDelete / trackFormat
restructuring:
- Document API section moved up and gets the full TrackChangeInfo shape
- editor.commands usage moved to a "Legacy editor commands" section at the
bottom, wrapped in a Warning banner pointing at the Document API
- voice aligned with brand.md: shorter sentences, concrete examples,
"Import it. Edit it. Export it. Nothing lost." over abstract claims
no code changes.
* docs(track-changes): use Lucide play icon for the example card (SD-2608)
* docs(track-changes): use git-compare icon to match module theming (SD-2608)
* feat(dev-app): add tracked-replacements mode toggle (SD-2608)
expose modules.trackChanges.replacements in the SuperDoc dev app so
developers can test both 'paired' (Google Docs) and 'independent' (Word)
revision models without editing code.
- reads the initial value from ?replacements=independent in the URL so
test links can pin a specific mode
- switching via the header <select> updates the URL param and reloads,
same pattern used by the Layout Engine and View Layout toggles
- moved the legacy top-level trackChanges: { visible: true } config to
modules.trackChanges.{visible, replacements} so the dev app stops
triggering its own deprecation warning
* fix(math): split multi-char m:r text per character to match Word (SD-2632) (#2875)
* fix(math): split multi-char m:r text per character to match Word (SD-2632)
Word's OMML2MML.XSL classifies each character in an m:r run individually —
digits group into a single <mn> (with one optional decimal point between
digits), operator characters each become their own <mo>, and everything
else becomes its own <mi>. SuperDoc was emitting the entire run text as
one element, so runs like "→∞" or "x+1" rendered as a single <mi>, losing
operator spacing and semantics.
tokenizeMathText implements the per-character classification. convertMathRun
returns a single Element for one-atom runs and a DocumentFragment when
multiple atoms are emitted, so siblings flow directly into the parent's
<mrow> without an extra wrapper.
m:fName is the documented exception — Word keeps multi-letter function
names like "sin" or "lim" as one <mi> inside the function-name slot.
convertFunction routes m:r children through convertMathRunWhole (no
splitting), and a new collapseFunctionNameBases pass re-merges the base
slot of any structural MathML element (munder/mover/msub/…) that Word
nested inside m:fName — without this, "lim" inside m:limLow would
incorrectly split to three <mi>.
Also drops U+221E (∞) from OPERATOR_CHARS — it's a mathematical constant
per Word's XSL, not an operator.
MathObjectConverter's return type widens from Element|null to Node|null
so convertMathRun can return a DocumentFragment. All other converters
already return Element, which is assignable to Node — no other changes.
Verified against real Word-native fixtures: `→∞` in the limit-tests
fixture case 1 now renders as <mi>n</mi><mo>→</mo><mi>∞</mi> (matches
Word OMML2MML.XSL byte-for-byte), and nested limits keep their function
names intact.
Ref ECMA-376 §22.1.2.116, Annex L.6.1.13, §22.1.2.58.
* fix(math): group letters inside m:fName mixed runs + cover mmultiscripts (SD-2632)
Follow-up to /review feedback backed by fresh Word OMML2MML.XSL evidence:
- `convertMathRunAsFunctionName` (renamed from `convertMathRunWhole` since
"whole" no longer fits) now groups consecutive non-digit / non-operator
characters into one <mi> while still splitting digits and operators. Word's
XSL for `<m:fName><m:r>log_2</m:r></m:fName>` produces
`<mi>log</mi><mo>_</mo><mn>2</mn>` — not `<mi>l</mi><mi>o</mi><mi>g</mi>…`.
- `BASE_BEARING_ELEMENTS` gains `mmultiscripts` — Word emits it when an
`m:sPre` sits inside `m:fName`; our base-collapse pass needs to know to
merge the first-child <mi> run.
- CONTRIBUTING.md now documents the widened `Node | null` return type.
Tests added:
- Direct `tokenizeMathText` edge cases: `.5` / `5.` / `1.2.3` / `2x+1` /
consecutive operators / empty / standalone ∞.
- m:fName mixed-content: `log_2` stays `<mi>log</mi><mo>_</mo><mn>2</mn>`.
- Base collapse inside nested `m:sSub` under `m:fName`.
- Base collapse inside nested `m:sPre` (mmultiscripts) under `m:fName`.
- Behavior test tightened to pin the full 3-atom sequence for `n→∞`.
Disputed during review and deferred with evidence:
- Opus claim that standalone `m:limLow` with "lim" base regresses to italic:
Word XSL itself splits "lim" per-char in that shape (with or without
m:sty=p), so our output matches Word.
- Codex claim that Arabic-Indic digits should be `<mn>`: Word XSL also
classifies them as `<mi>`, so our behavior matches.
- Non-BMP surrogate-pair support: edge case in extreme mathematical
alphanumerics; Word XSL itself errored on U+1D465. Separate ticket worth.
* fix(math): iterate tokenizer by code point to preserve surrogate pairs
Addresses Codex bot review on PR #2875. Astral-plane mathematical
alphanumerics (e.g. U+1D465 mathematical italic x, U+1D7D9
mathematical double-struck 1) are UTF-16 surrogate pairs. Walking
text by code unit split them into two half-pair <mi> atoms with
invalid content. `codePointUnitLength` returns 2 when the current
position starts a surrogate pair so tokenizeMathText and
tokenizeFunctionNameText step across the full code point.
* fix(react): stabilize user/users/modules props to prevent re-init flicker (SD-2635) (#2876)
* fix(react): stabilize user/users/modules props to prevent re-init flicker (SD-2635)
Consumers passing inline object literals (the idiomatic React pattern) caused
a full SuperDoc destroy + rebuild on every parent re-render because the
main useEffect compared these props by reference identity. When a consumer
stored the SuperDoc instance in state from onReady, the resulting re-render
supplied fresh references and triggered another cycle — observed as 4 full
destroy/re-init cycles in ~100ms with visible display:none/block flicker.
Wrap user, users, and modules in a new useStableValue hook that returns a
reference-stable version only changing identity when the structural content
actually changes (via JSON.stringify compare, run only on reference-change).
Semantics are strictly a superset of the prior behavior — value changes still
rebuild; reference-only changes no longer do.
* fix(react): drop modules from structural memo; harden tests (SD-2635 review round 2)
Review feedback addressed:
- Correctness: `modules` carries function-valued options (`permissionResolver`,
`popoverResolver`) and live objects (`collaboration.ydoc`/`provider`) that
JSON.stringify silently drops or collapses. Keeping it on structural
compare would silently ignore real config changes. Reverted to reference
identity for `modules`; `user` and `users` stay memoized since they are
plain data.
- Naming: renamed `useStableValue` → `useStructuralMemo` to match the
useMemo family and signal non-referential equality.
- JSDoc: expanded the hook's docblock to spell out every JSON.stringify
footgun consumers need to know about (functions dropped, class instances
collapsed, undefined values dropped, NaN/Infinity → null, circular refs,
key-insertion-order sensitivity).
- Tests: replaced the brittle `setTimeout(100)` negative assertion with a
synchronous `ref.getInstance()` identity check; strengthened the "rebuilds
on change" test to also assert a second onReady + a fresh instance;
added a `users`-prop stability test; added a StrictMode + rerender test
to guard the ref-write-during-render path.
* fix(react): define event types explicitly to unblock CI type-check (SD-2635)
CI type-check failed with `Property 'onEditorUpdate' does not exist on
type 'Config'` even though the JSDoc `Config` typedef in superdoc clearly
declares it. The old approach derived SuperDocEditorUpdateEvent and
SuperDocTransactionEvent via
`Parameters<NonNullable<SuperDocConstructorConfig['onEditorUpdate']>>[0]`,
which walked a chain:
ConstructorParameters<typeof SuperDoc>[0]
→ @param {Config} in core/SuperDoc.js
→ @typedef Config in core/types/index.js
→ @property onEditorUpdate with @typedef EditorUpdateEvent
This chain resolves fine locally but breaks on CI — the exact failure
point in JSDoc resolution depends on TS version, moduleResolution mode,
and the `customConditions: ["source"]` in tsconfig.base.json (which
routes imports to the raw .js source instead of the built .d.ts).
Define the two event shapes inline instead, mirroring superdoc's JSDoc.
No behavior change for consumers — same fields, same optionality.
* fix(react): round-4 review feedback — lock modules contract, restore transaction: any (SD-2635)
Addressing consensus findings from the round-3 review:
- Revert `transaction: unknown` back to `any` to match superdoc's upstream
typedef. `unknown` was a narrowing from `any` that would have broken
existing consumer code like `event.transaction.docChanged`.
- Re-export `EditorSurface` from the package barrel. It's referenced by
two public event interfaces but wasn't exported, so consumers couldn't
name the `surface` field's type.
- Symmetrize per-field JSDoc on `SuperDocTransactionEvent` to match its
sibling `SuperDocEditorUpdateEvent`.
- Add a regression test asserting that passing a new `modules` object
with identical content DOES rebuild the editor. This pins the contract
that `modules` stays on reference identity (it can carry functions and
live objects that structural compare misses) — a future "cleanup" that
wraps `modules` in useStructuralMemo would silently re-introduce the
SD-2635 blocker without this test.
Also trim commentary added during rounds 1-3: the stabilization rationale
was documented twice in SuperDocEditor.tsx (once at the destructure,
once near the dep array), and the types.ts docstrings leaked maintainer
build-tooling rationale into consumer IDE hovers.
* refactor(react): swap JSON.stringify compare for lodash.isequal; drop TransactionEvent (SD-2635)
- Swap the hand-rolled JSON.stringify-based structural compare for
`lodash.isequal`. +10KB raw / +3.7KB gzipped cost, but removes the
5-bullet footgun list from JSDoc and gets proper handling of Dates,
Maps, circular refs, and key ordering for free. For our use (stabilizing
`user`/`users` plain-data records) the 3.7KB buys nothing practical,
but it removes ~40 lines of hand-maintained code and a bag of edge
cases. Trade maintenance cost for bundle cost.
- Rename `useStructuralMemo` → `useMemoByValue`. Plainer, matches the
useMemo family, says what it does without jargon.
- Drop `SuperDocTransactionEvent` from the public API. It was exported
but never wired up to a callback prop in `CallbackProps`, so nothing
fires it. Its shape also leaked ProseMirror's `transaction` object —
a deprecated surface per superdoc's own notes. Removing it now is
cheaper than removing it after someone starts relying on it.
- Replace the JSON-stringify-specific unit tests with tests that exercise
what lodash.isequal actually gives us (key-order insensitivity,
same-reference function equality). 27/27 tests pass.
* refactor(react): drop lodash.isequal dep, inline JSON.stringify compare (SD-2635)
Round 5 added lodash.isequal for `useMemoByValue` correctness at the
cost of +10KB raw / +3.7KB gzipped. For the two props actually using the
hook (`user` and `users` — small plain-data records) the extra
correctness buys nothing practical: those records contain strings only,
no Dates, no Maps, no circular refs, no functions.
Revert to an inline JSON.stringify compare wrapped in a `try/catch` for
circular refs. The hook is now ~15 lines, zero dependencies, and the
unit test that required lodash (key-order insensitivity) is replaced by
a circular-ref fallback test that matches the implementation.
Bundle is back to 3.69 KB / 1.60 KB gzipped.
* test(react): cover StrictMode + user prop value change (SD-2635)
The existing StrictMode test only proved same-content rerender stays
stable — the positive path (real value change under StrictMode still
triggers destroy + fresh onReady) wasn't exercised. Coverage audit
after rounds 2-6 flagged this as the one gap worth closing before
ship. Mirrors the existing non-StrictMode rebuild test, wrapped in
<StrictMode>.
* feat(layout): enable odd/even header-footer support (w:evenAndOddHeaders) (#2804)
* feat(layout): enable odd/even header-footer support (w:evenAndOddHeaders)
Thread alternateHeaders from document settings through the layout
engine so the paginator selects the correct header/footer variant
per page. Fixes margin calculation for documents with different
odd and even page headers.
Also fixes getVariantTypeForPage to use document page number
(not section-relative) for even/odd selection, matching the
rendering side (headerFooterUtils.ts).
Closes #2803
* fix: address PR review — type guard + multi-section/footer/fallback tests
- Guard alternateHeaders behind isSemanticFlow check to match
ResolvedLayoutOptions type (paginated-only field)
- Add multi-section test: section 2 starting on page 4 (even by document
number, verifies the documentPageNumber fix)
- Add footer test: even/odd footer heights with alternateHeaders
- Add default-only fallback test: only default header defined
* test(layout): strengthen alternateHeaders tests and thread flag via resolver
Review round 2 follow-ups on PR #2804.
Tests
- Footer test now goes through sectionMetadata.footerRefs + footerContentHeightsByRId
(the real path) and asserts page.margins.bottom. The old test only checked body-top
y, which is footer-independent — stubbing getFooterHeightForPage to always return 0
left that assertion passing. Mutation-tested: forcing getVariantTypeForPage to
always return 'default' now breaks it.
- Default-only fallback test now exercises the production path (headerRefs.default +
per-rId heights) and asserts the correct outcome (y ~= 90 via the step-3 default
fallback). Old assertion of y ~= 50 codified a code path that never runs in
production because document imports always supply section refs. Mutation-tested:
removing the step-3 fallback breaks this test.
- New combined test: multi-section + titlePg + alternateHeaders, where section 2
has titlePg=true and starts on an even document page. Guards both the titlePg
interaction across a section boundary AND the documentPageNumber (not
sectionPageNumber) rule on pages 5 and 6. Mutation-tested: reverting to
sectionPageNumber breaks this test alongside the original multi-section case.
Layout engine
- getVariantTypeForPage now takes a named-params object. Two adjacent `number`
params (sectionPageNumber, documentPageNumber) are swap-vulnerable.
- JSDoc on LayoutOptions.alternateHeaders cross-references
getHeaderFooterTypeForSection in layout-bridge — the two sides must agree on
variant selection and the pointer helps future maintainers keep them in sync.
PresentationEditor
- alternateHeaders is now populated inside #resolveLayoutOptions, alongside the
other paginated-only fields (columns, sectionMetadata). The render-site spread
collapses back to the single ternary it was before, and the
`as EditorWithConverter` cast there disappears. types.ts didn't need changes —
the field was already declared on the paginated variant of ResolvedLayoutOptions
but unpopulated; it's now legitimately set by the resolver.
* test(alternate-headers): add unit + integration + behavior coverage
Follow-up to round 2 review. Closes the three test gaps flagged in the
gap analysis:
Unit (PresentationEditor threading)
- 3 tests in PresentationEditor.test.ts assert that
`converter.pageStyles.alternateHeaders` is forwarded to the layout
options that reach `incrementalLayout`. Covers true, unset, and
falsy-non-boolean cases. A refactor that drops the threading no
longer passes silently.
Unit (docxImporter)
- Export `isAlternatingHeadersOddEven` and add 4 tests covering the
import-side signal: `<w:evenAndOddHeaders/>` present, absent,
missing settings.xml, empty settings. Pins the contract between
OOXML settings and `pageStyles.alternateHeaders`.
Behavior (Playwright)
- `tests/headers/odd-even-header-variants.spec.ts` loads
`h_f-normal-odd-even.docx` (already in the corpus) and asserts:
- pages 1/3 render the default/odd header text, pages 2/4 render
the even header text
- page 1 and page 3 use the same `data-block-id` (same rId) but
differ from page 2 — catches regressions that produce the right
text from the wrong rId
- footers follow the same alternation
The existing layout/visual corpus already includes
`h_f-normal-odd-even*.docx` and `sd-2234-even-odd-headers.docx`, so
rendering regressions show up in `pnpm test:layout` and
`pnpm test:visual` without any additional wiring.
---------
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
* fix: image not being contained within cell (#2775)
Co-authored-by: Gabriel Chittolina <gabrielchittolina1@gmail.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
* fix: table cell selection with context menu in collab (#2822)
* fix: serif fallback for serif-like fonts (#2870)
Co-authored-by: Artem Nistuley <artem@superdoc.dev>
* fix(layout-bridge): keep footnote fragments within the reserved band (SD-1680) (#2881)
* fix(layout-bridge): keep footnote fragments within the reserved band (SD-1680)
When multiple footnotes competed for space on a single page, footnote
fragments could render past the bottom page margin — sometimes 150+ pixels
into the footer area or off the page entirely. Caused by three interacting
issues in `computeFootnoteLayoutPlan` + the multi-pass reserve loop:
1. Placement used `maxReserve` (~the whole page) as the height ceiling, so
the plan happily placed more footnote content than the body layout had
actually reserved at the bottom.
2. The multi-pass stabilization loop could oscillate when reducing the
reserve shifted refs between pages, and exit with a `reserves` vector
that didn't match what the plan placed — leaving body layout and
footnote plan inconsistent.
3. The post-loop "reservesDiffer → relayout" branch could then reintroduce
the mismatch by laying out with one reserve vector while the final plan
operated on another.
Fixes:
- `computeFootnoteLayoutPlan`: when `baseReserve > 0`, cap `placeFootnote`'s
`availableHeight` to `baseReserve` (the band actually reserved by the
body layout), not `maxReserve`. Excess footnotes are pushed to the next
page via the existing `pendingByColumn` mechanism. Initial pass
(`baseReserve === 0`) still uses `maxReserve` to seed a meaningful
reserve for subsequent passes.
- Multi-pass loop: merge reserves element-wise with `max(prev, next)` each
pass. Reserves are monotonically non-decreasing, so the loop is
guaranteed to converge (bounded by `maxReserve` per page) and the old
`[247] ↔ [394]` oscillation can't recur.
- Post-loop: drop the `reservesDiffer → relayout` branch. With the
monotonic loop already converged and placement bounded by `reserves`,
`finalPlan`'s slices always fit within the body's reserved band. The
extra relayout was the mechanism that reintroduced inconsistency.
Visual validation (in-browser) on the 3 fixtures from the Linear issue:
- reporter-test.docx: max overflow 156px → 0px
- harvey-nvca.docx: overflow present → 0px
- torke-carlsbad.docx: overflow present → 0px
Tests:
- 2 new tests in footnoteBandOverflow.test.ts asserting the invariant
"every footnote fragment's bottom-Y ≤ page's physical bottom limit"
for both multi-footnote pressure and oversized-footnote cases.
- 1168 layout-bridge tests pass, 604 layout-engine tests pass,
1737 pm-adapter tests pass, 11385 super-editor tests pass.
Follow-up (Step 3, separate PR): footnote-aware body pagination — consult
footnote heights when deciding whether paragraph P fits on page N, rather
than reactively shrinking body via reserves. This would eliminate the
multi-pass loop entirely.
* fix(layout-bridge): drive footnote reserve from per-page demand (SD-1680)
Cap footnote placement at each page's measured demand (capped at maxReserve)
rather than the body's prior-pass reserve, and merge element-wise max across
oscillating reserve vectors before the post-loop relayout. Ensures every
page's reserve is large enough to hold its placed footnotes so fragments
cannot render past the bottom margin, and iterates the post-loop relayout
until the layout's reserve per page matches the final plan's demand.
* fix: allow putting cursor after inline sdt field (#2763)
* fix: allow putting cursor after inline sdt field
* fix: simplify code; create helper file
* fix(react): restore SuperDocTransactionEvent export and wire onTransaction callback
* fix(layout): respect explicit column widths when drawing separators
* fix(importer): parse evenAndOddHeaders ST_OnOff values correctly
---------
Co-authored-by: superdoc-bot[bot] <235763992+superdoc-bot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
Co-authored-by: João Vitor Verona Biazibetti <joaaoverona@gmail.com>
Co-authored-by: michaelreavant <michael@reavant.ai>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
Co-authored-by: Luccas Correa <luccas@harbourshare.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Abdel ATIA <223786362+Abdeltoto@users.noreply.github.com>
Co-authored-by: ARTUR QUIRINO <artur.pquirino@gmail.com>
Co-authored-by: Gabriel Chittolina <163901514+chittolinag@users.noreply.github.com>
Co-authored-by: Gabriel Chittolina <gabrielchittolina1@gmail.com>
Co-authored-by: G Pardhiv Varma <gpardhivvarma@gmail.com>
Co-authored-by: Artem Nistuley <101666502+artem-harbour@users.noreply.github.com>
Co-authored-by: Artem Nistuley <artem@superdoc.dev>
Co-authored-by: Tadeu Tupinambá <tadeu.tupiz@gmail.com>
Co-authored-by: VladaHarbour <114763039+VladaHarbour@users.noreply.github.com>
harbournick
added a commit
that referenced
this pull request
Apr 22, 2026
* chore: merge stable into main (#2858)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* docs(pm-adapter): note why w:bar is not normalized
Word does not render w:bar borders on display (only preserves them on
save). SuperDoc currently matches that behavior by omitting 'bar' from
the normalized sides list. Add a note at the source of truth so future
contributors don't re-add it without a real use case.
* feat: support rendering of column line separators (#2088)
* feat: support column line separators
- Extracts 'w:sep' tag according to OOXML spec
- Renders one separator for each page column ('w:col' / 'w:num') in DOM painter
Closes #2067
* test: add docx file with line separator between columns enabled
* fix: rendering color of separators
* fix: reuse ColumnLayout and SINGLE_COLUMN_DEFAULT
* fix: ensure column width larger than separator width
* chore: lint
* chore: fix imports and missing SINGLE_COLUMN_DEFAULT usage
* chore: reuse type
* fix: render column separators per region on pages with continuous breaks
Two related fixes so the new column-line-separator feature works correctly
on pages where a continuous section break changes column layout mid-page.
1. isColumnConfigChanging now compares withSeparator. Before, a sep-only
toggle (count+gap unchanged) returned false, so no mid-page region was
created and the toggle was silently dropped. Applied in both
section-breaks.ts and the inline fallback in index.ts.
2. constraintBoundaries captured during layout are serialized onto a new
page.columnRegions contract field. renderColumnSeparators now iterates
regions and draws each separator bounded by its yStart/yEnd instead of
painting a single full-page overlay. When no mid-page change occurs,
columnRegions is omitted and the renderer falls back to page.columns
(unchanged behavior).
Verified by loading a fixture with 7 scenarios (2-col, 3-col, unequal
widths, separator on/off, continuous breaks toggling the separator).
Pages now show per-region separators tiled correctly; a 3-col region
followed by a 2-col region no longer paints a shared full-page line.
Out of scope here, tracked for follow-up: widths/equalWidth are still
dropped at pm-adapter extractColumns, so unequal-width separators render
at the equal-width midpoint; body-level w:sep is dropped at v2
docxImporter; there is no w:sep export.
* test: cover DomPainter renderColumnSeparators
13 unit tests over the separator renderer. Splits coverage into the
fallback path (page.columns only) and the region-aware path
(page.columnRegions).
Fallback path: pins the 2-col and 3-col geometry, and each early-return
guard (withSeparator false/undefined, single column, missing margins,
no columns at all, pathologically-small columnWidth).
Region path: verifies per-region yStart/yEnd bounding, mixed regions
(some draw, some skip for withSeparator=false or count<=1), zero-height
regions, and that columnRegions wins when both it and page.columns are
present.
Previously renderColumnSeparators had zero DOM-level coverage — the
region-aware refactor in the prior commit relied entirely on
layout-engine tests that never exercised the DOM output.
---------
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
* fix(sdk): raise asyncio StreamReader buffer in Python AsyncHostTransport (#2760)
* fix(sdk): raise asyncio StreamReader buffer in Python AsyncHostTransport
The Python async transport spawned the host CLI without passing a `limit=`
to `asyncio.create_subprocess_exec`, so its stdout `StreamReader` inherited
asyncio's default 64 KiB buffer. Every host response is written as a single
newline-delimited JSON line, so any `cli.invoke` whose serialized result
exceeds 64 KiB (e.g. `superdoc_get_content` on larger documents) caused
`readline()` to raise `ValueError: Separator is not found, and chunk
exceed the limit` inside `_reader_loop`. The exception was caught by the
generic reader-loop handler and pending requests were rejected with the
misleading `HOST_DISCONNECTED` error — even though the host process was
still alive and healthy.
Pass `limit=` to `create_subprocess_exec` and expose it as a new
`stdout_buffer_limit_bytes` constructor option on `AsyncHostTransport`,
threaded through `SuperDocAsyncRuntime` and `AsyncSuperDocClient`. The
default of 64 MiB safely covers the host's own 32 MiB
`DEFAULT_MAX_STDIN_BYTES` input cap with room for ~2x JSON expansion.
`SyncHostTransport` is unaffected — it uses raw blocking `subprocess.Popen`
which has no asyncio buffer limit.
Adds a `TestAsyncLargeResponse` regression suite that:
1. Round-trips a 200 KB response through the default-configured transport.
2. Pins that an explicitly tightened `stdout_buffer_limit_bytes` still
reproduces the original failure mode, guaranteeing the option is
wired through to `create_subprocess_exec`.
* fix(sdk): tear down host process on async reader-loop failure
AsyncHostTransport._reader_loop caught reader exceptions by rejecting
pending futures and flipping state to DISCONNECTED, but never killed
self._process. Because dispose() early-returns on DISCONNECTED, any
reader-loop failure left an orphaned host subprocess running with no public API to reap it. This is a pre-existing bug, but the previous
commit made it easier to trip by exposing stdout_buffer_limit_bytes:
any caller who sets it below their real response size hits the orphan path.
Route both the buffer-overflow and generic-error branches through a
new _schedule_cleanup helper that fires _cleanup() as a separate task
(it can't be awaited inline — _cleanup cancels and awaits the reader
task itself). _cleanup kills the process, waits on it, rejects pending,
and only then transitions to DISCONNECTED, so a subsequent dispose()
is a safe no-op instead of leaking the host.
Also catch asyncio.LimitOverrunError / ValueError separately and
surface HOST_PROTOCOL_ERROR with a "raise stdout_buffer_limit_bytes"
hint plus the current limit in details. The previous HOST_DISCONNECTED
code pointed users at the wrong problem since the host was still alive.
Extends TestAsyncLargeResponse to assert HOST_PROTOCOL_ERROR, verify
the hint is in the message, confirm the subprocess is actually reaped
(returncode set, _process cleared), and that dispose() after an overflow is a safe no-op.
* refactor(sdk): dedupe stdout_buffer_limit default and add wiring test
Address review follow-ups on the async transport buffer-limit option.
- Hoist DEFAULT_STDOUT_BUFFER_LIMIT_BYTES (64 MiB) to module scope in
transport.py and reference it from AsyncHostTransport, the async
runtime, and AsyncSuperDocClient so the default lives in one place
instead of three copies of 64 * 1024 * 1024.
- Add a short "raise if a single host response can exceed this size"
comment on the client.py parameter so callers see the guidance at
the public API boundary, not buried in transport.py.
- Rename test_response_above_default_64kb_buffer to
test_response_above_asyncio_default_streamreader_limit. 64 KiB is
asyncio's default, not the SDK's (which is now 64 MiB), so the old
name read backwards after this PR.
- Add test_client_threads_stdout_buffer_limit_to_transport: builds
AsyncSuperDocClient with a custom limit and asserts the value
reaches AsyncHostTransport. Without this, a silent drop of the arg
in client.py or runtime.py would leave the existing overflow test
passing while the public API reverts to the asyncio 64 KiB default.
* fix(sdk): mark transport DISPOSING synchronously on reader teardown
Round-2 review follow-ups:
- _schedule_cleanup now flips state to DISPOSING before scheduling the
cleanup task. Previously, between the reader returning and the async
_cleanup running, _ensure_connected's CONNECTED fast path would still
accept invoke() calls; they then blocked on a future the dead reader
could never resolve until watchdog_timeout_ms (default 30s).
- Narrow the buffer-overflow catch to readline() only and drop
asyncio.LimitOverrunError from the tuple. readline() re-raises
LimitOverrunError as ValueError (it is not a ValueError subclass on
any supported CPython), so the previous broad except could
reclassify unrelated ValueErrors from dispatch as a buffer-limit
error with a misleading remediation hint. Comment corrected to match.
- Re-export DEFAULT_STDOUT_BUFFER_LIMIT_BYTES from superdoc/__init__.py
so consumers tuning the option don't import from the implementation
module.
- Tighten test_host_crash to assert HOST_DISCONNECTED specifically and
verify process teardown via the new _schedule_cleanup path.
- Strengthen the dispose-after-overflow assertion to actually verify
the no-op claim (state stays DISCONNECTED, _process stays None, a
second dispose is also safe). Replace the timing-sensitive
process.returncode read with await process.wait().
* fix(sdk): serialize teardown across reader, _kill_and_reset, and dispose
Round-2 follow-up — addresses the residual race that the synchronous
DISPOSING flip didn't cover.
Before: `_kill_and_reset()` (called from `_send_request` on stdin write
failure or watchdog timeout) `await`ed `_cleanup` directly. If a
reader-triggered `_schedule_cleanup` was in flight, both ran
concurrently and raced on `_reject_all_pending`'s read-then-clear of
`self._pending` (futures added between snapshot and clear were leaked)
and on `process.kill()`/`reader_task.cancel()`. `dispose()` similarly
short-circuited on DISPOSING without waiting for the in-flight cleanup
to finish — the caller saw "disposed" before the host was fully torn
down.
Now:
- `_kill_and_reset` and `dispose` both check the cleanup-task slot and
`await` an in-flight cleanup rather than starting a parallel one.
Single-flight teardown across all three entry points.
- `_cleanup` clears `self._cleanup_task` in `finally` when it owns the
slot, so introspection doesn't surface a stale done handle and the
next teardown gets a fresh slot.
- `dispose()` after a reader-triggered cleanup now blocks until that
cleanup finishes, restoring the "host fully torn down on return"
contract.
Tests:
- `test_schedule_cleanup_dedupe_guard_drops_reentrant_call` — second
`_schedule_cleanup` does not replace the in-flight task slot.
- `test_overflow_during_dispose_does_not_schedule_cleanup` — `_stopping`
suppression is honored.
- `test_kill_and_reset_awaits_in_flight_cleanup` — `_kill_and_reset`
observes the existing task instead of running a parallel `_cleanup`.
- `test_dispose_waits_for_in_flight_cleanup` — `dispose()` blocks until
reader-triggered cleanup completes before returning.
95 transport tests pass; 5 consecutive runs with PYTHONASYNCIODEBUG=1
show no flakes.
* fix(sdk): close residual races in async transport teardown
Two correctness regressions and three test gaps surfaced in the
final-pass review of the cleanup-task lifecycle.
**1. _ensure_connected race (HIGH).** The synchronous DISPOSING flip
in _schedule_cleanup did not gate _ensure_connected, so a concurrent
connect()/invoke() reaching _start_host during the DISPOSING window
would reassign self._process and self._reader_task. The pending
cleanup task then read those slots after its first await and killed
the freshly-spawned process. Fix: drain self._cleanup_task at the top
of _ensure_connected via asyncio.shield (so a cancelled caller doesn't
abort the in-flight cleanup).
**2. Cancellation propagation race (HIGH).** _kill_and_reset and
dispose() awaited the cleanup task without asyncio.shield. When the
caller (e.g. an invoke task at the watchdog branch) was cancelled,
asyncio cancelled the awaited cleanup task too — _cleanup did not
catch CancelledError around process.wait(), so teardown stopped before
clearing _process / setting state. dispose() then saw DISPOSING with
_cleanup_task=None and returned without finishing teardown, leaking
the host process. Fix: wrap the awaited cleanup in asyncio.shield in
both call sites; restructure _cleanup so it captures handles and sets
state synchronously up-front, before any awaits, so observable state
is always consistent.
**3. Move _stopping guard into _schedule_cleanup.** The previous
test_overflow_during_dispose_does_not_schedule_cleanup was tautological
— it set _stopping=True and then re-checked the same condition in the
test body before calling _schedule_cleanup, so the call never ran and
the assertion passed trivially. Move the guard into _schedule_cleanup
itself (it's the correct authoritative location anyway), remove the
now-redundant call-site checks in _reader_loop, and rewrite the test
to call _schedule_cleanup unconditionally with _stopping=True. The
test now actually exercises the production guard.
**4. Multi-pending-invoke overflow test.** Codex round-2 gap that
remained open. Locks down that _reject_all_pending fails ALL pending
futures with HOST_PROTOCOL_ERROR plus the actionable hint, not just
the one whose response overflowed.
**5. Async reconnect-after-buffer-overflow test.** Sync transport
already had test_reconnect_after_failure; async only covered reconnect
after explicit dispose. Validates that reader-triggered cleanup leaves
the transport reusable for a fresh invoke without wedging
_cleanup_task / _connecting / _process.
Plus: replaced asyncio.sleep(0) with asyncio.Event-based
synchronization in lifecycle tests (Codex/Opus medium — sleep(0) is
implementation-defined under uvloop / Python scheduling changes); two
new tests directly cover the round-3 races
(test_ensure_connected_drains_in_flight_cleanup_before_spawn,
test_kill_and_reset_caller_cancellation_does_not_cancel_cleanup).
99 transport tests pass; 5 consecutive runs with PYTHONASYNCIODEBUG=1
show no flakes; new tests pass under -W error::ResourceWarning.
---------
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
* docs: add michaelreavant to community contributors (#2866)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* docs: add JoaaoVerona to community contributors (#2859)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
* fix: prevent text overlap when multi-column sections span pages (SD-1869) (#2676)
* fix: use actual font size for tab runs instead of hardcoded 12pt default
The tab measurement code hardcoded `maxFontSize: 12` for tab-created lines and tab runs, causing incorrect line height calculations when the surrounding text used a different font size. This also adds `fontSize: '0'` to line styles to eliminate the CSS strut that caused baseline misalignment between normal-flow and absolutely-positioned (tab-aligned) children.
* fix: prevent text overlap when multi-column section spills across pages
When a multi-column section's content spans multiple pages and columns have
unequal heights, the next section's content was positioned at the shorter
column's cursor Y instead of below all column content. This caused visible
text overlap on page 2.
Add maxCursorY to PageState to track the deepest Y reached across all
columns. startMidPageRegion now uses this value so the next section begins
below all column content.
* fix: eliminate CSS strut misalignment between tab-segmented and normal lines
Lines with tab stops use absolute positioning for text segments, which
bypasses the CSS strut. Normal-flow lines inherit the browser's default
16px font-size, creating a strut that shifts text down ~1px via baseline
alignment. This made tab-indented first lines appear shifted up relative
to continuation lines.
Zero the line container's font-size to remove the strut, and restore
explicit font-size on the three child elements that inherit rather than
set their own: empty-run caret placeholder (lineHeight), math run
wrapper (run height), and field annotation wrapper (16px fallback).
* test: add regression test for maxCursorY overlap fix (SD-1869)
Verifies that mid-page column transitions start below the tallest
column when columns have unequal heights. Uses a 3-col → 2-col
transition to exercise the maxCursorY tracking without triggering
the new-page guard (columnIndexBefore >= newColumns.count).
* fix: use browser default font size for math run fallback
run.height is a layout heuristic that can reach 80–100px for tall
expressions (fractions, equation arrays). Using it as the wrapper's
font-size made the plain-text fallback render at that size. Swap to
BROWSER_DEFAULT_FONT_SIZE (16px) — MathML has its own scaling, so the
value only affects the textContent fallback path.
---------
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
* chore(deps): update dependency rollup to v4.60.2 (#2863)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
* feat(corpus): seed worktree pulls from the primary repo via hardlink (#2867)
Pulling the full corpus (~450 .docx files, ~100 MB) on every new
worktree is wasteful: the bytes are identical to the primary repo
already sitting on disk. Detect when we're in a git worktree via
`git rev-parse --git-common-dir`, then hardlink any files the primary
has into the worktree's corpus dir before hitting R2.
Only files the primary is missing go over the network. --force still
re-downloads from R2 (we unlink the destination before writing so the
primary's inodes never get clobbered). --no-seed opts out for users
who want the old behaviour.
* feat(math): implement m:box and m:borderBox converters (#2750)
* feat(math): implement m:box and m:borderBox converters (closes #2605)
Made-with: Cursor
* fix(math): parse full ST_OnOff values in borderBox converter
Made-with: Cursor
* fix(math): fall back to mrow when borderBox hides all sides with no strikes
Made-with: Cursor
* fix(math): address review findings for m:box/m:borderBox
- isOn now checks m:val === undefined instead of !el.attributes so
elements with namespace-only attributes are still treated as on,
matching the ST_OnOff default per §22.9.2.7.
- convertBorderBox returns null for empty m:e, consistent with
convertBox and convertFunction (no empty <menclose> wrappers).
- m:box JSDoc now reflects that boxPr semantics (opEmu, noBreak,
aln, diff, argSz) are silently dropped — not "purely a grouping
mechanism".
- Registry comment drift fixed: m:box and m:borderBox moved into
the Implemented block.
- Tests: strike direction mapping (BLTR→up, TLBR→down, V), full
ST_OnOff matrix (1/true/on/bare/0/false), tightened assertions to
exact-string equality, pinned the current boxPr-drop behavior.
* feat(math): polyfill MathML <menclose> via CSS
MathML Core (Chrome 109+, 2023) dropped <menclose> — no browser paints
it natively. Without this, m:borderBox content imports correctly but
renders invisibly. Ship a small CSS polyfill that maps every notation
token to borders or pseudo-element strike overlays:
- box / top / bottom / left / right → CSS border sides
- horizontalstrike / verticalstrike → ::after gradient layer (H or V)
- updiagonalstrike / downdiagonalstrike → layered gradients via CSS
custom properties so X patterns stack correctly
Wired through the existing ensure*Styles pattern in renderer.ts. Zero
bundle cost, no runtime polling, fully semantic (the DOM still says
<menclose notation="box">).
* fix(math): correct diagonal strike directions in menclose polyfill
CSS linear-gradient direction keywords confusingly produce stripes
perpendicular to the direction vector:
- "to top right" progresses toward the top-right corner, which makes
the visible color stripe run top-left to bottom-right ("\")
- "to bottom right" progresses toward the bottom-right corner, which
makes the stripe run bottom-left to top-right ("/")
The polyfill had them swapped, so updiagonalstrike rendered as "\"
and downdiagonalstrike as "/" — the opposite of what Word shows and
what MathML 3 specifies. Swap the direction keywords and add a
comment so the next reader doesn't re-flip them.
* fix(math): wrap borderBox content in <mrow> for horizontal row layout
MathML Core does not define <menclose>, so Chrome treats it as an
unknown element and does not run the row-layout algorithm on its
children. Each child rendered with display: block math and stacked
vertically — a multi-element expression inside a borderBox (e.g.
Annex L.6.1.3's a² = b² + c²) became a column of letters.
Wrap the content in an inner <mrow> before appending to <menclose>.
<mrow> is in MathML Core, so the row layout runs on its children and
everything stays inline. The outer <menclose> remains the polyfill
target for borders and strikes.
* test(behavior): cover m:borderBox + menclose polyfill end-to-end
Loads the 30-scenario fixture (sd-2750-borderbox.docx) and asserts:
- every scenario produces the expected notation attribute in DOM order
- multi-child content (Annex L.6.1.3: a² = b² + c²) renders as a
horizontal row — width > 1.5× height, inner <mrow> present, 5 children
- ST_OnOff variants (1/true/on/bare/0/false) resolve correctly through
the full import path, not just the unit converter
- m:box silently drops boxPr (opEmu/noBreak/aln/diff) and emits <mrow>
- the menclose CSS polyfill stylesheet is injected into the document
Runs across chromium/firefox/webkit. Complements the 53 unit tests by
exercising the cross-package path: OMML import → pm-adapter →
painter-dom → rendered MathML.
---------
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
* fix: remove toolbar window listeners on unmount (#2783)
* fix(super-editor): remove toolbar window listeners on unmount
* fix: setup window listeners on onMounted and onActivated
* test: assert Toolbar window listener cleanup
* fix(super-editor): unmount toolbar Vue app on SuperToolbar.destroy
Without this, the Toolbar component's onBeforeUnmount hook never runs
when SuperDoc is destroyed, so the window resize/keydown listeners this
PR registers are never removed. Verified in the dev app: dispatching a
resize event after destroy still fired the toolbar handler; with the
unmount call the handlers are cleanly removed.
Also:
- drop dead teardownWindowListeners() call inside setupWindowListeners
(addEventListener is idempotent for the same type+listener pair)
- add afterEach(vi.restoreAllMocks) to Toolbar.test.js so spies don't
leak across tests if an assertion throws
---------
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
* docs: add ArturQuirino to community contributors (#2868)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* docs: update Angular integration examples for modern APIs (#2781)
* docs: uptade Angular example to use modern angular features, like input and viewChild signals
* docs: update Angular documentation to instruct how to use in version prior to 17.1
* docs(angular): correct signal-API version requirements
viewChild() landed in 17.2 (not 17.1) and is stable only from Angular 19;
input() is 17.1+ and stable from 19. Verified via tsc against
@angular/core@17.1.0: 'has no exported member named viewChild'.
Also aligns onReady log string with React/Vue docs.
---------
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
* chore(deps): update dependency @anthropic-ai/claude-agent-sdk to v0.2.114 (#2862)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
* chore(deps): update vue monorepo to v3.5.32 (#2865)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
* fix(math): preserve italic on variables inside function-wrapped limits (SD-2538) (#2871)
* fix(math): preserve italic on variables inside function-wrapped limits (SD-2538)
Word wraps lim_(n→∞) as m:func > m:fName > m:limLow. convertFunction
was calling forceNormalMathVariant with querySelectorAll('mi'), which
recursed into the <munder> produced by m:limLow and stripped italic
from every variable inside m:lim.
Scope forceNormalMathVariant so it walks only non-structural children
and never overwrites an existing mathvariant — preserves m:sty/m:scr
authored styling on the function name and leaves nested limit,
subscript, superscript, matrix cells, etc. untouched.
* docs(llms): mention native math equation rendering
* test(math): add behavior-level regression for SD-2538
Pins the end-to-end shape of the fix by loading math-limit-tests.docx and
asserting that every <mi> inside a lim-based <munder>'s limit expression
has no mathvariant attribute — i.e. variables in m:lim stay italic when
m:limLow is wrapped in m:func > m:fName. Matches the canonical shape
produced by Word's own OMML2MML.XSL.
* refactor(math): apply review feedback on SD-2538 fix
- Rename STRUCTURAL_MATHML → MATH_VARIANT_BOUNDARY_ELEMENTS; the intent is
"where the walk stops", not "which elements are structural".
- Drop the inner walk closure and the Array.from snapshot — the outer
function already recurses cleanly; neither earned its keep.
Tests:
- Fold the SD-2538 unit test into the adjacent "m:limLow in m:func" test;
the fixture shape was identical, only the assertions differed.
- Extend "m:limUpp in m:func" with the symmetric mathvariant assertions so
the 'mover' entry of the boundary set is pinned, not just 'munder'.
- Add a preserve-branch test (m:sty=i on function name) to pin the
!hasAttribute guard — previously no test would have failed if we'd
removed it.
- Behavior test: assert exact count (5 munder + 1 mover) instead of >0,
and cover both limLow and limUpp in the same sweep.
Review context: Word's own OMML2MML.XSL confirmed as parity target. The
subtree-opaque concern from Codex was checked against the XSL output for
m:func > m:fName > m:sSub (f_i(x)) and matches our post-fix behavior.
* fix(math): restore Array.from on HTMLCollection iteration
The review-driven removal of Array.from broke type-check in CI:
HTMLCollection is not iterable under the default lib.dom.d.ts (needs
`dom.iterable`), so `for…of root.children` fails TS2488. Bun's looser
runtime type-check let it slide locally. Restoring Array.from with a
comment explaining why.
* feat(toolbar): auto-derive font-option style from label/key (SD-2611) (#2874)
* feat(toolbar): auto-derive font-option style from label/key (SD-2611)
Consumers passing { label, key } to modules.toolbar.fonts got a dropdown
where every row rendered in the toolbar's UI font — rows only render in
their own typeface when each option carries props.style.fontFamily, and
the API silently expected callers to know that shape. This adds a small
normalizer that fills in props.style.fontFamily (from key or label) and
data-item, so the minimal { label, key } shape just works.
* docs(toolbar): align fonts API docs and FontConfig type with runtime behavior
The docs said `key` is 'font-family CSS value applied to text', but the
runtime applies `option.label` — the font dropdown doesn't set
`dropdownValueKey`, so ButtonGroup falls back to `option.label` when
emitting setFontFamily. The sample `{ label: 'Times', key: 'Times New Roman' }`
also violated the active-state matcher (which compares `label` to the first
segment of the document's font-family), so the 'active' chip never lit up.
Rewrites the field descriptions to match what each value actually does,
fixes the example to follow the label-equals-first-key-segment convention,
and surfaces the optional `props.style.fontFamily` preview override.
* SD-2447 - fix: tab stop computation and alignment for TOC styles (#2731)
* fix: toc not loading on paragraphs
* fix: tab stop computation and alignment for TOC styles
* fix: tests
* fix: don't decode every child twice
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
* fix: build
* refactor: removed unused code
* chore: small code tweaks
* chore: small code tweaks
* chore: removed unused code
* fix(layout-engine): tab-alignment parity, TOC schema invariant, regression tests
Follow-ups from PR review:
- measuring/dom: sync sequentialTabIndex with explicit tabIndex values (mirror remeasure.ts).
- Document getAlignmentStopForOrdinal as a Word-compat heuristic, not ECMA 17.3.3.32 behavior.
- tableOfContents encoder: normalize every non-paragraph child into its own paragraph
so the schema invariant (paragraph*) holds for mixed inline + paragraph input, not just
the all-inline case.
- Add regression tests: 3-tabs + 2-alignment-stops asymmetric case, mixed start+end stops
preserving legacy defaults-after-rightmost behavior, tightened TOC leader assertion.
* test(behavior): add TOC tab-alignment regression for SD-2447
Playwright spec that loads a TOC1 fixture and asserts leader endpoints align,
page numbers right-justify, and leader start positions vary with title length
(proving the first tab falls on the default grid, not the right stop).
Fixture is a Word-native 14KB DOCX with a right-aligned dot-leader tab stop
on TOC1 style — the same paragraph shape as the reported Keyper document.
---------
Co-authored-by: Gabriel Chittolina <gabrielchittolina1@gmail.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
* refactor(config): consolidate track-changes config under modules.trackChanges (SD-2608) (#2847)
* refactor(config): consolidate track-changes config under modules.trackChanges (SD-2608)
track-changes config lived in two places (config.trackChanges for visibility,
config.layoutEngineOptions.trackedChanges for mode/enabled), unlike every other
module which sits under config.modules.*. consumers had to learn three keys
for one module. this adds modules.trackChanges as the canonical path and
keeps both legacy keys working as deprecated aliases with one-time warnings.
- new normalizer in core/helpers resolves canonical + legacy with precedence
new > legacy > derived default, and mirrors values back to legacy paths so
the ~14 internal reads keep working untouched
- accepts all four TrackedChangesMode values (review | original | final | off)
to preserve existing pass-through of layoutEngineOptions.trackedChanges
- suppresses re-warning on a second normalization of the same config object
so write-through values don't look like new legacy usage
- JSDoc, Mintlify docs, and the one SuperDoc.test.js legacy use-site updated
* feat(track-changes): add pairReplacements mode matching Word/ECMA-376 (SD-2607) (#2849)
* feat(track-changes): add pairReplacements mode matching Word/ECMA-376 (SD-2607)
when track changes are on and a user replaces text, SuperDoc groups the
insertion and deletion under one shared id so accepting or rejecting takes
one click (Google-Docs-like). Microsoft Word and ECMA-376 §17.13.5 treat
every <w:ins> and <w:del> as an independent revision with its own required
w:id. a consumer wants their UI to match Word.
this adds modules.trackChanges.pairReplacements (default true preserves
current behaviour). when false, each insertion and each deletion is an
independent change, addressable and resolvable on its own.
- importer: buildTrackedChangeIdMap accepts { pairReplacements }; when
false, skips the adjacent w:del+w:ins pairing so each Word w:id maps to
its own UUID
- insertTrackedChange: no shared id on replacements in unpaired mode
- getChangesByIdToResolve: returns just the single matching mark in
unpaired mode (no neighbor walk)
- wiring: SuperDoc.vue -> editor.options.trackedChanges -> Editor.ts ->
SuperConverter.trackedChangesOptions -> docxImporter
no exporter change needed — <w:ins>/<w:del> are already written per-mark
with their own w:id in both modes. no public API shape change.
* fix(track-changes): address review findings on pairReplacements (SD-2607)
- always walk adjacent same-id marks in getChangesByIdToResolve so a single
logical revision split across multiple segments resolves as one unit; the
unpaired case is handled implicitly because ins/del now have distinct ids
- align changeId with the insertion mark's id so comment threads and the
optional comment reply attach to the same thread in unpaired mode
- simplify id-minting: one primary id anchors the operation; the deletion
mints its own fresh id only when unpaired AND it's a replacement. the
Document API write adapter now gets unpaired revisions when the flag is
off without any adapter-level change
- add trackedChanges?: {...} to EditorOptions so consumers don't need casts
- add an unpaired-mode example snippet to the docs
- extension test now covers the headline guarantee: in unpaired mode,
acceptTrackedChangeById(insertionId) resolves only the insertion, and the
deletion is still independently rejectable by its own id
* fix(types): tighten JSDoc signatures so type-check passes (SD-2608)
the normalizer and tracked-change mapper emit strict typedefs but several
helper signatures were relying on implicit any, and the NORMALIZED_MARKER
symbol indexed an under-typed config object. CI type-check rejected these
once @ts-check saw the full types.
- normalize-track-changes-config.js: explicit JSDoc param types on
helpers, Record<string, any> on config so module/track-chains resolve,
symbol cast for the NORMALIZED_MARKER access
- trackedChangeIdMapper.js: nested walk now propagates pairReplacements
instead of emitting an under-typed WalkContext
* refactor(track-changes): rename pairReplacements → replacements enum (SD-2608)
pairReplacements leaked editor-speak and forced consumers to mentally
invert a boolean to get the Word-style model. rename to a self-documenting
enum that sits alongside future per-behavior siblings under modules.trackChanges.
- public surface: `modules.trackChanges.replacements: 'paired' | 'independent'`
default `'paired'` (no behavior change for existing consumers)
- plumbing: Editor.options.trackedChanges.replacements → SuperConverter
trackedChangesOptions → buildTrackedChangeIdMap; parallel path through
trackedTransaction → replaceStep so user-driven replacements (the Firefox
coalescing path) also honor the flag
- replaceStep now passes id: undefined to markDeletion in 'independent'
mode so the deletion mints its own id — closing the UI-typing gap
- docs: configuration.mdx ParamField + example use the enum; track-changes
extension doc gets a new "Revision model" section explaining paired vs
independent with snippets
- tests: normalizer, mapper, extension, and a new behavior spec
(tracked-change-independent-replacement.spec.ts) exercise the enum
end-to-end across chromium/firefox/webkit; harness + fixture accept a
`replacements` URL param so tests can flip the flag
* docs(track-changes): migrate from extensions to modules (SD-2608)
with SuperDoc moving away from ProseMirror and extensions no longer
appearing in the public nav, track changes reads better as a first-class
module page alongside comments. the new modules/track-changes.mdx
consolidates what consumers actually need: configuration, revision model
(paired vs independent), the Document API (editor.doc.trackChanges.*),
editor commands, events, permissions, and DOCX import/export.
- new: apps/docs/modules/track-changes.mdx modeled on modules/comments.mdx
- nav: docs.json lists modules/track-changes after modules/comments
- redirect: /extensions/track-changes → /modules/track-changes so
bookmarked URLs and external links keep working
- deleted: apps/docs/extensions/track-changes.mdx (was hidden anyway)
- updated: modules/overview, extensions/overview, and superdoc/configuration
cross-links point at the new page
* docs(track-changes): correct docs against codebase + lead with Document API (SD-2608)
audited every claim on modules/track-changes.mdx against packages/ and fixed
the drift. the Document API is now the primary surface; legacy editor
commands are grouped under a single deprecated section.
corrections:
- event payload is flat ({ type: 'trackedChange', event, changeId, ... }),
not nested under { type, comment }. fields trackedChangeType, trackedChangeText,
deletedText, trackedChangeDisplayType, author, authorEmail, date, importedAuthor
come straight from comments-plugin.js:1134
- event values pulled from shared/common/event-types.ts: add, update, deleted,
resolved, selected, change-accepted, change-rejected
- permissions include RESOLVE_OWN / RESOLVE_OTHER (accept) alongside
REJECT_OWN / REJECT_OTHER (permission-helpers.js:3-12)
- trackChanges.list() returns .items (not .changes) per DiscoveryOutput
- removed disableTrackChangesShowFinal() — not implemented
- mark names confirmed: trackInsert / trackDelete / trackFormat
restructuring:
- Document API section moved up and gets the full TrackChangeInfo shape
- editor.commands usage moved to a "Legacy editor commands" section at the
bottom, wrapped in a Warning banner pointing at the Document API
- voice aligned with brand.md: shorter sentences, concrete examples,
"Import it. Edit it. Export it. Nothing lost." over abstract claims
no code changes.
* docs(track-changes): use Lucide play icon for the example card (SD-2608)
* docs(track-changes): use git-compare icon to match module theming (SD-2608)
* feat(dev-app): add tracked-replacements mode toggle (SD-2608)
expose modules.trackChanges.replacements in the SuperDoc dev app so
developers can test both 'paired' (Google Docs) and 'independent' (Word)
revision models without editing code.
- reads the initial value from ?replacements=independent in the URL so
test links can pin a specific mode
- switching via the header <select> updates the URL param and reloads,
same pattern used by the Layout Engine and View Layout toggles
- moved the legacy top-level trackChanges: { visible: true } config to
modules.trackChanges.{visible, replacements} so the dev app stops
triggering its own deprecation warning
* fix(math): split multi-char m:r text per character to match Word (SD-2632) (#2875)
* fix(math): split multi-char m:r text per character to match Word (SD-2632)
Word's OMML2MML.XSL classifies each character in an m:r run individually —
digits group into a single <mn> (with one optional decimal point between
digits), operator characters each become their own <mo>, and everything
else becomes its own <mi>. SuperDoc was emitting the entire run text as
one element, so runs like "→∞" or "x+1" rendered as a single <mi>, losing
operator spacing and semantics.
tokenizeMathText implements the per-character classification. convertMathRun
returns a single Element for one-atom runs and a DocumentFragment when
multiple atoms are emitted, so siblings flow directly into the parent's
<mrow> without an extra wrapper.
m:fName is the documented exception — Word keeps multi-letter function
names like "sin" or "lim" as one <mi> inside the function-name slot.
convertFunction routes m:r children through convertMathRunWhole (no
splitting), and a new collapseFunctionNameBases pass re-merges the base
slot of any structural MathML element (munder/mover/msub/…) that Word
nested inside m:fName — without this, "lim" inside m:limLow would
incorrectly split to three <mi>.
Also drops U+221E (∞) from OPERATOR_CHARS — it's a mathematical constant
per Word's XSL, not an operator.
MathObjectConverter's return type widens from Element|null to Node|null
so convertMathRun can return a DocumentFragment. All other converters
already return Element, which is assignable to Node — no other changes.
Verified against real Word-native fixtures: `→∞` in the limit-tests
fixture case 1 now renders as <mi>n</mi><mo>→</mo><mi>∞</mi> (matches
Word OMML2MML.XSL byte-for-byte), and nested limits keep their function
names intact.
Ref ECMA-376 §22.1.2.116, Annex L.6.1.13, §22.1.2.58.
* fix(math): group letters inside m:fName mixed runs + cover mmultiscripts (SD-2632)
Follow-up to /review feedback backed by fresh Word OMML2MML.XSL evidence:
- `convertMathRunAsFunctionName` (renamed from `convertMathRunWhole` since
"whole" no longer fits) now groups consecutive non-digit / non-operator
characters into one <mi> while still splitting digits and operators. Word's
XSL for `<m:fName><m:r>log_2</m:r></m:fName>` produces
`<mi>log</mi><mo>_</mo><mn>2</mn>` — not `<mi>l</mi><mi>o</mi><mi>g</mi>…`.
- `BASE_BEARING_ELEMENTS` gains `mmultiscripts` — Word emits it when an
`m:sPre` sits inside `m:fName`; our base-collapse pass needs to know to
merge the first-child <mi> run.
- CONTRIBUTING.md now documents the widened `Node | null` return type.
Tests added:
- Direct `tokenizeMathText` edge cases: `.5` / `5.` / `1.2.3` / `2x+1` /
consecutive operators / empty / standalone ∞.
- m:fName mixed-content: `log_2` stays `<mi>log</mi><mo>_</mo><mn>2</mn>`.
- Base collapse inside nested `m:sSub` under `m:fName`.
- Base collapse inside nested `m:sPre` (mmultiscripts) under `m:fName`.
- Behavior test tightened to pin the full 3-atom sequence for `n→∞`.
Disputed during review and deferred with evidence:
- Opus claim that standalone `m:limLow` with "lim" base regresses to italic:
Word XSL itself splits "lim" per-char in that shape (with or without
m:sty=p), so our output matches Word.
- Codex claim that Arabic-Indic digits should be `<mn>`: Word XSL also
classifies them as `<mi>`, so our behavior matches.
- Non-BMP surrogate-pair support: edge case in extreme mathematical
alphanumerics; Word XSL itself errored on U+1D465. Separate ticket worth.
* fix(math): iterate tokenizer by code point to preserve surrogate pairs
Addresses Codex bot review on PR #2875. Astral-plane mathematical
alphanumerics (e.g. U+1D465 mathematical italic x, U+1D7D9
mathematical double-struck 1) are UTF-16 surrogate pairs. Walking
text by code unit split them into two half-pair <mi> atoms with
invalid content. `codePointUnitLength` returns 2 when the current
position starts a surrogate pair so tokenizeMathText and
tokenizeFunctionNameText step across the full code point.
* fix(react): stabilize user/users/modules props to prevent re-init flicker (SD-2635) (#2876)
* fix(react): stabilize user/users/modules props to prevent re-init flicker (SD-2635)
Consumers passing inline object literals (the idiomatic React pattern) caused
a full SuperDoc destroy + rebuild on every parent re-render because the
main useEffect compared these props by reference identity. When a consumer
stored the SuperDoc instance in state from onReady, the resulting re-render
supplied fresh references and triggered another cycle — observed as 4 full
destroy/re-init cycles in ~100ms with visible display:none/block flicker.
Wrap user, users, and modules in a new useStableValue hook that returns a
reference-stable version only changing identity when the structural content
actually changes (via JSON.stringify compare, run only on reference-change).
Semantics are strictly a superset of the prior behavior — value changes still
rebuild; reference-only changes no longer do.
* fix(react): drop modules from structural memo; harden tests (SD-2635 review round 2)
Review feedback addressed:
- Correctness: `modules` carries function-valued options (`permissionResolver`,
`popoverResolver`) and live objects (`collaboration.ydoc`/`provider`) that
JSON.stringify silently drops or collapses. Keeping it on structural
compare would silently ignore real config changes. Reverted to reference
identity for `modules`; `user` and `users` stay memoized since they are
plain data.
- Naming: renamed `useStableValue` → `useStructuralMemo` to match the
useMemo family and signal non-referential equality.
- JSDoc: expanded the hook's docblock to spell out every JSON.stringify
footgun consumers need to know about (functions dropped, class instances
collapsed, undefined values dropped, NaN/Infinity → null, circular refs,
key-insertion-order sensitivity).
- Tests: replaced the brittle `setTimeout(100)` negative assertion with a
synchronous `ref.getInstance()` identity check; strengthened the "rebuilds
on change" test to also assert a second onReady + a fresh instance;
added a `users`-prop stability test; added a StrictMode + rerender test
to guard the ref-write-during-render path.
* fix(react): define event types explicitly to unblock CI type-check (SD-2635)
CI type-check failed with `Property 'onEditorUpdate' does not exist on
type 'Config'` even though the JSDoc `Config` typedef in superdoc clearly
declares it. The old approach derived SuperDocEditorUpdateEvent and
SuperDocTransactionEvent via
`Parameters<NonNullable<SuperDocConstructorConfig['onEditorUpdate']>>[0]`,
which walked a chain:
ConstructorParameters<typeof SuperDoc>[0]
→ @param {Config} in core/SuperDoc.js
→ @typedef Config in core/types/index.js
→ @property onEditorUpdate with @typedef EditorUpdateEvent
This chain resolves fine locally but breaks on CI — the exact failure
point in JSDoc resolution depends on TS version, moduleResolution mode,
and the `customConditions: ["source"]` in tsconfig.base.json (which
routes imports to the raw .js source instead of the built .d.ts).
Define the two event shapes inline instead, mirroring superdoc's JSDoc.
No behavior change for consumers — same fields, same optionality.
* fix(react): round-4 review feedback — lock modules contract, restore transaction: any (SD-2635)
Addressing consensus findings from the round-3 review:
- Revert `transaction: unknown` back to `any` to match superdoc's upstream
typedef. `unknown` was a narrowing from `any` that would have broken
existing consumer code like `event.transaction.docChanged`.
- Re-export `EditorSurface` from the package barrel. It's referenced by
two public event interfaces but wasn't exported, so consumers couldn't
name the `surface` field's type.
- Symmetrize per-field JSDoc on `SuperDocTransactionEvent` to match its
sibling `SuperDocEditorUpdateEvent`.
- Add a regression test asserting that passing a new `modules` object
with identical content DOES rebuild the editor. This pins the contract
that `modules` stays on reference identity (it can carry functions and
live objects that structural compare misses) — a future "cleanup" that
wraps `modules` in useStructuralMemo would silently re-introduce the
SD-2635 blocker without this test.
Also trim commentary added during rounds 1-3: the stabilization rationale
was documented twice in SuperDocEditor.tsx (once at the destructure,
once near the dep array), and the types.ts docstrings leaked maintainer
build-tooling rationale into consumer IDE hovers.
* refactor(react): swap JSON.stringify compare for lodash.isequal; drop TransactionEvent (SD-2635)
- Swap the hand-rolled JSON.stringify-based structural compare for
`lodash.isequal`. +10KB raw / +3.7KB gzipped cost, but removes the
5-bullet footgun list from JSDoc and gets proper handling of Dates,
Maps, circular refs, and key ordering for free. For our use (stabilizing
`user`/`users` plain-data records) the 3.7KB buys nothing practical,
but it removes ~40 lines of hand-maintained code and a bag of edge
cases. Trade maintenance cost for bundle cost.
- Rename `useStructuralMemo` → `useMemoByValue`. Plainer, matches the
useMemo family, says what it does without jargon.
- Drop `SuperDocTransactionEvent` from the public API. It was exported
but never wired up to a callback prop in `CallbackProps`, so nothing
fires it. Its shape also leaked ProseMirror's `transaction` object —
a deprecated surface per superdoc's own notes. Removing it now is
cheaper than removing it after someone starts relying on it.
- Replace the JSON-stringify-specific unit tests with tests that exercise
what lodash.isequal actually gives us (key-order insensitivity,
same-reference function equality). 27/27 tests pass.
* refactor(react): drop lodash.isequal dep, inline JSON.stringify compare (SD-2635)
Round 5 added lodash.isequal for `useMemoByValue` correctness at the
cost of +10KB raw / +3.7KB gzipped. For the two props actually using the
hook (`user` and `users` — small plain-data records) the extra
correctness buys nothing practical: those records contain strings only,
no Dates, no Maps, no circular refs, no functions.
Revert to an inline JSON.stringify compare wrapped in a `try/catch` for
circular refs. The hook is now ~15 lines, zero dependencies, and the
unit test that required lodash (key-order insensitivity) is replaced by
a circular-ref fallback test that matches the implementation.
Bundle is back to 3.69 KB / 1.60 KB gzipped.
* test(react): cover StrictMode + user prop value change (SD-2635)
The existing StrictMode test only proved same-content rerender stays
stable — the positive path (real value change under StrictMode still
triggers destroy + fresh onReady) wasn't exercised. Coverage audit
after rounds 2-6 flagged this as the one gap worth closing before
ship. Mirrors the existing non-StrictMode rebuild test, wrapped in
<StrictMode>.
* feat(layout): enable odd/even header-footer support (w:evenAndOddHeaders) (#2804)
* feat(layout): enable odd/even header-footer support (w:evenAndOddHeaders)
Thread alternateHeaders from document settings through the layout
engine so the paginator selects the correct header/footer variant
per page. Fixes margin calculation for documents with different
odd and even page headers.
Also fixes getVariantTypeForPage to use document page number
(not section-relative) for even/odd selection, matching the
rendering side (headerFooterUtils.ts).
Closes #2803
* fix: address PR review — type guard + multi-section/footer/fallback tests
- Guard alternateHeaders behind isSemanticFlow check to match
ResolvedLayoutOptions type (paginated-only field)
- Add multi-section test: section 2 starting on page 4 (even by document
number, verifies the documentPageNumber fix)
- Add footer test: even/odd footer heights with alternateHeaders
- Add default-only fallback test: only default header defined
* test(layout): strengthen alternateHeaders tests and thread flag via resolver
Review round 2 follow-ups on PR #2804.
Tests
- Footer test now goes through sectionMetadata.footerRefs + footerContentHeightsByRId
(the real path) and asserts page.margins.bottom. The old test only checked body-top
y, which is footer-independent — stubbing getFooterHeightForPage to always return 0
left that assertion passing. Mutation-tested: forcing getVariantTypeForPage to
always return 'default' now breaks it.
- Default-only fallback test now exercises the production path (headerRefs.default +
per-rId heights) and asserts the correct outcome (y ~= 90 via the step-3 default
fallback). Old assertion of y ~= 50 codified a code path that never runs in
production because document imports always supply section refs. Mutation-tested:
removing the step-3 fallback breaks this test.
- New combined test: multi-section + titlePg + alternateHeaders, where section 2
has titlePg=true and starts on an even document page. Guards both the titlePg
interaction across a section boundary AND the documentPageNumber (not
sectionPageNumber) rule on pages 5 and 6. Mutation-tested: reverting to
sectionPageNumber breaks this test alongside the original multi-section case.
Layout engine
- getVariantTypeForPage now takes a named-params object. Two adjacent `number`
params (sectionPageNumber, documentPageNumber) are swap-vulnerable.
- JSDoc on LayoutOptions.alternateHeaders cross-references
getHeaderFooterTypeForSection in layout-bridge — the two sides must agree on
variant selection and the pointer helps future maintainers keep them in sync.
PresentationEditor
- alternateHeaders is now populated inside #resolveLayoutOptions, alongside the
other paginated-only fields (columns, sectionMetadata). The render-site spread
collapses back to the single ternary it was before, and the
`as EditorWithConverter` cast there disappears. types.ts didn't need changes —
the field was already declared on the paginated variant of ResolvedLayoutOptions
but unpopulated; it's now legitimately set by the resolver.
* test(alternate-headers): add unit + integration + behavior coverage
Follow-up to round 2 review. Closes the three test gaps flagged in the
gap analysis:
Unit (PresentationEditor threading)
- 3 tests in PresentationEditor.test.ts assert that
`converter.pageStyles.alternateHeaders` is forwarded to the layout
options that reach `incrementalLayout`. Covers true, unset, and
falsy-non-boolean cases. A refactor that drops the threading no
longer passes silently.
Unit (docxImporter)
- Export `isAlternatingHeadersOddEven` and add 4 tests covering the
import-side signal: `<w:evenAndOddHeaders/>` present, absent,
missing settings.xml, empty settings. Pins the contract between
OOXML settings and `pageStyles.alternateHeaders`.
Behavior (Playwright)
- `tests/headers/odd-even-header-variants.spec.ts` loads
`h_f-normal-odd-even.docx` (already in the corpus) and asserts:
- pages 1/3 render the default/odd header text, pages 2/4 render
the even header text
- page 1 and page 3 use the same `data-block-id` (same rId) but
differ from page 2 — catches regressions that produce the right
text from the wrong rId
- footers follow the same alternation
The existing layout/visual corpus already includes
`h_f-normal-odd-even*.docx` and `sd-2234-even-odd-headers.docx`, so
rendering regressions show up in `pnpm test:layout` and
`pnpm test:visual` without any additional wiring.
---------
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
* fix: image not being contained within cell (#2775)
Co-authored-by: Gabriel Chittolina <gabrielchittolina1@gmail.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
* fix: table cell selection with context menu in collab (#2822)
* fix: serif fallback for serif-like fonts (#2870)
Co-authored-by: Artem Nistuley <artem@superdoc.dev>
* fix(layout-bridge): keep footnote fragments within the reserved band (SD-1680) (#2881)
* fix(layout-bridge): keep footnote fragments within the reserved band (SD-1680)
When multiple footnotes competed for space on a single page, footnote
fragments could render past the bottom page margin — sometimes 150+ pixels
into the footer area or off the page entirely. Caused by three interacting
issues in `computeFootnoteLayoutPlan` + the multi-pass reserve loop:
1. Placement used `maxReserve` (~the whole page) as the height ceiling, so
the plan happily placed more footnote content than the body layout had
actually reserved at the bottom.
2. The multi-pass stabilization loop could oscillate when reducing the
reserve shifted refs between pages, and exit with a `reserves` vector
that didn't match what the plan placed — leaving body layout and
footnote plan inconsistent.
3. The post-loop "reservesDiffer → relayout" branch could then reintroduce
the mismatch by laying out with one reserve vector while the final plan
operated on another.
Fixes:
- `computeFootnoteLayoutPlan`: when `baseReserve > 0`, cap `placeFootnote`'s
`availableHeight` to `baseReserve` (the band actually reserved by the
body layout), not `maxReserve`. Excess footnotes are pushed to the next
page via the existing `pendingByColumn` mechanism. Initial pass
(`baseReserve === 0`) still uses `maxReserve` to seed a meaningful
reserve for subsequent passes.
- Multi-pass loop: merge reserves element-wise with `max(prev, next)` each
pass. Reserves are monotonically non-decreasing, so the loop is
guaranteed to converge (bounded by `maxReserve` per page) and the old
`[247] ↔ [394]` oscillation can't recur.
- Post-loop: drop the `reservesDiffer → relayout` branch. With the
monotonic loop already converged and placement bounded by `reserves`,
`finalPlan`'s slices always fit within the body's reserved band. The
extra relayout was the mechanism that reintroduced inconsistency.
Visual validation (in-browser) on the 3 fixtures from the Linear issue:
- reporter-test.docx: max overflow 156px → 0px
- harvey-nvca.docx: overflow present → 0px
- torke-carlsbad.docx: overflow present → 0px
Tests:
- 2 new tests in footnoteBandOverflow.test.ts asserting the invariant
"every footnote fragment's bottom-Y ≤ page's physical bottom limit"
for both multi-footnote pressure and oversized-footnote cases.
- 1168 layout-bridge tests pass, 604 layout-engine tests pass,
1737 pm-adapter tests pass, 11385 super-editor tests pass.
Follow-up (Step 3, separate PR): footnote-aware body pagination — consult
footnote heights when deciding whether paragraph P fits on page N, rather
than reactively shrinking body via reserves. This would eliminate the
multi-pass loop entirely.
* fix(layout-bridge): drive footnote reserve from per-page demand (SD-1680)
Cap footnote placement at each page's measured demand (capped at maxReserve)
rather than the body's prior-pass reserve, and merge element-wise max across
oscillating reserve vectors before the post-loop relayout. Ensures every
page's reserve is large enough to hold its placed footnotes so fragments
cannot render past the bottom margin, and iterates the post-loop relayout
until the layout's reserve per page matches the final plan's demand.
* fix: allow putting cursor after inline sdt field (#2763)
* fix: allow putting cursor after inline sdt field
* fix: simplify code; create helper file
* fix(track-changes): split independent replacement sidebar updates (#2886)
* fix(react): restore SuperDocTransactionEvent export and wire onTransaction callback
(cherry picked from commit 4015206ed3e347ad1dbce6b1cb226c579d244e9b)
* fix(layout): respect explicit column widths when drawing separators
(cherry picked from commit c08456313807f5812dfedb28555bb67585405966)
* fix(importer): parse evenAndOddHeaders ST_OnOff values correctly
(cherry picked from commit 97f78c9b0750c817b2e4fdd824c73deebb06f846)
---------
Co-authored-by: superdoc-bot[bot] <235763992+superdoc-bot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Caio Pizzol <caio@harbourshare.com>
Co-authored-by: João Vitor Verona Biazibetti <joaaoverona@gmail.com>
Co-authored-by: michaelreavant <michael@reavant.ai>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
Co-authored-by: Luccas Correa <luccas@harbourshare.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Abdel ATIA <223786362+Abdeltoto@users.noreply.github.com>
Co-authored-by: ARTUR QUIRINO <artur.pquirino@gmail.com>
Co-authored-by: Gabriel Chittolina <163901514+chittolinag@users.noreply.github.com>
Co-authored-by: Gabriel Chittolina <gabrielchittolina1@gmail.com>
Co-authored-by: G Pardhiv Varma <gpardhivvarma@gmail.com>
Co-authored-by: Artem Nistuley <101666502+artem-harbour@users.noreply.github.com>
Co-authored-by: Artem Nistuley <artem@superdoc.dev>
Co-authored-by: Tadeu Tupinambá <tadeu.tupiz@gmail.com>
Co-authored-by: VladaHarbour <114763039+VladaHarbour@users.noreply.github.com>
Co-authored-by: Codex Test <codex-test@example.com>
Contributor
|
🎉 This PR is included in superdoc v1.28.0-next.1 The release is available on GitHub release |
Contributor
|
🎉 This PR is included in superdoc-cli v0.8.0-next.1 The release is available on GitHub release |
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.
Linear: SD-2610