Skip to content

Merge main into stable#3686

Open
superdoc-bot[bot] wants to merge 89 commits into
stablefrom
merge/main-into-stable-2026-06-09
Open

Merge main into stable#3686
superdoc-bot[bot] wants to merge 89 commits into
stablefrom
merge/main-into-stable-2026-06-09

Conversation

@superdoc-bot

@superdoc-bot superdoc-bot Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

  • creates merge/main-into-stable-2026-06-09 from stable
  • merges main into the candidate branch
  • opens the promotion PR to stable

Auto-created by promote-stable workflow.

mattConnHarbour and others added 30 commits May 28, 2026 16:26
…er zoom

Adds a new `layout-change` event that fires when container dimensions change,
enabling customers to implement responsive fit-to-container zoom without
manual polling or ResizeObservers.

Payload includes containerWidth, documentWidth, and fitZoom (calculated zoom
to fit document in container). Base document width is captured once at 100%
zoom to avoid feedback loops when setZoom is called.

Closes SD-3294

Co-Authored-By: Claude Opus 4.5 <[email protected]>
…idth

Defer base width capture until isReady is true to avoid latching stale
measurements before DOCX layout resolves (e.g., landscape or multi-section
documents).

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Verify that:
- layout-change is not emitted before isReady
- payload includes containerWidth, documentWidth, and fitZoom

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Addresses npm audit warning for SNYK-JS-UUID-16133035. Note: SuperDoc
was not actually vulnerable - we only use the 2-param signature which
returns a string directly. The vulnerability only affects the 4-param
signature that writes to a caller-provided buffer.

Ref: SD-3361

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Reshapes the layout-change contract from the base branch before it
ships, and models zoom as mode + value, the shape document viewers use.

- Rename layout-change to viewport-change: the public surface already
  exports LayoutUpdatePayload for document layout passes, and what this
  event reports is viewport fit. Payload { availableWidth,
  documentWidth, fitZoom } carries pure measurements (sidebar-aware,
  policy-free).
- Resolve the base document width from page styles, re-resolved per
  evaluation, instead of a one-time DOM capture: the measured element
  scales with zoom, so any zoom applied before capture corrupted
  fitZoom permanently. Never emit before an editor exists.
- zoom config: initial (seeded before first paint, no flash), mode
  (manual | fit-width), fitWidth bounds and padding. Padding and
  clamping shape the applied fit only, never the metrics.
- setZoom() switches the mode to manual, so picking a percentage stops
  the auto-fit instead of fighting it; setZoomMode('fit-width')
  re-enters fitting and applies immediately. The fit application
  writes zoom state directly and emits zoomChange with the mode.
- New reads: getZoomState(), getViewportMetrics() (latest metrics
  readable any time, so late subscribers cannot miss the first
  measurement). New constructor callbacks onZoomChange /
  onViewportChange register before the first emit.
Adds onZoomChange and onViewportChange as explicitly plumbed callback
props (the callbacksRef pattern), so swapped handler identities stay
fresh across rerenders without rebuilding the SuperDoc instance. The
zoom config flows through props automatically via SuperDocConfig.
Event types re-derive from the core Config so the wrapper cannot
drift from the core contract.
…SD-3294)

Documents the zoom config (initial, mode, fitWidth), the new
setZoomMode / getZoomState / getViewportMetrics methods, the
viewport-change event and its pure-metrics payload, the zoomChange
mode field, and the React responsive-zoom pattern.
…wport metrics (SD-3294)

Two gaps a zoom UI hits in practice:

- setZoomMode emitted nothing unless the numeric value later changed,
  so mode-only transitions (entering fit-width at the clamped value,
  returning to manual) were invisible to zoomChange subscribers. It
  now emits zoomChange with the current value on every mode change
  and no-ops on a same-mode call.
- The width resolver required a DOCX activeEditor, so PDF-only
  instances never produced viewport metrics even though setZoom
  supports PDFs, and multi-document instances measured only the
  active editor. The resolver now takes the widest measurable page
  across all documents: DOCX from per-document page styles, PDF from
  rendered pages normalized by their actual scale factor back to CSS
  px at 100% zoom (a 612pt letter page renders 816 CSS px), with a
  pdf:document-ready re-evaluation hook. HTML documents reflow and
  contribute nothing; an HTML-only instance reports no metrics.
…D-3294)

Custom UI gets first-class zoom: ui.zoom exposes one slice (mode,
value, fitZoom, bounds, viewport metrics) recomputed on zoomChange and
viewport-change, with set(percent) and setMode passthroughs to the
host. Hosts without the zoom surface degrade to a static manual/100
snapshot with no-op mutations.

