@beyondwork/docx-react-component 1.0.70 → 1.0.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +964 -75
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +243 -1
  4. package/src/api/v3/_create.ts +16 -1
  5. package/src/api/v3/_runtime-handle.ts +2 -0
  6. package/src/api/v3/ai/evaluate.ts +113 -0
  7. package/src/api/v3/ai/outline.ts +140 -0
  8. package/src/api/v3/ai/replacement.ts +8 -0
  9. package/src/api/v3/ai/review.ts +342 -0
  10. package/src/api/v3/ai/stats.ts +62 -0
  11. package/src/api/v3/runtime/viewport.ts +181 -0
  12. package/src/api/v3/runtime/workflow.ts +114 -1
  13. package/src/api/v3/ui/_types.ts +35 -0
  14. package/src/api/v3/ui/index.ts +1 -0
  15. package/src/api/v3/ui/viewport.ts +112 -0
  16. package/src/compare/diff-engine.ts +2 -0
  17. package/src/core/commands/formatting-commands.ts +1 -0
  18. package/src/core/commands/table-structure-commands.ts +1 -0
  19. package/src/io/export/serialize-headers-footers.ts +1 -0
  20. package/src/io/export/serialize-main-document.ts +13 -0
  21. package/src/io/export/serialize-paragraph-formatting.ts +34 -0
  22. package/src/io/export/split-review-boundaries.ts +1 -0
  23. package/src/io/normalize/normalize-text.ts +11 -0
  24. package/src/io/ooxml/parse-main-document.ts +21 -5
  25. package/src/io/ooxml/parse-paragraph-formatting.ts +105 -0
  26. package/src/model/canonical-document.ts +401 -1
  27. package/src/runtime/formatting/formatting-context.ts +2 -1
  28. package/src/runtime/geometry/overlay-rects.ts +7 -10
  29. package/src/runtime/layout/layout-engine-version.ts +257 -1
  30. package/src/runtime/layout/paginated-layout-engine.ts +134 -8
  31. package/src/runtime/layout/resolved-formatting-state.ts +108 -13
  32. package/src/runtime/markdown-sanitizer.ts +21 -4
  33. package/src/runtime/render/render-kernel.ts +21 -1
  34. package/src/runtime/scopes/audit-bundle.ts +8 -0
  35. package/src/runtime/scopes/compiler-service.ts +1 -0
  36. package/src/runtime/scopes/enumerate-scopes.ts +61 -3
  37. package/src/runtime/scopes/replacement/apply.ts +49 -3
  38. package/src/runtime/scopes/semantic-scope-types.ts +8 -0
  39. package/src/runtime/surface-projection.ts +22 -0
  40. package/src/runtime/workflow/coordinator.ts +3 -0
  41. package/src/runtime/workflow/scope-writer.ts +34 -0
  42. package/src/session/export/embedded-reconstitute.ts +37 -3
  43. package/src/session/import/embedded-offload.ts +26 -1
  44. package/src/shell/media-previews.ts +8 -6
  45. package/src/ui/WordReviewEditor.tsx +1 -0
  46. package/src/ui/editor-surface-controller.tsx +11 -0
  47. package/src/ui/headless/selection-helpers.ts +2 -2
  48. package/src/ui/runtime-shortcut-dispatch.ts +4 -4
  49. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +22 -4
  50. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +11 -11
  51. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -1
  52. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +5 -0
  53. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +18 -1
  54. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +22 -6
  55. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +18 -1
  56. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +98 -3
  57. package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -4
  58. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +8 -1
  59. package/src/ui-tailwind/editor-surface/search-plugin.ts +2 -4
  60. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +37 -0
  61. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +29 -4
  62. package/src/ui-tailwind/index.ts +4 -2
  63. package/src/ui-tailwind/page-chrome-model.ts +5 -7
  64. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +5 -2
  65. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +4 -1
  66. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +4 -1
  67. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +10 -1
  68. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +4 -1
  69. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +7 -1
  70. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +7 -1
  71. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +73 -8
  72. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +1 -1
  73. package/src/ui-tailwind/review-workspace/page-chrome.ts +4 -4
  74. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +1 -1
  75. package/src/ui-tailwind/tw-review-workspace.tsx +1 -0
@@ -4,7 +4,8 @@
4
4
  * queryScopes (live) / getMarkup (live) / getGuard (live) /
5
5
  * createScope (live-with-adapter) / attachMetadata (live-with-adapter) /
6
6
  * getVisibilityPolicy · getVisibilityPolicies · setVisibilityPolicy ·
