@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
@@ -264,6 +264,7 @@ function normalizeParagraph(
264
264
  ...(paragraph.suppressLineNumbers !== undefined
265
265
  ? { suppressLineNumbers: paragraph.suppressLineNumbers }
266
266
  : {}),
267
+ ...(paragraph.frameProperties ? { frameProperties: paragraph.frameProperties } : {}),
267
268
  // A.7: preserve w14:paraId / w14:textId across import → export so
268
269
  // downstream tools that diff documents by paragraph id stay stable.
269
270
  ...(paragraph.wordExtensionIds
@@ -715,9 +716,30 @@ function registerComplexPreviewMedia(
715
716
  function normalizeHyperlink(node: ParsedHyperlinkNode): {
716
717
  type: "hyperlink";
717
718
  href: string;
718
- children: Array<TextNode | { type: "hard_break" } | { type: "tab" }>;
719
+ children: Array<
720
+ | TextNode
721
+ | { type: "hard_break" }
722
+ | { type: "column_break" }
723
+ | { type: "page_break" }
724
+ | { type: "tab" }
725
+ | { type: "symbol"; char: string; font?: string; marks?: TextMark[] }
726
+ >;
719
727
  } {
720
- const children: Array<TextNode | { type: "hard_break" } | { type: "tab" }> = [];
728
+ // Canonical `HyperlinkNode.children` accepts the full inline-leaf set
729
+ // (TextNode | HardBreakNode | ColumnBreakNode | PageBreakNode | TabNode |
730
+ // SymbolNode). Matching the canonical shape here keeps rare
731
+ // hyperlink-inside-break patterns (a link spanning a column or page
732
+ // break in Word's output) from silently dropping at the normalize step —
733
+ // same class of drop that `coord-04 §1.19.b` fixed one level up in
734
+ // `normalizeInlineChildren`.
735
+ const children: Array<
736
+ | TextNode
737
+ | { type: "hard_break" }
738
+ | { type: "column_break" }
739
+ | { type: "page_break" }
740
+ | { type: "tab" }
741
+ | { type: "symbol"; char: string; font?: string; marks?: TextMark[] }
742
+ > = [];
721
743
 
722
744
  for (const child of node.children) {
723
745
  switch (child.type) {
@@ -743,6 +765,20 @@ function normalizeHyperlink(node: ParsedHyperlinkNode): {
743
765
  case "hard_break":
744
766
  children.push({ type: "hard_break" });
745
767
  break;
768
+ case "column_break":
769
+ children.push({ type: "column_break" });
770
+ break;
771
+ case "page_break":
772
+ children.push({ type: "page_break" });
773
+ break;
774
+ case "symbol":
775
+ children.push({
776
+ type: "symbol",
777
+ char: child.char,
778
+ ...(child.font ? { font: child.font } : {}),
779
+ ...(child.marks && child.marks.length > 0 ? { marks: child.marks } : {}),
780
+ });
781
+ break;
746
782
  }
747
783
  }
748
784
 
@@ -652,9 +652,9 @@ function extractNodeText(node: XmlNode): string {
652
652
 
653
653
  function compareThreadsByAnchor(left: CommentThread, right: CommentThread): number {
654
654
  const leftStart =
655
- left.anchor.kind === "range" ? left.anchor.range.from : Number.MAX_SAFE_INTEGER;
655
+ left.anchor.kind === "range" ? left.anchor.from : Number.MAX_SAFE_INTEGER;
656
656
  const rightStart =
657
- right.anchor.kind === "range" ? right.anchor.range.from : Number.MAX_SAFE_INTEGER;
657
+ right.anchor.kind === "range" ? right.anchor.from : Number.MAX_SAFE_INTEGER;
658
658
 
659
659
  if (leftStart !== rightStart) {
660
660
  return leftStart - rightStart;
@@ -328,6 +328,37 @@ function parseParagraphElement(
328
328
  activeComplexField = null;
329
329
  }
330
330
  pushFieldNode(children, child, "simple");
331
+ } else if (name === "sdt") {
332
+ // coord-11 §22 — structured-document-tag wrapping run-level content
333
+ // inside a header/footer paragraph. Word commonly uses these to
334
+ // bundle the page-number field + decorative drawings (e.g. CCEP's
335
+ // footer "Copyright CCEP STRICTLY CONFIDENTIAL" red rectangle +
336
+ // "Page N" label both sit inside one `<w:sdt>` in footer1.xml).
337
+ // Without this case the sdt was silently dropped at the paragraph
338
+ // walker and every run it carried — including WPS shapes bearing
339
+ // the brand-strip text — never reached the canonical tree.
340
+ // Treat `<w:sdtContent>` as a transparent wrapper and re-process
341
+ // its `<w:r>` / `<w:hyperlink>` / `<w:sdt>` children as if they
342
+ // were direct paragraph children.
343
+ const sdtContent = findChildElementOptional(child, "sdtContent");
344
+ if (sdtContent) {
345
+ for (const grandchild of sdtContent.children) {
346
+ if (grandchild.type !== "element") continue;
347
+ const gname = localName(grandchild.name);
348
+ if (gname === "r") {
349
+ activeComplexField = appendRunNodes(grandchild, children, activeComplexField, sourceXml, opts);
350
+ } else if (gname === "hyperlink") {
351
+ children.push(parseHyperlinkElement(grandchild, opts));
352
+ } else if (gname === "bookmarkStart" || gname === "bookmarkEnd") {
353
+ children.push(parseBookmarkElement(grandchild));
354
+ } else if (gname === "fldSimple") {
355
+ pushFieldNode(children, grandchild, "simple");
356
+ }
357
+ // Nested sdt / other elements ignored — deeper nesting is rare
358
+ // enough that opaque round-trip via the block-level sdt parser
359
+ // handles it if it matters.
360
+ }
361
+ }
331
362
  }
332
363
  }
333
364
 
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  BorderSpec,
3
3
  CellShading,
4
+ FrameProperties,
4
5
  TextMark,
5
6
  ParagraphBorders,
6
7
  ParagraphShading,
@@ -39,6 +40,7 @@ import { parseComplexContentXml, type ChartPartLookup } from "./parse-complex-co
39
40
  import { parseShapeXml, parseVmlXml } from "./parse-shapes.ts";
40
41
  import { parseObject } from "./parse-object.ts";
41
42
  import { parseDrawingFrame } from "./parse-drawing.ts";
43
+ import { readFrameProperties } from "./parse-paragraph-formatting.ts";
42
44
  import { classifyFieldInstruction } from "./parse-fields.ts";
43
45
  import { parseFFDataFromFldChar } from "./parse-ffdata.ts";
44
46
  import { resolveHighlightColor } from "./highlight-colors.ts";
@@ -217,6 +219,41 @@ function captureGrabBagFromContainer(
217
219
  export interface ParsedMainDocument {
218
220
  blocks: ParsedBlockNode[];
219
221
  finalSectionProperties?: SectionProperties;
222
+ /**
223
+ * Aggregate count of cosmetic markers stripped during parse (see
224
+ * {@link ParseMainDocumentOptions.stripCosmeticMarkers}). Keyed by
225
+ * local element name (e.g. `lastRenderedPageBreak`). Absent when no
226
+ * markers were stripped.
227
+ */
228
+ skippedCosmeticMarkerCounts?: Readonly<Record<string, number>>;
229
+ }
230
+
231
+ /**
232
+ * Cosmetic markers that Word re-inserts on reopen and that carry no
233
+ * contract semantics. Stripping them at parse time unblocks
234
+ * `replaceText` on ranges that today cross them as `opaque_inline`
235
+ * boundaries. See `docs/architecture/cosmetic-marker-strip.md`.
236
+ *
237
+ * This is the Phase 1 set. Bookmark-pair stripping (with reference
238
+ * scan) is Phase 2.
239
+ */
240
+ export const COSMETIC_MARKER_ELEMENT_NAMES: ReadonlySet<string> = new Set([
241
+ "lastRenderedPageBreak",
242
+ "proofErr",
243
+ "noBreakHyphen",
244
+ ]);
245
+
246
+ export interface ParseMainDocumentOptions {
247
+ /**
248
+ * When `true` (the default), drops `<w:lastRenderedPageBreak/>`,
249
+ * `<w:proofErr/>`, and `<w:noBreakHyphen/>` during the parse walk
250
+ * instead of emitting them as `opaque_inline` nodes. Counts are
251
+ * reported on {@link ParsedMainDocument.skippedCosmeticMarkerCounts}.
252
+ *
253
+ * Set to `false` to preserve the pre-strip behavior exactly — every
254
+ * cosmetic marker becomes an `opaque_inline` with its source XML.
255
+ */
256
+ stripCosmeticMarkers?: boolean;
220
257
  }
221
258
 
222
259
  export type ParsedBlockNode =
@@ -256,6 +293,15 @@ export interface ParsedParagraphNode {
256
293
  bidi?: boolean;
257
294
  suppressLineNumbers?: boolean;
258
295
  cnfStyle?: string;
296
+ /**
297
+ * `<w:framePr>` declared directly on the paragraph's own `<w:pPr>`.
298
+ * Coord-04 §1.19.d step 2 (inline path). The style-cascade path
299
+ * flows through `CanonicalParagraphFormatting.frameProperties` on
300
+ * the style side; this slot captures the direct-override path so
301
+ * L02 `ParagraphNode.frameProperties` (added 2026-04-24 `4b3ea0b2`)
302
+ * can reach its canonical shape.
303
+ */
304
+ frameProperties?: FrameProperties;
259
305
  /** A.7: preserved w14 extension ids (paraId/textId). */
260
306
  wordExtensionIds?: {
261
307
  paraId?: string;
@@ -656,24 +702,61 @@ export function setActiveParseTelemetryBus(bus: ParseTelemetryBus | undefined):
656
702
  activeParseTelemetryBus = bus;
657
703
  }
658
704
 
705
+ /**
706
+ * Request-scoped cosmetic-marker strip context. Set by
707
+ * `parseMainDocumentXml` for the duration of a single parse; read at
708
+ * the four emission sites in `parseBodyChild` / `parseRun` /
709
+ * `parseRunContentOnly` / `parseRevisionContainer`. Using a module
710
+ * variable instead of threading the flag through ~15 intermediate
711
+ * function signatures keeps the call sites readable; the try/finally
712
+ * in the entry point ensures the variable never leaks across calls.
713
+ *
714
+ * Re-entrancy invariant matches `activeChartPartLookup` above.
715
+ */
716
+ interface CosmeticStripContext {
717
+ readonly strip: boolean;
718
+ readonly counts: Record<string, number>;
719
+ }
720
+ let activeCosmeticStripContext: CosmeticStripContext | null = null;
721
+
722
+ function noteStrippedCosmeticMarker(tag: string): void {
723
+ if (!activeCosmeticStripContext) return;
724
+ activeCosmeticStripContext.counts[tag] =
725
+ (activeCosmeticStripContext.counts[tag] ?? 0) + 1;
726
+ }
727
+
728
+ function shouldStripCosmeticMarker(): boolean {
729
+ return activeCosmeticStripContext?.strip === true;
730
+ }
731
+
659
732
  export function parseMainDocumentXml(
660
733
  xml: string,
661
734
  relationships: readonly OpcRelationship[] = [],
662
735
  mediaParts: ReadonlyMap<string, InlineMediaPart> = new Map(),
663
736
  sourcePartPath = "/word/document.xml",
664
737
  chartPartLookup?: ChartPartLookup,
738
+ parseOptions: ParseMainDocumentOptions = {},
665
739
  ): ParsedMainDocument {
666
740
  activeChartPartLookup = chartPartLookup;
741
+ const stripContext: CosmeticStripContext = {
742
+ strip: parseOptions.stripCosmeticMarkers !== false,
743
+ counts: Object.create(null) as Record<string, number>,
744
+ };
745
+ activeCosmeticStripContext = stripContext;
667
746
  const bus = activeParseTelemetryBus;
668
747
  const started = bus?.isEnabled("parse") ? performanceNow() : 0;
669
748
  try {
670
749
  const result = parseMainDocumentXmlInner(xml, relationships, mediaParts, sourcePartPath);
750
+ if (Object.keys(stripContext.counts).length > 0) {
751
+ result.skippedCosmeticMarkerCounts = Object.freeze({ ...stripContext.counts });
752
+ }
671
753
  if (bus?.isEnabled("parse")) {
672
754
  emitParseSummary(bus, result, sourcePartPath, performanceNow() - started);
673
755
  }
674
756
  return result;
675
757
  } finally {
676
758
  activeChartPartLookup = undefined;
759
+ activeCosmeticStripContext = null;
677
760
  }
678
761
  }
679
762
 
@@ -704,6 +787,13 @@ function emitParseSummary(
704
787
  blockCount: result.blocks.length,
705
788
  blockKindCounts: counts,
706
789
  ms,
790
+ // Strip counts are surfaced here (telemetry-only) rather than as a
791
+ // warning on `diagnostics.warnings` — the markers carry no
792
+ // contract semantics and surfacing them in the user-visible
793
+ // warnings feed would be noise. Available to debug UX / tests via
794
+ // the `parse` channel; absent when the feature is disabled or no
795
+ // markers were stripped.
796
+ skippedCosmeticMarkerCounts: result.skippedCosmeticMarkerCounts,
707
797
  },
708
798
  });
709
799
  }
@@ -1004,6 +1094,7 @@ function parseBodyChild(
1004
1094
  let bidi: ParsedParagraphNode["bidi"];
1005
1095
  let suppressLineNumbers: ParsedParagraphNode["suppressLineNumbers"];
1006
1096
  let cnfStyle: ParsedParagraphNode["cnfStyle"];
1097
+ let frameProperties: ParsedParagraphNode["frameProperties"];
1007
1098
  let sectionProperties: SectionProperties | undefined;
1008
1099
  let sectionPropertiesXml: string | undefined;
1009
1100
  let paragraphSupported = true;
@@ -1050,6 +1141,12 @@ function parseBodyChild(
1050
1141
  bidi = readOnOffParagraphProperty(child, "bidi");
1051
1142
  suppressLineNumbers = readOnOffParagraphProperty(child, "suppressLineNumbers");
1052
1143
  cnfStyle = readParagraphCnfStyle(child);
1144
+ {
1145
+ const framePrNode = child.children.find(
1146
+ (c): c is XmlElementNode => c.type === "element" && localName(c.name) === "framePr",
1147
+ );
1148
+ if (framePrNode) frameProperties = readFrameProperties(framePrNode);
1149
+ }
1053
1150
  sectionProperties = readSectionPropertiesFromPPr(child);
1054
1151
  sectionPropertiesXml = readSectionPropertiesXmlFromPPr(child, sourceXml);
1055
1152
  paragraphSupported = paragraphSupported && supportsParagraphProperties(child);
@@ -1148,6 +1245,10 @@ function parseBodyChild(
1148
1245
  flushActiveComplexField(children, () => {
1149
1246
  activeComplexField = null;
1150
1247
  }, activeComplexField);
1248
+ if (shouldStripCosmeticMarker()) {
1249
+ noteStrippedCosmeticMarker("proofErr");
1250
+ break;
1251
+ }
1151
1252
  children.push({
1152
1253
  type: "opaque_inline",
1153
1254
  rawXml: sourceXml.slice(child.start, child.end),
@@ -1235,6 +1336,7 @@ function parseBodyChild(
1235
1336
  ...(bidi !== undefined ? { bidi } : {}),
1236
1337
  ...(suppressLineNumbers !== undefined ? { suppressLineNumbers } : {}),
1237
1338
  ...(cnfStyle ? { cnfStyle } : {}),
1339
+ ...(frameProperties ? { frameProperties } : {}),
1238
1340
  ...(wordExtensionIds ? { wordExtensionIds } : {}),
1239
1341
  ...(sectionProperties ? { sectionProperties } : {}),
1240
1342
  ...(sectionPropertiesXml ? { sectionPropertiesXml } : {}),
@@ -2584,6 +2686,11 @@ function parseRun(
2584
2686
  }
2585
2687
  case "lastRenderedPageBreak":
2586
2688
  case "proofErr":
2689
+ case "noBreakHyphen":
2690
+ if (shouldStripCosmeticMarker()) {
2691
+ noteStrippedCosmeticMarker(localName(child.name));
2692
+ break;
2693
+ }
2587
2694
  result.push({
2588
2695
  type: "opaque_inline",
2589
2696
  rawXml: sourceXml.slice(child.start, child.end),
@@ -2657,12 +2764,23 @@ function parseRevisionContainer(
2657
2764
  result.push(hyperlink);
2658
2765
  break;
2659
2766
  }
2767
+ case "proofErr":
2768
+ case "lastRenderedPageBreak":
2769
+ case "noBreakHyphen":
2770
+ if (shouldStripCosmeticMarker()) {
2771
+ noteStrippedCosmeticMarker(localName(child.name));
2772
+ break;
2773
+ }
2774
+ return [
2775
+ {
2776
+ type: "opaque_inline",
2777
+ rawXml: sourceXml.slice(node.start, node.end),
2778
+ },
2779
+ ];
2660
2780
  case "commentRangeStart":
2661
2781
  case "commentRangeEnd":
2662
2782
  case "bookmarkStart":
2663
2783
  case "bookmarkEnd":
2664
- case "proofErr":
2665
- case "lastRenderedPageBreak":
2666
2784
  return [
2667
2785
  {
2668
2786
  type: "opaque_inline",
@@ -2835,10 +2953,17 @@ function parseRunContentOnly(
2835
2953
  case "commentReference":
2836
2954
  case "lastRenderedPageBreak":
2837
2955
  case "proofErr":
2956
+ case "noBreakHyphen": {
2957
+ const tag = localName(child.name);
2958
+ if (shouldStripCosmeticMarker() && tag !== "commentReference") {
2959
+ noteStrippedCosmeticMarker(tag);
2960
+ break;
2961
+ }
2838
2962
  if (options.preserveUnsupportedReviewMarkup) {
2839
2963
  return { nodes: [], supported: false };
2840
2964
  }
2841
2965
  break;
2966
+ }
2842
2967
  default:
2843
2968
  return { nodes: [], supported: false };
2844
2969
  }
@@ -204,7 +204,7 @@ function readShading(node: XmlElementNode): ParagraphShading | undefined {
204
204
  * The typed attributes cover the CCEP cases we've seen (2-column inset
205
205
  * text frames, drop-caps); extension attrs are rare in that corpus.
206
206
  */
207
- function readFrameProperties(node: XmlElementNode): FrameProperties | undefined {
207
+ export function readFrameProperties(node: XmlElementNode): FrameProperties | undefined {
208
208
  const out: FrameProperties = {};
209
209
  const width = readIntAttr(node, "w:w");
210
210
  if (width !== undefined) out.widthTwips = width;
@@ -25,9 +25,17 @@ export interface BoundaryAssoc {
25
25
  end: Assoc;
26
26
  }
27
27
 
28
+ /**
29
+ * Range anchor — flat shape `{ kind: "range", from, to, assoc }` after the
30
+ * 2026-04-23 flat-wins collapse. Prior to the collapse this carried a nested
31
+ * `{ range: DocRange, assoc }` shape. See `canonical-document.ts` CanonicalAnchor
32
+ * for the matching canonical shape + `repairCanonicalDocumentEnvelope` for the
33
+ * persisted-snapshot legacy-to-flat migration.
34
+ */
28
35
  export interface RangeAnchor {
29
36
  kind: "range";
30
- range: DocRange;
37
+ from: Position;
38
+ to: Position;
31
39
  assoc: BoundaryAssoc;
32
40
  }
33
41
 
@@ -2013,7 +2013,8 @@ export interface BoundaryAssoc {
2013
2013
  export type CanonicalAnchor =
2014
2014
  | {
2015
2015
  kind: "range";
2016
- range: DocRange;
2016
+ from: number;
2017
+ to: number;
2017
2018
  assoc: BoundaryAssoc;
2018
2019
  }
2019
2020
  | {
@@ -2329,7 +2330,7 @@ export function repairCanonicalDocumentEnvelope(
2329
2330
  review:
2330
2331
  record.review === undefined
2331
2332
  ? base.review
2332
- : (record.review as CanonicalDocument["review"]),
2333
+ : (migrateLegacyReviewAnchors(record.review) as CanonicalDocument["review"]),
2333
2334
  preservation:
2334
2335
  record.preservation === undefined
2335
2336
  ? base.preservation
@@ -2355,6 +2356,67 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
2355
2356
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
2356
2357
  }
2357
2358
 
2359
+ /**
2360
+ * Schema-compat shim for the 2026-04-23 flat-wins anchor collapse. Legacy
2361
+ * persisted snapshots carry range anchors as `{kind: "range", range: {from, to},
2362
+ * assoc}`. Rewrite those to the flat shape `{kind: "range", from, to, assoc}`
2363
+ * on rehydration so old snapshots continue to load.
2364
+ */
2365
+ export function migrateLegacyReviewAnchors(review: unknown): unknown {
2366
+ if (!isPlainRecord(review)) {
2367
+ return review;
2368
+ }
2369
+ const migrated: Record<string, unknown> = { ...review };
2370
+ if (isPlainRecord(review.comments)) {
2371
+ migrated.comments = Object.fromEntries(
2372
+ Object.entries(review.comments).map(([id, record]) => [
2373
+ id,
2374
+ migrateReviewRecordAnchor(record),
2375
+ ]),
2376
+ );
2377
+ }
2378
+ if (isPlainRecord(review.revisions)) {
2379
+ migrated.revisions = Object.fromEntries(
2380
+ Object.entries(review.revisions).map(([id, record]) => [
2381
+ id,
2382
+ migrateReviewRecordAnchor(record),
2383
+ ]),
2384
+ );
2385
+ }
2386
+ return migrated;
2387
+ }
2388
+
2389
+ function migrateReviewRecordAnchor(record: unknown): unknown {
2390
+ if (!isPlainRecord(record)) {
2391
+ return record;
2392
+ }
2393
+ const anchor = record.anchor;
2394
+ if (!isPlainRecord(anchor) || anchor.kind !== "range") {
2395
+ return record;
2396
+ }
2397
+ if (typeof anchor.from === "number" && typeof anchor.to === "number") {
2398
+ return record;
2399
+ }
2400
+ const nested = anchor.range;
2401
+ if (
2402
+ isPlainRecord(nested) &&
2403
+ typeof nested.from === "number" &&
2404
+ typeof nested.to === "number"
2405
+ ) {
2406
+ const { range: _range, ...rest } = anchor;
2407
+ void _range;
2408
+ return {
2409
+ ...record,
2410
+ anchor: {
2411
+ ...rest,
2412
+ from: nested.from,
2413
+ to: nested.to,
2414
+ },
2415
+ };
2416
+ }
2417
+ return record;
2418
+ }
2419
+
2358
2420
  export function serializeCanonicalDocument(document: CanonicalDocument): string {
2359
2421
  assertCanonicalDocument(document);
2360
2422
  return stableStringify(document);
@@ -3507,7 +3569,18 @@ function validateAnchor(
3507
3569
  }
3508
3570
 
3509
3571
  if (kind === "range") {
3510
- validateRange(record.range, `${path}.range`, issues);
3572
+ if (typeof record.from !== "number") {
3573
+ issues.push({
3574
+ path: `${path}.from`,
3575
+ message: "range anchor from must be a number.",
3576
+ });
3577
+ }
3578
+ if (typeof record.to !== "number") {
3579
+ issues.push({
3580
+ path: `${path}.to`,
3581
+ message: "range anchor to must be a number.",
3582
+ });
3583
+ }
3511
3584
  validateBoundaryAssoc(record.assoc, `${path}.assoc`, issues);
3512
3585
  } else if (kind === "node") {
3513
3586
  if (typeof record.at !== "number") {
@@ -170,6 +170,15 @@ export function describeStructuredWrapperBlock(
170
170
  };
171
171
  }
172
172
  if (block.properties.sdtType === "docPartObj") {
173
+ // coord-02 §11 P0 — a Template content control that wraps a
174
+ // drawing_frame (CCEP header logos, cover hero photos) must stay
175
+ // recursable so surface-projection emits the inner image segment.
176
+ // Collapsing to opaque_block here silently drops every image
177
+ // inside a docPartObj SDT. Only collapse when the content is
178
+ // genuinely wrapper-heavy with no user-visible media.
179
+ if (sdtContainsDrawingFrame(block)) {
180
+ return null;
181
+ }
173
182
  return {
174
183
  featureKey: "content-controls",
175
184
  label: "Template content control",
@@ -220,6 +229,21 @@ function createDetail(fragment: OpaqueFragmentRecord): string {
220
229
  : "Preserved whole-unit to keep unsupported OOXML intact.";
221
230
  }
222
231
 
232
+ function sdtContainsDrawingFrame(block: Extract<BlockNode, { type: "sdt" }>): boolean {
233
+ const visit = (node: unknown): boolean => {
234
+ if (!node || typeof node !== "object") return false;
235
+ const n = node as { type?: string; children?: readonly unknown[] };
236
+ if (n.type === "drawing_frame") return true;
237
+ if (Array.isArray(n.children)) {
238
+ for (const child of n.children) {
239
+ if (visit(child)) return true;
240
+ }
241
+ }
242
+ return false;
243
+ };
244
+ return block.children.some(visit);
245
+ }
246
+
223
247
  function isTocContentControl(block: Extract<BlockNode, { type: "sdt" }>): boolean {
224
248
  const searchText = [
225
249
  block.properties.alias,
@@ -47,7 +47,7 @@ export function summarizeCommentAnchor(anchor: CommentAnchor): CommentAnchorSumm
47
47
  return {
48
48
  anchor,
49
49
  state: "active",
50
- range: normalizeRange(anchor.range),
50
+ range: normalizeRange({ from: anchor.from, to: anchor.to }),
51
51
  };
52
52
  }
53
53
 
@@ -98,7 +98,7 @@ function normalizeCommentAnchor(
98
98
 
99
99
  if (
100
100
  mappedAnchor.kind === "range" &&
101
- !rangeStaysWithinCommentableStory(nextContent, mappedAnchor.range)
101
+ !rangeStaysWithinCommentableStory(nextContent, { from: mappedAnchor.from, to: mappedAnchor.to })
102
102
  ) {
103
103
  return detachReviewAnchor(previousRange, "invalidatedByStructureChange");
104
104
  }
@@ -153,8 +153,8 @@ export function applyRevisionAction(
153
153
 
154
154
  const story = parseTextStory(options.document.content);
155
155
  const range = normalizeRange(
156
- revision.anchor.range.from,
157
- revision.anchor.range.to,
156
+ revision.anchor.from,
157
+ revision.anchor.to,
158
158
  );
159
159
 
160
160
  if (range.to > story.size) {
@@ -811,8 +811,8 @@ function resolveParagraphMarkDeletionRange(
811
811
 
812
812
  const paragraphs = mapParagraphRanges(story);
813
813
  const anchorPosition = normalizeRange(
814
- revision.anchor.range.from,
815
- revision.anchor.range.to,
814
+ revision.anchor.from,
815
+ revision.anchor.to,
816
816
  ).from;
817
817
  const paragraph = paragraphs.find(
818
818
  (candidate) =>
@@ -77,7 +77,7 @@ export function summarizeRevisionAnchor(anchor: RevisionAnchor): RevisionAnchorS
77
77
  return {
78
78
  anchor,
79
79
  state: "active",
80
- range: normalizeRange(anchor.range),
80
+ range: normalizeRange({ from: anchor.from, to: anchor.to }),
81
81
  };
82
82
  }
83
83
 
@@ -96,7 +96,7 @@ export function collectScopeTagTouches(
96
96
 
97
97
  function anchorRange(anchor: CanonicalAnchor): { from: number; to: number } {
98
98
  if (anchor.kind === "range") {
99
- return { from: anchor.range.from, to: anchor.range.to };
99
+ return { from: anchor.from, to: anchor.to };
100
100
  }
101
101
  if (anchor.kind === "node") {
102
102
  return { from: anchor.at, to: anchor.at };
@@ -88,10 +88,10 @@ export function mapLocalSelectionOnRemoteReplay(
88
88
 
89
89
  if (active.kind === "range") {
90
90
  const directionForward = selection.anchor <= selection.head;
91
- const isCollapsed = active.range.from === active.range.to;
91
+ const isCollapsed = active.from === active.to;
92
92
 
93
93
  if (isCollapsed) {
94
- const collapsedAt = mapPosition(active.range.from, 1, mapping).position;
94
+ const collapsedAt = mapPosition(active.from, 1, mapping).position;
95
95
  return {
96
96
  anchor: collapsedAt,
97
97
  head: collapsedAt,
@@ -107,7 +107,7 @@ export function mapLocalSelectionOnRemoteReplay(
107
107
  const mapped = mapAnchor(active, mapping);
108
108
 
109
109
  if (mapped.kind === "range") {
110
- const { from, to } = mapped.range;
110
+ const { from, to } = mapped;
111
111
  return {
112
112
  anchor: directionForward ? from : to,
113
113
  head: directionForward ? to : from,
@@ -122,7 +122,7 @@ export function mapLocalSelectionOnRemoteReplay(
122
122
  // positions that land inside a deleted span to `step.from +
123
123
  // insertSize`, which is the correct logical "where the selection
124
124
  // used to start" position.
125
- const collapsedAt = mapPosition(active.range.from, 1, mapping).position;
125
+ const collapsedAt = mapPosition(active.from, 1, mapping).position;
126
126
  return {
127
127
  anchor: collapsedAt,
128
128
  head: collapsedAt,
@@ -147,9 +147,9 @@ export function mapLocalSelectionOnRemoteReplay(
147
147
  }
148
148
  if (mapped.kind === "range") {
149
149
  return {
150
- anchor: mapped.range.from,
151
- head: mapped.range.to,
152
- isCollapsed: mapped.range.from === mapped.range.to,
150
+ anchor: mapped.from,
151
+ head: mapped.to,
152
+ isCollapsed: mapped.from === mapped.to,
153
153
  activeRange: mapped,
154
154
  };
155
155
  }