React mirrors it with useSuperDocZoom (slice plus bound actions), and
the toolbar registry gains a zoom-fit-width toggle command so custom
toolbars can offer Fit width without reaching for the host instance.
The numeric zoom command is untouched. The built-in toolbar's Fit
width affordance stays a follow-up: the state and command layer it
needs ships here.
…ent' into caio/sd-3294-fit-to-container-config

# Conflicts:
#	packages/superdoc/src/core/SuperDoc.ts
#	packages/superdoc/src/public/index.ts
#	tests/consumer-typecheck/snapshots/superdoc-root-classification.json
#	tests/consumer-typecheck/snapshots/superdoc-root-exports.json
#	tests/consumer-typecheck/snapshots/superdoc-root-exports.md
#	tests/consumer-typecheck/src/all-public-types.ts
…ommand (SD-3294)

Extracts the pdf measurement math into a pure helper
(normalizePdfPageMeasurement) and locks it directly: scale-relative
conversion back to CSS px at 100%, the zoom-fallback path, and the
zoom-desync case where a seeded zoom has not reached the viewer yet.
Component tests cover the widest-page rule across mixed-orientation
documents and the pdf DOM path with a stubbed scale factor. Registry
tests lock zoom-fit-width active/disabled state and the
fit-width/manual toggle.
… (SD-3294)

The UI host-event comment said three events; viewport-change made it
four. Root export snapshots regenerate for the union of this branch's
zoom types and the font types the base brought in from main.
The pre-commit format hook ran over the merge commit's full staged set
and prettified 15 generated and upstream files this branch never
touches (mcp catalog, document-api templates, font-system, sdk
dispatch). A clean merge takes the base side verbatim for files only
one side changed; restore those bytes so the PR diff carries zoom work
only. Committed with hooks disabled so the formatter does not
reintroduce the drift.
…dth (SD-3294)

The mode-model rework widened the emit condition to any rounded
availableWidth change, which the dedup unit test correctly rejected in
CI: px-level jitter during a window drag would spam consumers with
emits that cannot change any fit decision. Restore the intended key
(rounded fitZoom plus rounded documentWidth); meaningful
available-width changes already surface through fitZoom.
…3278)

Multi-line text in text-mode mutations stored newlines as a raw \n inside
one <w:t>, which Word collapses while SuperDoc renders a break. Convert
newlines to lineBreak nodes at creation, split any residual raw newline
into <w:t>/<w:br/> on export, and make the read model agree that a
lineBreak reads as \n so rewrite/search/query stay consistent. Serializes
as a Word-native <w:br/> (ECMA-376 17.3.3.1).

- buildTextWithTabs: normalize \n, \r\n, \r to lineBreak nodes, gated on
  parent admission (probed per edit position) for text*-only parents
- materializeLineBreak: prefer lineBreak over hardBreak (soft, not page)
- getTextNodeForExport: split residual raw newline into <w:t>/<w:br/>
- del-translator: rename every <w:t> in a split run to <w:delText>
- lineBreak.leafText = '\n' so textBetweenWithTabs / charOffsetToDocPos /
  text-offset-resolver read a break as \n; idempotent rewrite no longer
  duplicates it, a rewrite to single-line text removes it
- SearchIndex honors leafText, and a single hit spanning text+lineBreak+
  text coalesces to one contiguous range so query.match('Alpha\nBeta')
  works (block separators still split; D5 guard intact)
- list paragraph beforeinput removes the placeholder break when text is
  typed; visible text models skip tracked-deleted leaf nodes
… (SD-3278)

Typing into a list item that holds only a placeholder break dropped the
caret before the first inserted character, so subsequent native
keystrokes prepended instead of appended ("abcdef" landed as "bcdefa").
Move the selection past the inserted text after the delete+insert.
…s (SD-3278)

Coalesce adjacent search segments only when they are both offset-contiguous
(same hit) and document-adjacent (segment.docFrom === current.to). This
merges text + lineBreak + text within one run into a single range without
bridging a skipped/tracked-deleted leaf or a run boundary, so the
downstream D5 contiguity guard still rejects genuinely separate edits.
Five verified issues from the multi-agent and Codex review of #3659:

- zoom.initial now reaches every surface at first paint: PdfViewer
  seeds its scale from a new initialScale prop (the activeZoom watcher
  never fires for a seeded ref, so a PDF painted 100% while getZoom()
  said 50, putting overlay math 2x off), and the non-layout-engine CSS
  fallback applies once from the document/editor ready hooks via the
  factored style application.
