@beyondwork/docx-react-component 1.0.72 → 1.0.74

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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/api/anchor-conversion.ts +2 -2
  3. package/src/api/public-types.ts +70 -6
  4. package/src/api/v3/_runtime-handle.ts +15 -0
  5. package/src/api/v3/ai/policy.ts +31 -0
  6. package/src/api/v3/ui/_types.ts +21 -0
  7. package/src/api/v3/ui/chrome-preset-model.ts +6 -0
  8. package/src/api/v3/ui/overlays.ts +276 -2
  9. package/src/api/v3/ui/scope.ts +113 -1
  10. package/src/api/v3/ui/viewport.ts +1 -1
  11. package/src/compare/diff-engine.ts +1 -2
  12. package/src/core/commands/index.ts +14 -15
  13. package/src/core/selection/anchor-conversion.ts +2 -2
  14. package/src/core/selection/mapping.ts +10 -8
  15. package/src/core/selection/review-anchors.ts +3 -3
  16. package/src/core/state/editor-state.ts +49 -6
  17. package/src/io/export/export-session.ts +53 -0
  18. package/src/io/export/serialize-comments.ts +4 -4
  19. package/src/io/export/serialize-footnotes.ts +6 -0
  20. package/src/io/export/serialize-headers-footers.ts +6 -0
  21. package/src/io/export/serialize-main-document.ts +7 -0
  22. package/src/io/export/serialize-paragraph-formatting.ts +1 -1
  23. package/src/io/export/serialize-runtime-revisions.ts +10 -10
  24. package/src/io/export/split-review-boundaries.ts +4 -4
  25. package/src/io/export/split-story-blocks-for-runtime-revisions.ts +2 -2
  26. package/src/io/normalize/normalize-text.ts +38 -2
  27. package/src/io/ooxml/parse-comments.ts +2 -2
  28. package/src/io/ooxml/parse-headers-footers.ts +31 -0
  29. package/src/io/ooxml/parse-main-document.ts +127 -2
  30. package/src/io/ooxml/parse-paragraph-formatting.ts +1 -1
  31. package/src/model/anchor.ts +9 -1
  32. package/src/model/canonical-document.ts +76 -3
  33. package/src/preservation/store.ts +24 -0
  34. package/src/review/store/comment-anchors.ts +1 -1
  35. package/src/review/store/comment-remapping.ts +1 -1
  36. package/src/review/store/revision-actions.ts +4 -4
  37. package/src/review/store/revision-types.ts +1 -1
  38. package/src/review/store/scope-tag-diff.ts +1 -1
  39. package/src/runtime/collab/map-local-selection-on-remote-replay.ts +7 -7
  40. package/src/runtime/document-runtime.ts +205 -37
  41. package/src/runtime/formatting/formatting-context.ts +1 -1
  42. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  43. package/src/runtime/layout/layout-engine-version.ts +30 -1
  44. package/src/runtime/layout/paginated-layout-engine.ts +47 -0
  45. package/src/runtime/layout/public-facet.ts +27 -0
  46. package/src/runtime/scopes/action-validation.ts +30 -4
  47. package/src/runtime/scopes/evidence.ts +1 -1
  48. package/src/runtime/scopes/replacement/apply.ts +1 -0
  49. package/src/runtime/scopes/review-bundle.ts +1 -1
  50. package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
  51. package/src/runtime/scopes/scope-range.ts +1 -1
  52. package/src/runtime/scopes/semantic-scope-types.ts +19 -0
  53. package/src/runtime/selection/post-edit-validator.ts +4 -4
  54. package/src/runtime/surface-projection.ts +94 -4
  55. package/src/session/import/loader-types.ts +18 -0
  56. package/src/session/import/loader.ts +2 -0
  57. package/src/session/import/review-import.ts +12 -12
  58. package/src/session/import/workflow-scope-import.ts +9 -8
  59. package/src/shell/session-bootstrap.ts +4 -0
  60. package/src/ui-tailwind/editor-surface/pm-schema.ts +32 -0
  61. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +6 -0
  62. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +5 -2
  63. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +77 -0
  64. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +12 -4
  65. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +49 -32
  66. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +5 -1
  67. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +5 -1
  68. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +71 -7
  69. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +99 -43
  70. package/src/ui-tailwind/review-workspace/use-page-markers.ts +48 -7
  71. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +8 -8
  72. package/src/ui-tailwind/theme/editor-theme.css +15 -16
  73. package/src/ui-tailwind/tw-review-workspace.tsx +34 -49
  74. package/src/validation/compatibility-engine.ts +1 -1
  75. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +0 -114
  76. package/src/ui-tailwind/review-workspace/tw-review-workspace-page-toolbar.tsx +0 -240
@@ -33,9 +33,92 @@
33
33
  */