7
- * clearVisibilityPolicy (live — W10 state-classes X1).
7
+ * clearVisibilityPolicy (live — W10 state-classes X1) /
8
+ * scopeTags (live — coord-10 L11-4 tag-catalog read).
8
9
  */
9
10
 
10
11
  import type { RuntimeApiHandle } from "../_runtime-handle.ts";
@@ -17,6 +18,13 @@ import type {
17
18
  import { emitUxResponse } from "../_ux-response.ts";
18
19
  import { createScopeFromBlockId } from "../../../runtime/workflow/scope-writer.ts";
19
20
  import { attachScopeMetadata } from "../../../runtime/workflow/metadata-writer.ts";
21
+ import {
22
+ DEFAULT_REGISTRY_ENTRIES,
23
+ DEFAULT_UNKNOWN_BEHAVIOR,
24
+ createScopeTagRegistry,
25
+ type ScopeTagBehavior,
26
+ type ScopeTagRegistry,
27
+ } from "../../../runtime/workflow/scope-tag-registry.ts";
20
28
 
21
29
  export const queryScopesMetadata: ApiV3FnMetadata = {
22
30
  name: "runtime.workflow.queryScopes",
@@ -79,6 +87,25 @@ export interface CreateScopeInput {
79
87
  * leak in). Agents pick per scope family per coord-09 §1.14.
80
88
  */
81
89
  readonly assoc?: { readonly start: -1 | 1; readonly end: -1 | 1 };
90
+ /**
91
+ * Coord-08 §9 / coord-09 §1.13 (A3) — caller-steerable identity
92
+ * strategy for the enumerated scope's `ScopeHandle.stableRef`.
93
+ * Passthrough to L06 scope-writer + L08 compiler. Honored for
94
+ * `"scope-id"` and `"semantic-path"` today; `"bookmark"` and
95
+ * `"runtime-handle"` fall back to the compiler default (bookmark
96
+ * lookup not wired in phase 1). See
97
+ * `src/runtime/scopes/enumerate-scopes.ts::stableRefHintForScopeId`.
98
+ *
99
+ * Ownership: L08 owns the selection policy; L06 owns the primitive
100
+ * (`scope-writer.ts::CreateScopeFromBlockIdInput.stableRefHint`);
101
+ * L07 is passthrough (this field); L02 owns the `ScopeStableRef`
102
+ * value shape.
103
+ */
104
+ readonly stableRefHint?:
105
+ | "scope-id"
106
+ | "bookmark"
107
+ | "semantic-path"
108
+ | "runtime-handle";
82
109
  }
83
110
 
84
111
  export interface CreateScopeResult {
@@ -273,6 +300,68 @@ export const subscribeMarkupModePolicyMetadata: ApiV3FnMetadata = {
273
300
  rwdReference: "§Runtime API § runtime.workflow.subscribeMarkupModePolicy",
274
301
  };
275
302
 
303
+ // ---------------------------------------------------------------------------
304
+ // Coord-10 L11-4 — scope-tag catalog read, graduating the pragmatic
305
+ // `createScopeTagRegistry` re-export through `src/api/public-types.ts`
306
+ // (refactor/11 §4.17 commit `7a2d2fc0`, 2026-04-24) to a proper v3 family
307
+ // entry. Three methods: factory + catalog enumeration + per-tag peek. All
308
+ // A-canonical — the catalog is code-derived but describes canonical-shape
309
+ // invariants: every tag it catalogs is encoded into customXml (via scope-
310
+ // marker / bookmark / comment structures) and broadcast via crdt along
311
+ // with scope-marker commits.
312
+ // ---------------------------------------------------------------------------
313
+
314
+ export const createScopeTagRegistryMetadata: ApiV3FnMetadata = {
315
+ name: "runtime.workflow.createScopeTagRegistry",
316
+ status: "live",
317
+ sourceLayer: "workflow-review",
318
+ liveEvidence: {
319
+ runnerTest: "test/api/v3/runtime/workflow-scope-tags.test.ts",
320
+ commit: "coord-10-l11-4",
321
+ },
322
+ uxIntent: { uiVisible: false, expectsUxResponse: "none" },
323
+ agentMetadata: { readOrMutate: "read", boundedScope: "session", auditCategory: "scope-tag-catalog" },
324
+ stateClass: "A-canonical",
325
+ persistsTo: "customXml",
326
+ broadcastsVia: "crdt",
327
+ rwdReference:
328
+ "§Runtime API § runtime.workflow.createScopeTagRegistry. Mints a fresh default-seeded ScopeTagRegistry. Hosts register custom annotation families on the returned registry via `.register(...)`. Retires the pragmatic `createScopeTagRegistry` re-export through public-types.ts (refactor/11 §4.17 2026-04-24) — the v3 family is the long-term home per coord-10 L11-4.",
329
+ };
330
+
331
+ export const listScopeTagsMetadata: ApiV3FnMetadata = {
332
+ name: "runtime.workflow.listScopeTags",
333
+ status: "live",
334
+ sourceLayer: "workflow-review",
335
+ liveEvidence: {
336
+ runnerTest: "test/api/v3/runtime/workflow-scope-tags.test.ts",
337
+ commit: "coord-10-l11-4",
338
+ },
339
+ uxIntent: { uiVisible: false, expectsUxResponse: "none" },
340
+ agentMetadata: { readOrMutate: "read", boundedScope: "session", auditCategory: "scope-tag-catalog" },
341
+ stateClass: "A-canonical",
342
+ persistsTo: "customXml",
343
+ broadcastsVia: "crdt",
344
+ rwdReference:
345
+ "§Runtime API § runtime.workflow.listScopeTags. Enumerates the shipped default catalog as `[tagType, behavior]` pairs without constructing a registry. Does not reflect host-registered custom tags — callers that want those iterate `createScopeTagRegistry().list()` on their own instance.",
346
+ };
347
+
348
+ export const getScopeTagMetadata: ApiV3FnMetadata = {
349
+ name: "runtime.workflow.getScopeTag",
350
+ status: "live",
351
+ sourceLayer: "workflow-review",
352
+ liveEvidence: {
353
+ runnerTest: "test/api/v3/runtime/workflow-scope-tags.test.ts",
354
+ commit: "coord-10-l11-4",
355
+ },
356
+ uxIntent: { uiVisible: false, expectsUxResponse: "none" },
357
+ agentMetadata: { readOrMutate: "read", boundedScope: "session", auditCategory: "scope-tag-catalog" },
358
+ stateClass: "A-canonical",
359
+ persistsTo: "customXml",
360
+ broadcastsVia: "crdt",
361
+ rwdReference:
362
+ "§Runtime API § runtime.workflow.getScopeTag. Peeks a default tag's behavior by name. Returns DEFAULT_UNKNOWN_BEHAVIOR for unknown tag types (bail-on-cross) — matches `createScopeTagRegistry().get(unknownTagType)`.",
363
+ };
364
+
276
365
  export function createWorkflowFamily(runtime: RuntimeApiHandle) {
277
366
  return {
278
367
  queryScopes(filter?: unknown) {
@@ -301,6 +390,9 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
301
390
  mode: input.mode,
302
391
  label: input.label,
303
392
  ...(input.assoc ? { assoc: input.assoc } : {}),
393
+ ...(input.stableRefHint
394
+ ? { stableRefHint: input.stableRefHint }
395
+ : {}),
304
396
  });
305
397
  emitUxResponse(runtime, {
306
398
  apiFn: createScopeMetadata.name,
@@ -430,5 +522,26 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
430
522
  }
431
523
  return { status: "scope-not-found" };
432
524
  },
525
+
526
+ createScopeTagRegistry(): ScopeTagRegistry {
527
+ // @endStateApi — live. Coord-10 L11-4. Mints a fresh default-
528
+ // seeded registry. Hosts register custom annotation families on
529
+ // the returned registry via `.register(...)`.
530
+ return createScopeTagRegistry();
531
+ },
532
+
533
+ listScopeTags(): readonly (readonly [string, ScopeTagBehavior])[] {
534
+ // @endStateApi — live. Coord-10 L11-4. Enumerates the shipped
535
+ // default catalog as `[tagType, behavior]` pairs. Does not
536
+ // reflect host-registered customs on any specific registry.
537
+ return Object.entries(DEFAULT_REGISTRY_ENTRIES);
538
+ },
539
+
540
+ getScopeTag(tagType: string): ScopeTagBehavior {
541
+ // @endStateApi — live. Coord-10 L11-4. Peeks a default tag's
542
+ // behavior by name. Returns `DEFAULT_UNKNOWN_BEHAVIOR` (bail-on-
543
+ // cross) for unknown tag types.
544
+ return DEFAULT_REGISTRY_ENTRIES[tagType] ?? DEFAULT_UNKNOWN_BEHAVIOR;
545
+ },
433
546
  };
434
547
  }
@@ -233,6 +233,26 @@ export type ScrollTarget =
233
233
  | { kind: "revision"; value: string; behavior?: ScrollTargetBehavior }
234
234
  | { kind: "page"; value: number; behavior?: ScrollTargetBehavior };
235
235
 
236
+ /**
237
+ * Result of `ui.viewport.scrollToPage(n)` — the settled state after
238
+ * the scroll dispatch lands.
239
+ *
240
+ * - `actualPage` is the 1-based page number that was actually
241
+ * targeted. Differs from the requested `pageNumber` when the caller
242
+ * asked for a page beyond the document's resolved bounds —
243
+ * implementation clamps and reports the clamped value here.
244
+ * - `scrollY` is the document-relative Y offset (in CSS px) of the
245
+ * target page's top edge, sourced from
246
+ * `handle.geometry.getPage(index).frame.topPx`. Useful for consumers
247
+ * that want to verify their own scroll container landed at the
248
+ * expected coordinate (visual-fidelity harness: deterministic
249
+ * per-page capture).
250
+ */
251
+ export interface ScrollToPageResult {
252
+ readonly actualPage: number;
253
+ readonly scrollY: number;
254
+ }
255
+
236
256
  export interface SelectionRangeInput {
237
257
  readonly anchor: number;
238
258
  readonly head: number;
@@ -369,6 +389,21 @@ export interface ApiV3UiViewport {
369
389
  get(): ViewportState;
370
390
  subscribe(listener: UiListener<ViewportState>): UiUnsubscribe;
371
391
 
392
+ /**
393
+ * Scroll the mounted surface to a specific 1-based page number.
394
+ * Resolves via `handle.geometry.getPage`; dispatches through the
395
+ * bound controller's `dispatchScroll` hook. Returns the settled
396
+ * `{ actualPage, scrollY }` or `null` when geometry cannot resolve
397
+ * any page / no controller bound / no dispatchScroll hook.
398
+ * Clamps pageNumber to the document's valid range; `actualPage`
399
+ * reflects the clamp. coord-10 §γ first-class replacement for the
400
+ * visual-fidelity harness's DOM-scrape fallback.
401
+ */
402
+ scrollToPage(
403
+ pageNumber: number,
404
+ opts?: { behavior?: ScrollTargetBehavior },
405
+ ): Promise<ScrollToPageResult | null>;
406
+
372
407
  /**
373
408
  * X5 · Composed effective markup mode. Merges L06's class-A
374
409
  * `WorkflowMarkupModePolicy` (via `handle.getMarkupModePolicy()`)
@@ -24,6 +24,7 @@ export type {
24
24
  ViewportState,
25
25
  ScrollTarget,
26
26
  ScrollTargetBehavior,
27
+ ScrollToPageResult,
27
28
  SelectionRangeInput,
28
29
  OverlayAnchorQuery,
29
30
  OverlayKind,
@@ -27,6 +27,8 @@ import type {
27
27
  UiListener,
28
28
  UiUnsubscribe,
29
29
  WorkflowMarkupMode,
30
+ ScrollTargetBehavior,
31
+ ScrollToPageResult,
30
32
  } from "./_types.ts";
31
33
  import type { UiApiContext } from "./_context.ts";
32
34
  import { readComposedViewport } from "./_context.ts";
@@ -84,6 +86,35 @@ export const subscribeMetadata: ApiV3FnMetadata = {
84
86
  rwdReference: "§UI API § ui.viewport.subscribe. Adapter delegates to UiController.subscribeViewport; throws when the active binding has no hook. Subscribe call emits one `ux.response.ui.viewport.subscribe` acknowledgement; per-tick ViewportState deliveries flow through the listener (rAF-coalesced, U7).",
85
87
  };
86
88
 
89
+ // ----- scrollToPage (coord-10 §γ — visual-fidelity / Go-to-page UX) -----
90
+
91
+ export const scrollToPageMetadata: ApiV3FnMetadata = {
92
+ name: "ui.viewport.scrollToPage",
93
+ status: "live-with-adapter",
94
+ sourceLayer: "presentation",
95
+ liveEvidence: {
96
+ runnerTest: "test/api/v3/ui/scroll-to-page.test.ts",
97
+ commit: "refactor-10-slice-scroll-to-page",
98
+ },
99
+ uxIntent: {
100
+ uiVisible: true,
101
+ expectsUxResponse: "surface-refresh",
102
+ expectedDelta: "mounted surface scrolls to the target page's top edge",
103
+ },
104
+ agentMetadata: {
105
+ readOrMutate: "mutate",
106
+ boundedScope: "session",
107
+ auditCategory: "ui-viewport-scroll",
108
+ },
109
+ // Scroll position is a class-C local view state (per-session /
110
+ // per-mounted-instance); changing it doesn't mutate canonical document
111
+ // state and is not broadcast across collab peers.
112
+ stateClass: "C-local",
113
+ persistsTo: "none",
114
+ rwdReference:
115
+ "§UI API § ui.viewport.scrollToPage. Resolves pageNumber → scrollY via handle.geometry.getPage(pageIndex); dispatches through controller.dispatchScroll({ kind:'page', value, behavior }); returns the settled {actualPage, scrollY}. 1-based page numbers; clamps to [1, pageCount]. First-class API for visual-fidelity harness + 'Go to page N' UX — replaces DOM-scrape fallback (coord-10 §γ). When L07 ships runtime.viewport.getPageAnchor / getPageGeometry (coord-07 §2.9), this wrapper may be simplified to delegate to those primitives; the public shape stays stable.",
116
+ };
117
+
87
118
  // ----- X5 markup-mode metadata (state-classes cross-cutting Slice X5) -----
88
119
 
89
120
  /**
@@ -249,6 +280,87 @@ export function createViewportFamily(ctx: UiApiContext) {
249
280
  return unsubscribe;
250
281
  },
251
282
 
283
+ // ----- scrollToPage (coord-10 §γ) -----
284
+
285
+ /**
286
+ * Scroll the mounted surface to a specific 1-based page number.
287
+ *
288
+ * Resolution: `handle.geometry.getPage(pageNumber - 1)` supplies the
289
+ * page's `frame.topPx` (deterministic, renderer-frame coordinates).
290
+ * Dispatch: `controller.dispatchScroll({ kind: "page", value, behavior })`
291
+ * — same seam as `ui.surface.scrollTo`, so any consumer-specific
292
+ * scroll-container routing lives on the shell side.
293
+ *
294
+ * Clamping: `pageNumber < 1` resolves to page 1. A request beyond
295
+ * the document's page count returns the last valid page's scrollY;
296
+ * `actualPage` reflects the clamp so callers can detect it.
297
+ *
298
+ * Returns `null` when (a) no controller is bound, (b) the controller
299
+ * has no `dispatchScroll` hook, or (c) geometry cannot resolve any
300
+ * page (pre-paint / empty doc). Callers that need explicit failure
301
+ * handling check `result !== null` before trusting the scroll.
302
+ */
303
+ async scrollToPage(
304
+ pageNumber: number,
305
+ opts?: { behavior?: ScrollTargetBehavior },
306
+ ): Promise<ScrollToPageResult | null> {
307
+ const controller = ctx.binding?.controller;
308
+ if (!controller) {
309
+ throw new Error(
310
+ "ui.viewport.scrollToPage: no controller bound — call ui.session.bind(controller) first",
311
+ );
312
+ }
313
+ if (!controller.dispatchScroll) {
314
+ throw new Error(
315
+ `ui.viewport.scrollToPage: controller of kind "${controller.kind}" did not provide a dispatchScroll hook`,
316
+ );
317
+ }
318
+
319
+ // Clamp to [1, pageCount]. The geometry facet's `getPage(index)`
320
+ // takes a 0-based index; we accept 1-based input and convert.
321
+ // Walk forward from the clamped target to find the first page the
322
+ // facet actually resolves — this handles sparse / pre-paint
323
+ // states where higher indices may be null.
324
+ const getPage = ctx.handle.geometry?.getPage;
325
+ if (typeof getPage !== "function") return null;
326
+
327
+ const requestedClampedLow = Math.max(1, Math.floor(pageNumber));
328
+ // Try the requested page; if null, scan downward through lower
329
+ // indices to land on the largest resolvable page (the doc's last
330
+ // populated page). If nothing resolves, return null.
331
+ let resolved: { pageIndex: number; scrollY: number } | null = null;
332
+ for (let i = requestedClampedLow - 1; i >= 0; i--) {
333
+ const page = getPage.call(ctx.handle.geometry, i);
334
+ if (page) {
335
+ resolved = { pageIndex: i, scrollY: page.frame.topPx };
336
+ break;
337
+ }
338
+ }
339
+ if (!resolved) return null;
340
+
341
+ const actualPage = resolved.pageIndex + 1;
342
+ const behavior = opts?.behavior;
343
+ await controller.dispatchScroll({
344
+ kind: "page",
345
+ value: actualPage,
346
+ ...(behavior ? { behavior } : {}),
347
+ });
348
+
349
+ emitUxResponse(ctx.handle, {
350
+ apiFn: scrollToPageMetadata.name,
351
+ intent: scrollToPageMetadata.uxIntent.expectedDelta ?? "",
352
+ mockOrLive: "live-with-adapter",
353
+ uiVisible: true,
354
+ expectedDelta: scrollToPageMetadata.uxIntent.expectedDelta,
355
+ actualDelta: {
356
+ kind: "surface-refresh",
357
+ payload: { page: actualPage, scrollY: resolved.scrollY },
358
+ },
359
+ });
360
+
361
+ return { actualPage, scrollY: resolved.scrollY };
362
+ },
363
+
252
364
  // ----- X5 markup-mode (state-classes cross-cutting Slice X5) -----
253
365
 
254
366
  getEffectiveMarkupMode(): WorkflowMarkupMode {
@@ -509,6 +509,7 @@ function getInlineLength(node: InlineNode): number {
509
509
  case "tab":
510
510
  case "hard_break":
511
511
  case "column_break":
512
+ case "page_break":
512
513
  case "symbol":
513
514
  case "image":
514
515
  case "opaque_inline":
@@ -555,6 +556,7 @@ function getInlineDisplayText(node: InlineNode): string {
555
556
  return "\t";
556
557
  case "hard_break":
557
558
  case "column_break":
559
+ case "page_break":
558
560
  return "\n";
559
561
  case "symbol":
560
562
  return node.char;
@@ -1030,6 +1030,7 @@ function inlineNodeLength(node: InlineNode): number {
1030
1030
  );
1031
1031
  case "hard_break":
1032
1032
  case "column_break":
1033
+ case "page_break":
1033
1034
  case "tab":
1034
1035
  case "symbol":
1035
1036
  case "image":
@@ -329,6 +329,7 @@ export function getTableStructureContext(
329
329
  columnCount,
330
330
  selectedCellCount,
331
331
  isSimpleTable: simpleTable,
332
+ alignment: target.alignment ?? null,
332
333
  currentCell: {
333
334
  rowIndex: effectiveSelection.anchorCell.rowIndex,
334
335
  columnIndex: effectiveSelection.anchorCell.columnIndex,
@@ -284,6 +284,7 @@ function serializeInlineNode(node: InlineNode): string {
284
284
  throw new Error(`Cannot safely serialize ${node.type} content in header/footer sub-parts.`);
285
285
  case "image":
286
286
  case "column_break":
287
+ case "page_break":
287
288
  case "symbol":
288
289
  default:
289
290
  throw new Error(`Cannot safely serialize ${node.type} content in header/footer sub-parts.`);
@@ -581,6 +581,8 @@ function serializeTableInlineNode(
581
581
  return "<w:r><w:tab/></w:r>";
582
582
  case "column_break":
583
583
  return "<w:r><w:br w:type=\"column\"/></w:r>";
584
+ case "page_break":
585
+ return "<w:r><w:br w:type=\"page\"/></w:r>";
584
586
  case "hard_break":
585
587
  return "<w:r><w:br/></w:r>";
586
588
  case "symbol": {
@@ -1010,6 +1012,17 @@ function serializeInlineNode(
1010
1012
  boundaries,
1011
1013
  };
1012
1014
  }
1015
+ case "page_break": {
1016
+ const xml = `<w:r><w:br w:type="page"/></w:r>`;
1017
+ const boundaries = new Map<number, number>();
1018
+ boundaries.set(cursor, xmlOffset);
1019
+ boundaries.set(cursor + 1, xmlOffset + xml.length);
1020
+ return {
1021
+ xml,
1022
+ cursor: cursor + 1,
1023
+ boundaries,
1024
+ };
1025
+ }
1013
1026
  case "hard_break": {
1014
1027
  const xml = serializeRun({ kind: "hard_break" });
1015
1028
  const boundaries = new Map<number, number>();
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type {
8
8
  CanonicalParagraphFormatting,
9
+ FrameProperties,
9
10
  ParagraphBorders,
10
11
  ParagraphIndentation,
11
12
  ParagraphShading,
@@ -92,6 +93,34 @@ function buildSpacingXml(s: ParagraphSpacing | undefined): string {
92
93
  return attrs.length > 0 ? `<w:spacing ${attrs.join(" ")}/>` : "";
93
94
  }
94
95
 
96
+ function buildFrameXml(f: FrameProperties | undefined): string {
97
+ if (!f) return "";
98
+ // Prefer parsed rawXml when available — preserves extension attributes
99
+ // (`w14:*`, `w15:*`, `mc:Ignorable`) that the typed field set doesn't
100
+ // cover. When rawXml isn't present (parser couldn't capture the source
101
+ // string), emit from typed fields only; extension attrs are lost in
102
+ // that round-trip path, but every CCEP-class framed paragraph we've
103
+ // seen uses only modelled attributes.
104
+ if (f.rawXml) return f.rawXml;
105
+ const attrs: string[] = [];
106
+ if (f.widthTwips !== undefined) attrs.push(`w:w="${f.widthTwips}"`);
107
+ if (f.heightTwips !== undefined) attrs.push(`w:h="${f.heightTwips}"`);
108
+ if (f.hRule) attrs.push(`w:hRule="${escXml(f.hRule)}"`);
109
+ if (f.xTwips !== undefined) attrs.push(`w:x="${f.xTwips}"`);
110
+ if (f.yTwips !== undefined) attrs.push(`w:y="${f.yTwips}"`);
111
+ if (f.xAlign) attrs.push(`w:xAlign="${escXml(f.xAlign)}"`);
112
+ if (f.yAlign) attrs.push(`w:yAlign="${escXml(f.yAlign)}"`);
113
+ if (f.hAnchor) attrs.push(`w:hAnchor="${escXml(f.hAnchor)}"`);
114
+ if (f.vAnchor) attrs.push(`w:vAnchor="${escXml(f.vAnchor)}"`);
115
+ if (f.wrap) attrs.push(`w:wrap="${escXml(f.wrap)}"`);
116
+ if (f.hSpaceTwips !== undefined) attrs.push(`w:hSpace="${f.hSpaceTwips}"`);
117
+ if (f.vSpaceTwips !== undefined) attrs.push(`w:vSpace="${f.vSpaceTwips}"`);
118
+ if (f.dropCap) attrs.push(`w:dropCap="${escXml(f.dropCap)}"`);
119
+ if (f.lines !== undefined) attrs.push(`w:lines="${f.lines}"`);
120
+ if (f.anchorLock !== undefined) attrs.push(`w:anchorLock="${f.anchorLock ? "1" : "0"}"`);
121
+ return attrs.length > 0 ? `<w:framePr ${attrs.join(" ")}/>` : "";
122
+ }
123
+
95
124
  function buildIndentXml(i: ParagraphIndentation | undefined): string {
96
125
  if (!i) return "";
97
126
  const attrs: string[] = [];
@@ -114,6 +143,11 @@ export function buildParagraphPropertiesXml(
114
143
  parts.push(toggleEl("keepLines", pPr.keepLines));
115
144
  parts.push(toggleEl("pageBreakBefore", pPr.pageBreakBefore));
116
145
 
146
+ // 2. framePr (ECMA-376 §17.3.1 canonical order slot, between pageBreakBefore
147
+ // and pBdr). Emit before pBdr so the OpenXML SDK validator accepts a framed
148
+ // paragraph that also carries borders (coord-04 §1.18.d).
149
+ parts.push(buildFrameXml(pPr.frameProperties));
150
+
117
151
  // 4. pBdr
118
152
  parts.push(buildBordersXml(pPr.borders));
119
153
 
@@ -340,6 +340,7 @@ function measureInlineNodeForReviewBoundaries(node: InlineNode): number {
340
340
  case "tab":
341
341
  case "hard_break":
342
342
  case "column_break":
343
+ case "page_break":
343
344
  case "footnote_ref":
344
345
  case "image":
345
346
  case "opaque_inline":
@@ -481,6 +481,17 @@ function normalizeInlineChildren(
481
481
  normalized.push({ type: "column_break" });
482
482
  state.cursor += 1;
483
483
  break;
484
+ case "page_break":
485
+ // coord-04 §1.18.5 follow-up: the fde93da3 cross-layer page_break
486
+ // ship added parse + surface-projection + pagination but missed
487
+ // this normalize-text switch. Without this case, every
488
+ // `<w:br w:type="page"/>` run parsed by L01 falls through and gets
489
+ // silently dropped during canonical assembly — so L04's
490
+ // `hasPageBreak` never fires on real documents. Mirrors the
491
+ // `column_break` branch.
492
+ normalized.push({ type: "page_break" });
493
+ state.cursor += 1;
494
+ break;
484
495
  case "chart_preview":
485
496
  registerComplexPreviewMedia(state, node);
486
497
  normalized.push({
@@ -271,6 +271,7 @@ export type ParsedInlineNode =
271
271
  | ParsedTextNode
272
272
  | ParsedBreakNode
273
273
  | ParsedColumnBreakNode
274
+ | ParsedPageBreakNode
274
275
  | ParsedTabNode
275
276
  | ParsedSymbolNode
276
277
  | ParsedImageNode
@@ -306,6 +307,10 @@ export interface ParsedColumnBreakNode {
306
307
  type: "column_break";
307
308
  }
308
309
 
310
+ export interface ParsedPageBreakNode {
311
+ type: "page_break";
312
+ }
313
+
309
314
  export interface ParsedTabNode {
310
315
  type: "tab";
311
316
  }
@@ -350,7 +355,7 @@ export interface ParsedImageNode {
350
355
  export interface ParsedHyperlinkNode {
351
356
  type: "hyperlink";
352
357
  href: string;
353
- children: Array<ParsedTextNode | ParsedBreakNode | ParsedColumnBreakNode | ParsedTabNode | ParsedSymbolNode>;
358
+ children: Array<ParsedTextNode | ParsedBreakNode | ParsedColumnBreakNode | ParsedPageBreakNode | ParsedTabNode | ParsedSymbolNode>;
354
359
  rawXml: string;
355
360
  }
356
361
 
@@ -606,7 +611,7 @@ interface XmlTextNode {
606
611
  type XmlNode = XmlElementNode | XmlTextNode;
607
612
 
608
613
  interface RunParseResult {
609
- nodes: Array<ParsedTextNode | ParsedBreakNode | ParsedColumnBreakNode | ParsedTabNode | ParsedSymbolNode>;
614
+ nodes: Array<ParsedTextNode | ParsedBreakNode | ParsedColumnBreakNode | ParsedPageBreakNode | ParsedTabNode | ParsedSymbolNode>;
610
615
  supported: boolean;
611
616
  }
612
617
 
@@ -2390,7 +2395,9 @@ function parseRun(
2390
2395
  break;
2391
2396
  }
2392
2397
  case "br":
2393
- if (isColumnBreak(child)) {
2398
+ if (isPageBreak(child)) {
2399
+ result.push({ type: "page_break" });
2400
+ } else if (isColumnBreak(child)) {
2394
2401
  result.push({ type: "column_break" });
2395
2402
  } else if (isSimpleLineBreak(child)) {
2396
2403
  result.push({ type: "hard_break" });
@@ -2714,7 +2721,7 @@ function parseHyperlink(
2714
2721
  };
2715
2722
  }
2716
2723
 
2717
- const children: Array<ParsedTextNode | ParsedBreakNode | ParsedColumnBreakNode | ParsedTabNode | ParsedSymbolNode> = [];
2724
+ const children: Array<ParsedTextNode | ParsedBreakNode | ParsedColumnBreakNode | ParsedPageBreakNode | ParsedTabNode | ParsedSymbolNode> = [];
2718
2725
 
2719
2726
  for (const child of node.children) {
2720
2727
  if (child.type !== "element") {
@@ -2764,7 +2771,7 @@ function parseRunContentOnly(
2764
2771
  }
2765
2772
 
2766
2773
  const marks = marksResult.marks;
2767
- const nodes: Array<ParsedTextNode | ParsedBreakNode | ParsedColumnBreakNode | ParsedTabNode | ParsedSymbolNode> = [];
2774
+ const nodes: Array<ParsedTextNode | ParsedBreakNode | ParsedColumnBreakNode | ParsedPageBreakNode | ParsedTabNode | ParsedSymbolNode> = [];
2768
2775
 
2769
2776
  for (const child of node.children) {
2770
2777
  if (child.type !== "element") {
@@ -2812,6 +2819,10 @@ function parseRunContentOnly(
2812
2819
  break;
2813
2820
  }
2814
2821
  case "br":
2822
+ if (isPageBreak(child)) {
2823
+ nodes.push({ type: "page_break" });
2824
+ break;
2825
+ }
2815
2826
  if (isColumnBreak(child)) {
2816
2827
  nodes.push({ type: "column_break" });
2817
2828
  break;
@@ -3149,6 +3160,11 @@ function isColumnBreak(node: XmlElementNode): boolean {
3149
3160
  return value === "column";
3150
3161
  }
3151
3162
 
3163
+ function isPageBreak(node: XmlElementNode): boolean {
3164
+ const value = (node.attributes["w:type"] ?? node.attributes.type ?? "").toLowerCase();
3165
+ return value === "page";
3166
+ }
3167
+
3152
3168
  function findChildElement(node: XmlElementNode, childLocalName: string): XmlElementNode {
3153
3169
  const child = node.children.find(
3154
3170
  (entry): entry is XmlElementNode =>