- Fit-width targets what the renderer paints: the resolver prefers the
  widest laid-out page (editor.getPages(), the same source
  SuperEditor's container sizing uses for landscape sections) with
  body page styles as the pre-pagination fallback.
- setZoom/setZoomMode before init now warn and emit nothing instead of
  advertising a change that was never persisted.
- Stored viewport metrics are always latest (refreshed on any field
  change, frozen against consumer mutation) while the viewport-change
  event stays deduped to fit-relevant changes; all five public doc
  surfaces now state that contract precisely. getZoomState() derives
  its bounds from the same resolver the policy clamps with.
- The applied fit floors at 1 (fractional bounds plus a degenerate
  container could round to 0, which the presentation engine rejects),
  and width/pagination evaluations defer a tick so measurement never
  runs against a mid-flush DOM (also fixes the one-frame sidebar
  bounce). The PDF page scan is skipped without PDF documents, the
  sidebar measures through a template ref, and the pt-to-px constant
  imports from the same module PdfViewerPage writes --scale-factor
  with.
The geometry 'zoom' latch only arms when the zoom value actually
changed (seeded from the host state), so mode-only zoomChange
emissions with no repaint to consume the tag no longer mis-label the
next unrelated layout notification. useSuperDocZoom memoizes its
return so the object identity is stable across unrelated parent
renders, matching the controller-side slice memo it sits on.
The span-rewrite path got the same parentAllowsLineBreak probe as the
rewrite/insert paths but had no newline test, though its comment claimed
coverage. Add two cases: a single '\n' in a normal parent mints one
lineBreak (no hardBreak, no raw newline text node), and the same into a
text*-only total-page-number falls back to literal text with no lineBreak.
The previous lockfile was generated from a dirty working tree (importer
entries for untracked local directories, super-editor/superdoc entries
not matching the committed manifests) and failed frozen installs.
Regenerated on current main with only the uuid catalog change applied;
two consecutive regens produce byte-identical output. Beyond the uuid
entries, the clean regen re-keys the docs mintlify chain's optional
@types/node peer back to the catalog-pinned 22.19.2 and records the
registry's new deprecated flag on @microsoft/teamsapp-cli.
uuid@11 bundles type declarations for every dist flavor, and the
DefinitelyTyped package is now a deprecation stub. Removes the catalog
entry and the two devDependency consumers.
… described (SD-3294)

The d643fb9 message documented two freshness tiers, but a format-hook
reformatting made the scripted edit miss silently and the diff never
contained them. This commit holds the actual change: stored metrics
refresh on any field change (frozen against consumer mutation) so
getViewportMetrics() and ui.zoom reads are always latest, while the
viewport-change event stays deduped to fit-relevant changes. The ui
zoom slice's reference-keyed memo now documents the field-gated
replacement invariant it relies on. Also drops two em dashes from
comments per repo writing rules.
…D-3294)

The F2 fix made the resolver prefer editor.getPages() with page styles
as the pre-pagination fallback; the composable overview and the events
doc still said page styles only.
garhm and others added 21 commits June 8, 2026 14:08
 - anchor tab caret Y to line top for trailing tab (was bottom-aligned tab box)
 - position soft-break continuation caret at line start, alignment-aware
   (left/center/right), instead of the page right edge
 - anchor tab caret Y to line top for trailing tab (was bottom-aligned tab box)
 - position soft-break continuation caret at line start, alignment-aware
   (left/center/right), instead of the page right edge
…options