34
34
 
35
35
  import type { ApiV3FnMetadata } from "../_layer-metadata.ts";
36
- import type { ScopeBundle } from "../../public-types.ts";
36
+ import type {
37
+ ScopeBundle,
38
+ ScopeCardModel,
39
+ ScopeRailSnapshot,
40
+ SemanticScope,
41
+ SemanticScopeKind,
42
+ } from "../../public-types.ts";
37
43
  import type { UiApiContext } from "./_context.ts";
38
44
 
45
+ export interface UiScopeListFilter {
46
+ readonly kind?: SemanticScopeKind;
47
+ readonly limit?: number;
48
+ }
49
+
50
+ export interface UiScopeRailOptions {
51
+ /**
52
+ * Narrow the snapshot to a single page index. Omit (default) to
53
+ * span every page the runtime's page graph knows about.
54
+ */
55
+ readonly pageIndex?: number;
56
+ }
57
+
58
+ export const cardMetadata: ApiV3FnMetadata = {
59
+ name: "ui.scope.card",
60
+ status: "live-with-adapter",
61
+ sourceLayer: "presentation",
62
+ liveEvidence: {
63
+ runnerTest: "test/api/v3/ui/scope-card-rail.test.ts",
64
+ commit: "refactor-10-ki-008-ui-scope-card-rail",
65
+ },
66
+ uxIntent: { uiVisible: false },
67
+ agentMetadata: {
68
+ readOrMutate: "read",
69
+ boundedScope: "scope",
70
+ auditCategory: "ui-scope-card-read",
71
+ },
72
+ // Same classification as other `ui.scope.*` reads — canonical scope
73
+ // projection (not class-C local preference). KI-008 close.
74
+ stateClass: "A-canonical",
75
+ persistsTo: "canonical",
76
+ rwdReference:
77
+ "§UI API § ui.scope.card. Wraps handle.compileScopeCardById(scopeId) which projects runtime.workflow.getAllScopeCardModels() filtered to the matching scope. Returns plain-value ScopeCardModel — no React handlers, no DOM refs. Hosts wire event handlers in their own chrome tree.",
78
+ };
79
+
80
+ export const railMetadata: ApiV3FnMetadata = {
81
+ name: "ui.scope.rail",
82
+ status: "live-with-adapter",
83
+ sourceLayer: "presentation",
84
+ liveEvidence: {
85
+ runnerTest: "test/api/v3/ui/scope-card-rail.test.ts",
86
+ commit: "refactor-10-ki-008-ui-scope-card-rail",
87
+ },
88
+ uxIntent: { uiVisible: false },
89
+ agentMetadata: {
90
+ readOrMutate: "read",
91
+ boundedScope: "document",
92
+ auditCategory: "ui-scope-rail-read",
93
+ },
94
+ stateClass: "A-canonical",
95
+ persistsTo: "canonical",
96
+ rwdReference:
97
+ "§UI API § ui.scope.rail. Wraps handle.compileScopeRailSnapshot({pageIndex?}) which projects runtime.workflow.getRailSegments(pageIndex) / getAllRailSegments() into a ScopeRailSnapshot envelope. Plain-value data; no React handlers.",
98
+ };
99
+
100
+ export const listMetadata: ApiV3FnMetadata = {
101
+ name: "ui.scope.list",
102
+ status: "live-with-adapter",
103
+ sourceLayer: "presentation",
104
+ liveEvidence: {
105
+ runnerTest: "test/api/v3/ui/scope-list.test.ts",
106
+ commit: "refactor-10-ki-008-ui-scope-list",
107
+ },
108
+ uxIntent: { uiVisible: false },
109
+ agentMetadata: {
110
+ readOrMutate: "read",
111
+ boundedScope: "document",
112
+ auditCategory: "ui-scope-list",
113
+ },
114
+ // Same classification as `ui.scope.getBundle` — reads canonical
115
+ // scope state (not class-C local preference). KI-008 closure.
116
+ stateClass: "A-canonical",
117
+ persistsTo: "canonical",
118
+ rwdReference:
119
+ "§UI API § ui.scope.list. Wraps handle.compileScopeList(filter?) to enumerate compiled SemanticScope records through the L10 seam without importing src/runtime/scopes/**. Filter matches ai.listScopes({kind?, limit?}) for identity-set symmetry.",
120
+ };
121
+
39
122
  export const getBundleMetadata: ApiV3FnMetadata = {
40
123
  name: "ui.scope.getBundle",
41
124
  status: "live-with-adapter",
@@ -67,5 +150,34 @@ export function createScopeFamily(ctx: UiApiContext) {
67
150
  const nowUtc = new Date().toISOString();
68
151
  return compile.call(ctx.handle, scopeId, nowUtc);
69
152
  },
153
+ list(filter?: UiScopeListFilter): readonly SemanticScope[] {
154
+ // KI-008 — enumeration through the handle's batch primitive
155
+ // (L10 purity blocks importing src/runtime/scopes/**). Returns
156
+ // a fresh array so callers that sort / splice don't leak into
157
+ // internal compiler state.
158
+ const enumerate = ctx.handle.compileScopeList;
159
+ if (typeof enumerate !== "function") return Object.freeze([]);
160
+ return enumerate.call(ctx.handle, filter);
161
+ },
162
+ card(scopeId: string): ScopeCardModel | null {
163
+ // KI-008 close — single-scope presentation projection. Returns
164
+ // a plain-value ScopeCardModel (posture, label, issue, suggestion
165
+ // groups, review actions, agent-pending flag). Host chrome wires
166
+ // event handlers around it.
167
+ const compile = ctx.handle.compileScopeCardById;
168
+ if (typeof compile !== "function") return null;
169
+ return compile.call(ctx.handle, scopeId);
170
+ },
171
+ rail(options?: UiScopeRailOptions): ScopeRailSnapshot {
172
+ // KI-008 close — rail snapshot. When `options.pageIndex` is
173
+ // supplied, narrows to that page; otherwise spans every page
174
+ // in the runtime's page graph. Envelope shape is stable either
175
+ // way; callers check `snapshot.pageIndex` to detect narrowing.
176
+ const compile = ctx.handle.compileScopeRailSnapshot;
177
+ if (typeof compile !== "function") {
178
+ return { segments: Object.freeze([]) };
179
+ }
180
+ return compile.call(ctx.handle, options);
181
+ },
70
182
  };
71
183
  }
@@ -112,7 +112,7 @@ export const scrollToPageMetadata: ApiV3FnMetadata = {
112
112
  stateClass: "C-local",
113
113
  persistsTo: "none",
114
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.",
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 §γ). Parity note: reads the same `handle.geometry.getPage(i).frame.topPx` source as `runtime.viewport.getPageAnchor` (L07 coord-07 §2.9, shipped 2026-04-24 in `src/api/v3/runtime/viewport.ts`), so `actualPage + scrollY` here and `{scrollY, pageRect}` on the runtime side stay consistent by construction. No direct delegation today because `scripts/ci-check-ui-api-layer-purity.mjs` restricts `src/api/v3/ui/**` from importing `src/api/v3/runtime/**`; both surfaces are thin wrappers over the shared geometry facet.",
116
116
  };
117
117
 
118
118
  // ----- X5 markup-mode metadata (state-classes cross-cutting Slice X5) -----
@@ -410,8 +410,7 @@ function createParagraphRevisionRecords(
410
410
  changeId: revision.changeId,
411
411
  kind: revision.kind,
412
412
  anchor: {
413
- kind: "range",
414
- range: { from: position, to: position },
413
+ kind: "range", from: position, to: position,
415
414
  assoc: { start: -1, end: 1 },
416
415
  },
417
416
  authorId,
@@ -1525,9 +1525,9 @@ export function remapSelection(
1525
1525
 
1526
1526
  if (activeRange.kind === "range") {
1527
1527
  return {
1528
- anchor: activeRange.range.from,
1529
- head: activeRange.range.to,
1530
- isCollapsed: activeRange.range.from === activeRange.range.to,
1528
+ anchor: activeRange.from,
1529
+ head: activeRange.to,
1530
+ isCollapsed: activeRange.from === activeRange.to,
1531
1531
  activeRange,
1532
1532
  };
1533
1533
  }
@@ -1664,8 +1664,8 @@ function normalizeSelection(selection: SelectionSnapshot): SelectionSnapshot {
1664
1664
  );
1665
1665
 
1666
1666
  if (activeRange.kind === "range") {
1667
- const rangeFrom = activeRange.range.from;
1668
- const rangeTo = activeRange.range.to;
1667
+ const rangeFrom = activeRange.from;
1668
+ const rangeTo = activeRange.to;
1669
1669
  const collapsed = rangeFrom === rangeTo;
1670
1670
  const anchorWithinRange = selection.anchor >= rangeFrom && selection.anchor <= rangeTo;
1671
1671
  const headWithinRange = selection.head >= rangeFrom && selection.head <= rangeTo;
@@ -2256,7 +2256,8 @@ function createAuthoredRevision(
2256
2256
  kind,
2257
2257
  anchor: {
2258
2258
  kind: "range",
2259
- range: { from, to },
2259
+ from,
2260
+ to,
2260
2261
  assoc: { start: 1, end: -1 },
2261
2262
  },
2262
2263
  authorId,
@@ -2396,8 +2397,7 @@ function applySuggestingInsert(
2396
2397
  const updatedRevision: CanonicalRevisionRecord = {
2397
2398
  ...existingInsertion,
2398
2399
  anchor: {
2399
- kind: "range",
2400
- range: { from: existingInsertion.anchor.range.from, to: insertedTo },
2400
+ kind: "range", from: existingInsertion.anchor.from, to: insertedTo,
2401
2401
  assoc: { start: 1, end: -1 },
2402
2402
  },
2403
2403
  };
@@ -2635,19 +2635,18 @@ function applySuggestingDelete(
2635
2635
  if (existingDeletion) {
2636
2636
  // Extend the existing deletion revision to cover the new range
2637
2637
  const extendedFrom = Math.min(
2638
- existingDeletion.anchor.kind === "range" ? existingDeletion.anchor.range.from : deleteFrom,
2638
+ existingDeletion.anchor.kind === "range" ? existingDeletion.anchor.from : deleteFrom,
2639
2639
  deleteFrom,
2640
2640
  );
2641
2641
  const extendedTo = Math.max(
2642
- existingDeletion.anchor.kind === "range" ? existingDeletion.anchor.range.to : deleteTo,
2642
+ existingDeletion.anchor.kind === "range" ? existingDeletion.anchor.to : deleteTo,
2643
2643
  deleteTo,
2644
2644
  );
2645
2645
 
2646
2646
  const updatedRevision: CanonicalRevisionRecord = {
2647
2647
  ...existingDeletion,
2648
2648
  anchor: {
2649
- kind: "range",
2650
- range: { from: extendedFrom, to: extendedTo },
2649
+ kind: "range", from: extendedFrom, to: extendedTo,
2651
2650
  assoc: { start: 1, end: -1 },
2652
2651
  },
2653
2652
  };
@@ -2933,7 +2932,7 @@ function findAdjacentInsertionRevision(
2933
2932
  revision.status === "open" &&
2934
2933
  revision.metadata?.source === "runtime" &&
2935
2934
  revision.anchor.kind === "range" &&
2936
- revision.anchor.range.to === cursorPos &&
2935
+ revision.anchor.to === cursorPos &&
2937
2936
  revision.authorId === authorId
2938
2937
  ) {
2939
2938
  return revision;
@@ -2954,8 +2953,8 @@ function findOverlappingAuthoredDeletion(
2954
2953
  revision.metadata?.source === "runtime" &&
2955
2954
  revision.anchor.kind === "range"
2956
2955
  ) {
2957
- const revFrom = revision.anchor.range.from;
2958
- const revTo = revision.anchor.range.to;
2956
+ const revFrom = revision.anchor.from;
2957
+ const revTo = revision.anchor.to;
2959
2958
  // Adjacent or overlapping: the deletion is directly next to or overlapping the range
2960
2959
  if (
2961
2960
  (from >= revFrom && from <= revTo) ||
@@ -79,8 +79,8 @@ export function toPublicAnchorProjection(
79
79
  case "range":
80
80
  return {
81
81
  kind: "range",
82
- from: anchor.range.from,
83
- to: anchor.range.to,
82
+ from: anchor.from,
83
+ to: anchor.to,
84
84
  assoc: anchor.assoc,
85
85
  };
86
86
  case "node":
@@ -66,9 +66,11 @@ export function createRangeAnchor(
66
66
  to = from,
67
67
  assoc: BoundaryAssoc = DEFAULT_BOUNDARY_ASSOC,
68
68
  ): RangeAnchor {
69
+ const normalized = normalizeRange({ from, to });
69
70
  return {
70
71
  kind: "range",
71
- range: normalizeRange({ from, to }),
72
+ from: normalized.from,
73
+ to: normalized.to,
72
74
  assoc,
73
75
  };
74
76
  }
@@ -89,7 +91,7 @@ export function createEmptyMapping(): TransactionMapping {
89
91
 
90
92
  export function getEffectiveRange(anchor: EditorAnchorProjection): DocRange {
91
93
  if (anchor.kind === "range") {
92
- return anchor.range;
94
+ return { from: anchor.from, to: anchor.to };
93
95
  }
94
96
  if (anchor.kind === "node") {
95
97
  return { from: anchor.at, to: anchor.at };
@@ -140,13 +142,13 @@ export function mapAnchor(
140
142
  return createNodeAnchor(mapped.position, anchor.assoc);
141
143
  }
142
144
 
143
- const from = mapPosition(anchor.range.from, anchor.assoc.start, mapping);
144
- const to = mapPosition(anchor.range.to, anchor.assoc.end, mapping);
145
+ const from = mapPosition(anchor.from, anchor.assoc.start, mapping);
146
+ const to = mapPosition(anchor.to, anchor.assoc.end, mapping);
145
147
  const mappedRange = normalizeRange({ from: from.position, to: to.position });
146
148
 
147
149
  if (from.deleted && to.deleted && mappedRange.from === mappedRange.to) {
148
150
  return createDetachedAnchor(
149
- anchor.range,
151
+ { from: anchor.from, to: anchor.to },
150
152
  mapping.metadata?.invalidatesStructures
151
153
  ? "invalidatedByStructureChange"
152
154
  : "deleted",
@@ -187,8 +189,8 @@ export function areAnchorsEqual(
187
189
 
188
190
  if (left.kind === "range" && right.kind === "range") {
189
191
  return (
190
- left.range.from === right.range.from &&
191
- left.range.to === right.range.to &&
192
+ left.from === right.from &&
193
+ left.to === right.to &&
192
194
  left.assoc.start === right.assoc.start &&
193
195
  left.assoc.end === right.assoc.end
194
196
  );
@@ -213,7 +215,7 @@ export function anchorUnaffectedByMapping(
213
215
  ): boolean {
214
216
  if (anchor.kind === "detached") return true;
215
217
  if (mapping.steps.length === 0) return true;
216
- const end = anchor.kind === "range" ? anchor.range.to : anchor.at;
218
+ const end = anchor.kind === "range" ? anchor.to : anchor.at;
217
219
  return mapping.steps.every((s) => s.from > end);
218
220
  }
219
221
 
@@ -40,7 +40,7 @@ export function mapReviewAnchor(
40
40
 
41
41
  export function getAnchorRange(anchor: ReviewAnchor): DocRange {
42
42
  if (anchor.kind === "range") {
43
- return normalizeRange(anchor.range);
43
+ return normalizeRange({ from: anchor.from, to: anchor.to });
44
44
  }
45
45
 
46
46
  if (anchor.kind === "node") {
@@ -136,7 +136,7 @@ export function commentAnchorRejectionReason(
136
136
  return "invalid_comment_anchor";
137
137
  }
138
138
 
139
- const normalized = normalizeRange(anchor.range);
139
+ const normalized = normalizeRange({ from: anchor.from, to: anchor.to });
140
140
  if (normalized.from === normalized.to) {
141
141
  return "invalid_comment_anchor";
142
142
  }
@@ -164,7 +164,7 @@ export function snapCommentAnchorAwayFromTable(
164
164
  ): ReviewAnchor | null {
165
165
  if (anchor.kind !== "range") return null;
166
166
 
167
- const normalized = normalizeRange(anchor.range);
167
+ const normalized = normalizeRange({ from: anchor.from, to: anchor.to });
168
168
  if (normalized.from === normalized.to) return null;
169
169
 
170
170
  // If the anchor is already flagged invalid for other reasons (crosses an
@@ -582,14 +582,57 @@ export function createPersistedEditorSnapshot(
582
582
  }
583
583
 
584
584
  function estimateParagraphCount(content: unknown): number {
585
- if (Array.isArray(content)) {
586
- return content.length;
587
- }
585
+ // Canonical shape: `{type:"doc", children: BlockNode[]}`. Older
586
+ // shapes (array / `.blocks`) handled for persistence-snapshot
587
+ // fallback. KI-P4 (2026-04-23): pre-fix the array + .blocks
588
+ // branches never matched the current envelope, so the fallback
589
+ // returned 1 on any non-empty document regardless of paragraph
590
+ // count. Fix counts ParagraphNode entries recursively, descending
591
+ // into table cells + SDT / customXml blocks so nested paragraphs
592
+ // contribute to the total.
593
+ let count = 0;
594
+ const walk = (node: unknown): void => {
595
+ if (!node || typeof node !== "object") return;
596
+ const typed = node as { type?: unknown };
597
+ if (typed.type === "paragraph") {
598
+ count += 1;
599
+ return;
600
+ }
601
+ if (typed.type === "table") {
602
+ const rows = (node as { rows?: unknown[] }).rows;
603
+ if (Array.isArray(rows)) {
604
+ for (const row of rows) {
605
+ const cells = (row as { cells?: unknown[] }).cells;
606
+ if (Array.isArray(cells)) {
607
+ for (const cell of cells) {
608
+ const children = (cell as { children?: unknown[] }).children;
609
+ if (Array.isArray(children)) children.forEach(walk);
610
+ }
611
+ }
612
+ }
613
+ }
614
+ return;
615
+ }
616
+ const children = (node as { children?: unknown[] }).children;
617
+ if (Array.isArray(children)) children.forEach(walk);
618
+ };
588
619
 
589
- if (content && typeof content === "object" && Array.isArray((content as { blocks?: unknown[] }).blocks)) {
590
- return ((content as { blocks: unknown[] }).blocks).length;
620
+ if (content && typeof content === "object") {
621
+ const children = (content as { children?: unknown[] }).children;
622
+ if (Array.isArray(children)) {
623
+ children.forEach(walk);
624
+ return count;
625
+ }
626
+ const blocks = (content as { blocks?: unknown[] }).blocks;
627
+ if (Array.isArray(blocks)) {
628
+ blocks.forEach(walk);
629
+ return count;
630
+ }
631
+ }
632
+ if (Array.isArray(content)) {
633
+ content.forEach(walk);
634
+ return count;
591
635
  }
592
-
593
636
  return extractText(content).length > 0 ? 1 : 0;
594
637
  }
595
638
 
@@ -119,6 +119,7 @@ export class ExportSession {
119
119
  this.packageRelationships,
120
120
  this.ownedPaths,
121
121
  );
122
+ ensurePicNamespaceOnStoryParts(this.workingParts);
122
123
  const parts = pruneReservedManifestParts(this.workingParts);
123
124
  const manifest = buildManifest(parts, this.packageRelationships, this.sourcePackage.manifest.contentTypes);
124
125
  return {
@@ -140,6 +141,58 @@ export function createExportSession(
140
141
  return new ExportSession(sourcePackage, ownedOutputPaths);
141
142
  }
142
143
 
144
+ /**
145
+ * LibreOffice strict-parser safety net. Word tolerates `pic:` prefix
146
+ * usage (from `<pic:pic>` / `<pic:spPr>` inside drawings) without
147
+ * declaring `xmlns:pic` at the part root, because it inherits scope
148
+ * from `<wp:inline xmlns:pic=…>` wrappers or from `mc:AlternateContent`
149
+ * fallbacks. LibreOffice refuses the file ("Namespace prefix pic on
150
+ * spPr is not defined"). Our own serializers already inject
151
+ * `xmlns:pic` on freshly serialized parts — but parts that pass
152
+ * through unmodified from the source package keep the original
153
+ * declaration (which may be missing it). This sweep runs at toPackage
154
+ * time over the main story + every header / footer part, and injects
155
+ * `xmlns:pic` on the root element when the part uses the `pic:` prefix
156
+ * but doesn't declare it. Byte-stable for parts already correct.
157
+ *
158
+ * See commit a3e05a8e (serializer-side fix) + this (pass-through fix).
159
+ */
160
+ const PIC_NAMESPACE_URI = "http://schemas.openxmlformats.org/drawingml/2006/picture";
161
+ const PIC_NAMESPACE_DECL = ` xmlns:pic="${PIC_NAMESPACE_URI}"`;
162
+
163
+ function ensurePicNamespaceOnStoryParts(
164
+ parts: Map<string, OpcPackagePart>,
165
+ ): void {
166
+ for (const [path, part] of parts.entries()) {
167
+ if (!isPicSensitiveStoryPath(path)) continue;
168
+ const patched = ensurePicNamespaceOnRoot(part.bytes);
169
+ if (patched !== part.bytes) {
170
+ parts.set(path, { ...part, bytes: patched, crc32: 0 });
171
+ }
172
+ }
173
+ }
174
+
175
+ function isPicSensitiveStoryPath(path: string): boolean {
176
+ // Normalize for leading slash variation.
177
+ const normalized = path.startsWith("/") ? path.slice(1) : path;
178
+ if (normalized === "word/document.xml") return true;
179
+ if (/^word\/(?:header|footer|footnotes|endnotes)[^/]*\.xml$/.test(normalized)) return true;
180
+ return false;
181
+ }
182
+
183
+ function ensurePicNamespaceOnRoot(bytes: Uint8Array): Uint8Array {
184
+ const xml = new TextDecoder().decode(bytes);
185
+ // Match the first element (skipping the XML prolog + whitespace).
186
+ const rootMatch = xml.match(/^([\s\S]*?)<([A-Za-z][\w.:-]*)([^>]*)>/);
187
+ if (!rootMatch) return bytes;
188
+ const [fullMatch, prefix, tagName, rootAttrs] = rootMatch;
189
+ if (rootAttrs.includes("xmlns:pic=")) return bytes; // already declared
190
+ if (!/\bpic:[A-Za-z]/.test(xml)) return bytes; // doesn't use pic: prefix
191
+ const patchedRoot = `<${tagName}${rootAttrs}${PIC_NAMESPACE_DECL}>`;
192
+ const patched = `${prefix}${patchedRoot}${xml.slice(fullMatch.length)}`;
193
+ return new TextEncoder().encode(patched);
194
+ }
195
+
143
196
  function clonePartsForExport(
144
197
  parts: Map<string, OpcPackagePart>,
145
198
  ownedPaths: ReadonlySet<string>,
@@ -269,16 +269,16 @@ export function serializeCommentAnchorsIntoDocumentXml(
269
269
  continue;
270
270
  }
271
271
 
272
- const startParagraph = findParagraphForEndpoint(paragraphs, anchor.range.from, "start");
273
- const endParagraph = findParagraphForEndpoint(paragraphs, anchor.range.to, "end");
272
+ const startParagraph = findParagraphForEndpoint(paragraphs, anchor.from, "start");
273
+ const endParagraph = findParagraphForEndpoint(paragraphs, anchor.to, "end");
274
274
 
275
275
  if (!startParagraph || !endParagraph) {
276
276
  skippedCommentIds.push(thread.commentId);
277
277
  continue;
278
278
  }
279
279
 
280
- const startIndex = startParagraph.boundaries.get(anchor.range.from);
281
- const endIndex = endParagraph.boundaries.get(anchor.range.to);
280
+ const startIndex = startParagraph.boundaries.get(anchor.from);
281
+ const endIndex = endParagraph.boundaries.get(anchor.to);
282
282
 
283
283
  if (startIndex === undefined || endIndex === undefined) {
284
284
  skippedCommentIds.push(thread.commentId);
@@ -18,6 +18,7 @@ import {
18
18
  } from "./table-properties-xml.ts";
19
19
  import { twip } from "./twip.ts";
20
20
  import { escapeXmlAttribute } from "./escape-xml-attribute.ts";
21
+ import { buildFrameXml } from "./serialize-paragraph-formatting.ts";
21
22
 
22
23
  export const WORD_FOOTNOTES_CONTENT_TYPE =
23
24
  "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml";
@@ -222,6 +223,11 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
222
223
  if (paragraph.styleId) {
223
224
  parts.push(`<w:pStyle w:val="${escapeXmlAttribute(paragraph.styleId)}"/>`);
224
225
  }
226
+ // Coord-04 §1.19.d — direct-paragraph framePr (footnotes path).
227
+ {
228
+ const frameXml = buildFrameXml(paragraph.frameProperties);
229
+ if (frameXml) parts.push(frameXml);
230
+ }
225
231
  if (paragraph.alignment) {
226
232
  parts.push(`<w:jc w:val="${escapeXmlAttribute(paragraph.alignment)}"/>`);
227
233
  }
@@ -18,6 +18,7 @@ import {
18
18
  } from "./table-properties-xml.ts";
19
19
  import { twip } from "./twip.ts";
20
20
  import { escapeXmlAttribute } from "./escape-xml-attribute.ts";
21
+ import { buildFrameXml } from "./serialize-paragraph-formatting.ts";
21
22
 
22
23
  export const WORD_HEADER_CONTENT_TYPE =
23
24
  "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml";
@@ -186,6 +187,11 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
186
187
  if (paragraph.styleId) {
187
188
  parts.push(`<w:pStyle w:val="${escapeXmlAttribute(paragraph.styleId)}"/>`);
188
189
  }
190
+ // Coord-04 §1.19.d — direct-paragraph framePr (headers/footers path).
191
+ {
192
+ const frameXml = buildFrameXml(paragraph.frameProperties);
193
+ if (frameXml) parts.push(frameXml);
194
+ }
189
195
  if (paragraph.spacing) {
190
196
  const s = paragraph.spacing;
191
197
  const attrs: string[] = [];
@@ -22,6 +22,7 @@ import { SCOPE_MARKER_BOOKMARK_PREFIX } from "../ooxml/parse-scope-markers.ts";
22
22
  import { getOpaqueFragment } from "../../preservation/store.ts";
23
23
  import { retainRelationshipsForFragment } from "../../preservation/relationship-retention.ts";
24
24
  import { serializeParagraphNumberingProperties } from "./serialize-numbering.ts";
25
+ import { buildFrameXml } from "./serialize-paragraph-formatting.ts";
25
26
  import {
26
27
  serializeTableCellPropertiesXml,
27
28
  serializeTablePropertiesXml,
@@ -716,6 +717,12 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
716
717
  pushOnOffParagraphProperty(children, "keepNext", paragraph.keepNext);
717
718
  pushOnOffParagraphProperty(children, "keepLines", paragraph.keepLines);
718
719
  pushOnOffParagraphProperty(children, "pageBreakBefore", paragraph.pageBreakBefore);
720
+ // ECMA-376 §17.3.1 canonical slot for framePr: between pageBreakBefore
721
+ // and pBdr. Coord-04 §1.19.d — direct-paragraph path.
722
+ {
723
+ const frameXml = buildFrameXml(paragraph.frameProperties);
724
+ if (frameXml) children.push(frameXml);
725
+ }
719
726
  pushOnOffParagraphProperty(children, "widowControl", paragraph.widowControl);
720
727
  if (paragraph.outlineLevel !== undefined) {
721
728
  children.push(`<w:outlineLvl w:val="${paragraph.outlineLevel}"/>`);
@@ -93,7 +93,7 @@ function buildSpacingXml(s: ParagraphSpacing | undefined): string {
93
93
  return attrs.length > 0 ? `<w:spacing ${attrs.join(" ")}/>` : "";
94
94
  }
95
95
 
96
- function buildFrameXml(f: FrameProperties | undefined): string {
96
+ export function buildFrameXml(f: FrameProperties | undefined): string {
97
97
  if (!f) return "";
98
98
  // Prefer parsed rawXml when available — preserves extension attributes
99
99
  // (`w14:*`, `w15:*`, `mc:Ignorable`) that the typed field set doesn't
@@ -135,8 +135,8 @@ function createParagraphPropertyChangeReplacement(
135
135
  }
136
136
  const paragraphBoundary = findParagraphBoundaryForRange(
137
137
  boundaries,
138
- revision.anchor.range.from,
139
- revision.anchor.range.to,
138
+ revision.anchor.from,
139
+ revision.anchor.to,
140
140
  );
141
141
  if (!paragraphBoundary) {
142
142
  return undefined;
@@ -168,14 +168,14 @@ function createRunPropertyChangeReplacement(
168
168
  }
169
169
  const paragraphBoundary = findParagraphBoundaryForRange(
170
170
  boundaries,
171
- revision.anchor.range.from,
172
- revision.anchor.range.to,
171
+ revision.anchor.from,
172
+ revision.anchor.to,
173
173
  );
174
174
  if (!paragraphBoundary) {
175
175
  return undefined;
176
176
  }
177
- const startIndex = paragraphBoundary.boundaries.get(revision.anchor.range.from);
178
- const endIndex = paragraphBoundary.boundaries.get(revision.anchor.range.to);
177
+ const startIndex = paragraphBoundary.boundaries.get(revision.anchor.from);
178
+ const endIndex = paragraphBoundary.boundaries.get(revision.anchor.to);
179
179
  if (startIndex === undefined || endIndex === undefined || endIndex < startIndex) {
180
180
  return undefined;
181
181
  }
@@ -245,13 +245,13 @@ function createRangeRevisionReplacement(
245
245
  if (anchor.kind !== "range") {
246
246
  return undefined;
247
247
  }
248
- const paragraphBoundary = findParagraphBoundaryForRange(boundaries, anchor.range.from, anchor.range.to);
248
+ const paragraphBoundary = findParagraphBoundaryForRange(boundaries, anchor.from, anchor.to);
249
249
  if (!paragraphBoundary) {
250
250
  return undefined;
251
251
  }
252
252
 
253
- const startIndex = paragraphBoundary.boundaries.get(anchor.range.from);
254
- const endIndex = paragraphBoundary.boundaries.get(anchor.range.to);
253
+ const startIndex = paragraphBoundary.boundaries.get(anchor.from);
254
+ const endIndex = paragraphBoundary.boundaries.get(anchor.to);
255
255
  if (startIndex === undefined || endIndex === undefined || endIndex < startIndex) {
256
256
  return undefined;
257
257
  }
@@ -340,7 +340,7 @@ function findParagraphBoundaryForAnchor(
340
340
  boundaries: readonly RevisionParagraphBoundary[],
341
341
  revision: RevisionRecord,
342
342
  ): RevisionParagraphBoundary | undefined {
343
- const anchor = revision.anchor.kind === "range" ? revision.anchor.range.from : undefined;
343
+ const anchor = revision.anchor.kind === "range" ? revision.anchor.from : undefined;
344
344
  if (anchor === undefined) {
345
345
  return undefined;
346
346
  }
@@ -35,8 +35,8 @@ function collectSplitPositions(
35
35
  continue;
36
36
  }
37
37
 
38
- positions.add(thread.anchor.range.from);
39
- positions.add(thread.anchor.range.to);
38
+ positions.add(thread.anchor.from);
39
+ positions.add(thread.anchor.to);
40
40
  }
41
41
 
42
42
  for (const revision of revisions) {
@@ -51,8 +51,8 @@ function collectSplitPositions(
51
51
  continue;
52
52
  }
53
53
 
54
- positions.add(revision.anchor.range.from);
55
- positions.add(revision.anchor.range.to);
54
+ positions.add(revision.anchor.from);
55
+ positions.add(revision.anchor.to);
56
56
  }
57
57
 
58
58
  return positions;
@@ -200,8 +200,8 @@ function collectSplitPoints(
200
200
  ): number[] {
201
201
  const points = new Set<number>();
202
202
  for (const revision of revisions) {
203
- const from = revision.anchor.range.from;
204
- const to = revision.anchor.range.to;
203
+ const from = revision.anchor.from;
204
+ const to = revision.anchor.to;
205
205
  if (from > start && from < end) {
206
206
  points.add(from);
207
207
  }