feat(toolbar): list document fonts in the font picker
…359) (#3638)

* fix(layout-engine): balance explicit equal-width continuous columns

balanceSectionOnPage skipped every section with equalWidth=false plus explicit widths, so continuous newspaper sections declared as <w:cols w:num=N w:equalWidth=0> with equal <w:col w:w> children (the common case) never balanced and rendered single-column. Narrow the skip to GENUINELY-unequal widths: explicit widths that are all equal now balance like implicit equal columns. Genuinely-unequal widths still fill column-by-column (Word parity, unchanged). (SD-2324)

* fix(layout-adapter): honor per-column w:space for unequal columns

Per ECMA-376 §17.6.4, when columns are not equal width (w:equalWidth=0) the section-level w:cols/@w:space is ignored and the inter-column gap comes from each <w:col w:space>. extractColumns used the section space, over-spacing explicit columns so their widths scaled down to fit and diverged from Word (e.g. the 2002 ISDA sections). Use the per-column w:space for unequal columns; equal-width columns keep the section space. Advances SD-2629 for the uniform-spacing case. (SD-2324)

* fix(columns): equal-mode column correctness + explicit count cap (SD-2324)

Equal-width sections (w:equalWidth="1" or omitted) now match Word: extraction
drops child <w:col> widths and takes the gap from the section w:space
(default 720), and normalizeColumnLayout honours per-column widths only when
w:equalWidth="0".

For explicit columns (w:equalWidth="0"), cap the count to min(w:num, valid
child-width count) at the source, so a w:num larger than the provided <w:col>
widths no longer creates surplus 1px phantom columns in the fill loop (which
reads the raw count). A matching clamp in normalizeColumnLayout stays as a
defensive net.

* test(columns): cover valid-width count cap, equal-mode count, and absent w:cols (SD-2324)

Adds three extraction unit tests for the landed column fix: count caps to the
valid child-width count (four <w:col> but two usable w:w -> 2), equal mode takes
the count from w:num (count 3, no children), and a section without <w:cols>
yields no columnsPx.

* fix(layout-engine): balance continuous multi-column sections like Word

A two-column section ending at a continuous section break rendered with
lumpy columns (SD-3359, IT-1150): on the repro NDA the left column ran
169px deeper than the right, and the following single-column content
started below the unbalanced overhang.

Three defects, all in the balancing path:

- balanceSectionOnPage fed the balancer atomic fragments (canBreak:
  false, no lineHeights), so a paragraph straddling the column boundary
  could not split. The balancer already computes line-level break points
  (blockBreakPoints) with widow/orphan control, but no caller consumed
  them. Paragraph fragments now opt in with their per-line heights, and
  a chosen break is applied as fragment surgery: the first half keeps
  the leading lines, a cloned second half carries the remaining lines to
  the top of the next column, the same fromLine/toLine and continuation
  model pagination uses. Paragraphs with w:keepLines (ECMA-376
  17.3.1.14) and sectPr marker paragraphs stay atomic.

- The binary search floored its target at the tallest block's full
  height. For a breakable paragraph the indivisible chunk is its tallest
  line, so the search could never reach the truly balanced height and
  packed the overflow lines into column 0.

- The post-layout gate keyed "mid-doc continuous" off the section's own
  begin type (its sectPr w:type, 17.6.22) instead of the type of the
  break that ends it, which is the NEXT section's begin type. A 2-col
  section that merely started continuous balanced even when ended by a
  nextPage break; per the 17.18.77 note only a continuous break
  balances the previous section.

Verified against the IT-1150 repro (169px -> 14px imbalance, trailing
paragraph tucked directly below the balanced region) and eleven
spec-derived document mutations (3 columns, explicit equal and unequal
w:col, keepLines, explicit column break, nextPage/evenPage/nextColumn
end breaks, w:sep, implicit and explicit body sectPr): all conform to
ECMA-376 and the documented Word behaviors (sd-1655, sd-1480,
mixed-columns-tabs-tnr). Adds 4 unit tests and 2 layoutDocument
integration tests.

* fix(layout-engine): honor remeasured fragment lines when splitting columns

Review follow-up. A paragraph fragment remeasured for a narrower column
(or beside a float) carries its own `lines` array, and resolveParagraph
renders that array INSTEAD of slicing measure.lines by fromLine/toLine.
The column split cloned the fragment wholesale, so both halves kept the
full remeasured array and each column rendered the entire paragraph.

Three coordinated corrections:

- The split slices `lines` across the halves (first keeps the leading
  slice, the clone carries the rest), so each column renders only its
  own lines.
- Break points are computed against the fragment's own remeasured line
  heights when present; measure.lines describes the original width and
  can disagree in both count and height.
- getFragmentHeight sums fragment.lines when present, matching how
  resolveLayout sizes such fragments, so balancing cursors agree with
  what the resolve stage actually renders.

Adds a regression test with a remeasured fragment (22px lines vs a
stale 20px measure) asserting the halves partition the lines and the
column cursors advance by the remeasured heights.

---------

Co-authored-by: Caio Pizzol <[email protected]>
Co-authored-by: Caio Pizzol <[email protected]>
fix: caret positioning for tabs and soft-break lines
docs: add garhm to community contributors
…c-96bf-ffbe04b51e27/superdoc-font-toolbar-catalog

fix(toolbar): scroll long font dropdowns
@superdoc-bot superdoc-bot Bot requested a review from a team as a code owner June 9, 2026 06:28
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Agent docs audit

Found deterministic findings on 2 changed agent-doc item(s).

AGENTS.md (102 lines)

  • 2 broken path ref(s)

Broken path refs:

  • core/layout-adapter/
  • src/editors/v1/core/layout-adapter

apps/docs/AGENTS.md (211 lines)

  • over nested-warn (211 > 200)
  • 5 broken path ref(s)
  • 3 unresolved command(s)

Broken path refs:

  • icon="/path/to/icon.svg"
  • packages/superdoc/src/core/SuperDoc.js
  • packages/superdoc/src/core/types/index.js
  • superdoc/ui
  • superdoc/ui/react

Unresolved pnpm commands (advisory):

  • pnpm check:icons
  • pnpm check:imports
  • pnpm test:examples

Deterministic L1 only: no AI, no Bash, no secrets. Semantic L2/L3 audit runs weekly on main. Policy: agent-docs-policy.md.

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

The ecma-spec MCP tools are being denied in this environment, so I verified against ECMA-376 from established knowledge of these (core, unambiguous) run-content elements. Here's my review.


Status: PASS

Both handlers are spec-compliant. I checked every element and attribute these changes touch.

del-translator.js — Renaming every w:t to w:delText inside <w:del> (rather than just the first) is correct. Per ECMA-376 §17.3.3.7, deleted text runs must use w:delText in place of w:t; a leftover w:t inside <w:del> would not be treated as deleted content by Word. Both are CT_Text, so the rename is purely the element name — the only valid attribute on either is xml:space, which is preserved. The previous single-element behavior was a latent bug; this fixes it. Leaving w:noBreakHyphen/w:tab/w:br untouched is also correct — those carry no deleted-text analog and the <w:del> wrapper alone conveys the deletion.

translate-text-node.js — The newline export safety net is sound:

  • <w:br/> with no w:type is a soft line break (ST_BrType defaults to textWrapping), which is the intended semantics. The test correctly asserts no w:type="page". ✓
  • w:br interleaved with w:t siblings inside a single w:r is valid — EG_RunInnerContent permits t, br, delText, etc. in any order/repetition. ✓
  • xml:space="preserve" applied only to segments with edge whitespace is the correct, standard XML attribute on CT_Text. Dropping the original nodeAttrs on the split segments is safe since w:t has no other attributes besides xml:space. ✓
  • Leading/trailing/consecutive newlines emitting bare <w:br/> elements (no empty w:t) is valid run content. ✓

The two changes also compose correctly: the newline split can produce multiple w:t per run, which is exactly why renaming all w:t in del-translator matters — a deleted Alpha\nBeta round-trips as <w:delText>Alpha</w:delText><w:br/><w:delText>Beta</w:delText>, with no orphan <w:t> surviving inside <w:del>.

No non-existent attributes/elements, no missing required attributes, and the w:br default-type behavior is used correctly. See https://ooxml.dev/spec?q=delText and https://ooxml.dev/spec?q=br for details.

Note: I couldn't reach the ecma-spec MCP tools (permission denied in this session), so this verification is from ECMA-376 knowledge rather than a live schema query. If you want, re-run with the spec tools granted and I'll confirm w:br's CT_Br attribute list and ST_BrType default against the XSD directly.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d4313204d3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +21 to +25
const zoomMode = ref('manual');
// Latest viewport measurements (availableWidth / documentWidth / fitZoom),
// written by the viewport-fit composable; null until editors mount.
/** @type {import('vue').Ref<import('@superdoc/core/types/index.js').SuperDocViewportMetrics | null>} */
const viewportMetrics = ref(null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset viewport zoom state on store init

When the Pinia store is reused for a later SuperDoc instance or document reinitialization, these new session-scoped refs survive reset() because that function only clears document/user fields. A prior session that entered fit-width or measured a viewport can therefore make a new/default SuperDoc keep applying fit-width and report stale metrics before its own config asks for them. Please clear zoomMode back to 'manual' and viewportMetrics back to null during reset/init before seeding from config.

Useful? React with 👍 / 👎.

Comment on lines +48 to +49
const padded = computeFitZoom(availableWidth - options.padding, documentWidth);
if (padded === null) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clamp fit-width when padding exhausts the viewport

When config.zoom.fitWidth.padding is greater than or equal to the measured available width, this call returns null, so applyFitWidth() skips updating activeZoom. In fit-width mode that leaves the previous zoom value in place instead of honoring the configured min, which is easy to hit in narrow containers or with a reserved sidebar/gutter and causes the document to remain over-zoomed. Treat the padded width as zero and clamp to options.min rather than bailing out.

Useful? React with 👍 / 👎.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.16379% with 41 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
packages/superdoc/src/SuperDoc.vue 60.65% 24 Missing ⚠️
...kages/superdoc/src/composables/use-viewport-fit.js 96.07% 12 Missing ⚠️
packages/superdoc/src/core/SuperDoc.ts 93.65% 4 Missing ⚠️
...es/superdoc/src/components/PdfViewer/PdfViewer.vue 85.71% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants