@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
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type {
10
10
  CanonicalParagraphFormatting,
11
+ FrameProperties,
11
12
  ParagraphBorders,
12
13
  ParagraphIndentation,
13
14
  ParagraphShading,
@@ -49,9 +50,37 @@ const PPR_MODELLED_CHILDREN: ReadonlySet<string> = new Set([
49
50
  "suppressLineNumbers",
50
51
  "suppressAutoHyphens",
51
52
  "outlineLvl",
53
+ "framePr",
52
54
  "rPr",
53
55
  ]);
54
56
 
57
+ const FRAME_H_RULE_VOCAB = new Set<NonNullable<FrameProperties["hRule"]>>(["auto", "atLeast", "exact"]);
58
+ const FRAME_X_ALIGN_VOCAB = new Set<NonNullable<FrameProperties["xAlign"]>>([
59
+ "left",
60
+ "center",
61
+ "right",
62
+ "inside",
63
+ "outside",
64
+ ]);
65
+ const FRAME_Y_ALIGN_VOCAB = new Set<NonNullable<FrameProperties["yAlign"]>>([
66
+ "top",
67
+ "center",
68
+ "bottom",
69
+ "inside",
70
+ "outside",
71
+ "inline",
72
+ ]);
73
+ const FRAME_ANCHOR_VOCAB = new Set<NonNullable<FrameProperties["hAnchor"]>>(["text", "margin", "page"]);
74
+ const FRAME_WRAP_VOCAB = new Set<NonNullable<FrameProperties["wrap"]>>([
75
+ "around",
76
+ "auto",
77
+ "none",
78
+ "notBeside",
79
+ "tight",
80
+ "through",
81
+ ]);
82
+ const FRAME_DROP_CAP_VOCAB = new Set<NonNullable<FrameProperties["dropCap"]>>(["none", "drop", "margin"]);
83
+
55
84
  const PPR_GRAB_BAG_DESCRIPTOR: PropertyGrabBagDescriptor = {
56
85
  modelledChildNames: PPR_MODELLED_CHILDREN,
57
86
  modelledChildAttributes: new Map(),
@@ -164,6 +193,76 @@ function readShading(node: XmlElementNode): ParagraphShading | undefined {
164
193
  };
165
194
  }
166
195
 
196
+ /**
197
+ * `<w:framePr>` — paragraph text-frame properties (ECMA-376 §17.3.1.11).
198
+ *
199
+ * All children of framePr are attributes on a single self-closing element;
200
+ * this reader maps each modelled attribute to its typed field and ignores
201
+ * extension attributes (`w14:*`, `w15:*`, `mc:Ignorable`, etc.). Extension-
202
+ * attribute round-trip via `FrameProperties.rawXml` is a TODO — the current
203
+ * signature doesn't receive the source XML string, so rawXml is left unset.
204
+ * The typed attributes cover the CCEP cases we've seen (2-column inset
205
+ * text frames, drop-caps); extension attrs are rare in that corpus.
206
+ */
207
+ function readFrameProperties(node: XmlElementNode): FrameProperties | undefined {
208
+ const out: FrameProperties = {};
209
+ const width = readIntAttr(node, "w:w");
210
+ if (width !== undefined) out.widthTwips = width;
211
+ const height = readIntAttr(node, "w:h");
212
+ if (height !== undefined) out.heightTwips = height;
213
+ const hRuleRaw = (node.attributes["w:hRule"] ?? node.attributes.hRule)?.trim();
214
+ if (hRuleRaw) {
215
+ const lower = hRuleRaw.charAt(0).toLowerCase() + hRuleRaw.slice(1);
216
+ const candidate = lower === "atleast" ? "atLeast" : lower;
217
+ if (FRAME_H_RULE_VOCAB.has(candidate as NonNullable<FrameProperties["hRule"]>)) {
218
+ out.hRule = candidate as NonNullable<FrameProperties["hRule"]>;
219
+ }
220
+ }
221
+ const x = readIntAttr(node, "w:x");
222
+ if (x !== undefined) out.xTwips = x;
223
+ const y = readIntAttr(node, "w:y");
224
+ if (y !== undefined) out.yTwips = y;
225
+ const xAlign = (node.attributes["w:xAlign"] ?? node.attributes.xAlign)?.toLowerCase().trim();
226
+ if (xAlign && FRAME_X_ALIGN_VOCAB.has(xAlign as NonNullable<FrameProperties["xAlign"]>)) {
227
+ out.xAlign = xAlign as NonNullable<FrameProperties["xAlign"]>;
228
+ }
229
+ const yAlign = (node.attributes["w:yAlign"] ?? node.attributes.yAlign)?.toLowerCase().trim();
230
+ if (yAlign && FRAME_Y_ALIGN_VOCAB.has(yAlign as NonNullable<FrameProperties["yAlign"]>)) {
231
+ out.yAlign = yAlign as NonNullable<FrameProperties["yAlign"]>;
232
+ }
233
+ const hAnchor = (node.attributes["w:hAnchor"] ?? node.attributes.hAnchor)?.toLowerCase().trim();
234
+ if (hAnchor && FRAME_ANCHOR_VOCAB.has(hAnchor as NonNullable<FrameProperties["hAnchor"]>)) {
235
+ out.hAnchor = hAnchor as NonNullable<FrameProperties["hAnchor"]>;
236
+ }
237
+ const vAnchor = (node.attributes["w:vAnchor"] ?? node.attributes.vAnchor)?.toLowerCase().trim();
238
+ if (vAnchor && FRAME_ANCHOR_VOCAB.has(vAnchor as NonNullable<FrameProperties["vAnchor"]>)) {
239
+ out.vAnchor = vAnchor as NonNullable<FrameProperties["vAnchor"]>;
240
+ }
241
+ const wrapRaw = (node.attributes["w:wrap"] ?? node.attributes.wrap)?.trim();
242
+ if (wrapRaw) {
243
+ const wrapLower = wrapRaw.charAt(0).toLowerCase() + wrapRaw.slice(1);
244
+ const wrapCandidate = wrapLower === "notbeside" ? "notBeside" : wrapLower;
245
+ if (FRAME_WRAP_VOCAB.has(wrapCandidate as NonNullable<FrameProperties["wrap"]>)) {
246
+ out.wrap = wrapCandidate as NonNullable<FrameProperties["wrap"]>;
247
+ }
248
+ }
249
+ const hSpace = readIntAttr(node, "w:hSpace");
250
+ if (hSpace !== undefined) out.hSpaceTwips = hSpace;
251
+ const vSpace = readIntAttr(node, "w:vSpace");
252
+ if (vSpace !== undefined) out.vSpaceTwips = vSpace;
253
+ const dropCap = (node.attributes["w:dropCap"] ?? node.attributes.dropCap)?.toLowerCase().trim();
254
+ if (dropCap && FRAME_DROP_CAP_VOCAB.has(dropCap as NonNullable<FrameProperties["dropCap"]>)) {
255
+ out.dropCap = dropCap as NonNullable<FrameProperties["dropCap"]>;
256
+ }
257
+ const lines = readIntAttr(node, "w:lines");
258
+ if (lines !== undefined) out.lines = lines;
259
+ const anchorLockRaw = (node.attributes["w:anchorLock"] ?? node.attributes.anchorLock)?.toLowerCase().trim();
260
+ if (anchorLockRaw !== undefined && anchorLockRaw.length > 0) {
261
+ out.anchorLock = !(anchorLockRaw === "false" || anchorLockRaw === "0" || anchorLockRaw === "off");
262
+ }
263
+ return Object.keys(out).length > 0 ? out : undefined;
264
+ }
265
+
167
266
  export function readParagraphProperties(
168
267
  node: XmlElementNode | undefined,
169
268
  ): CanonicalParagraphFormatting | undefined {
@@ -237,6 +336,12 @@ export function readParagraphProperties(
237
336
  const outline = readIntVal(findChildOptional(node, "outlineLvl"));
238
337
  if (outline !== undefined) out.outlineLevel = outline;
239
338
 
339
+ const framePrNode = findChildOptional(node, "framePr");
340
+ if (framePrNode) {
341
+ const frameProperties = readFrameProperties(framePrNode);
342
+ if (frameProperties) out.frameProperties = frameProperties;
343
+ }
344
+
240
345
  const rPrNode = findChildOptional(node, "rPr");
241
346
  const markRpr = readRunProperties(rPrNode);
242
347
  if (markRpr) out.paragraphMarkRunProperties = markRpr;
@@ -697,6 +697,7 @@ export type DocumentNode =
697
697
  | HardBreakNode
698
698
  | TabNode
699
699
  | ColumnBreakNode
700
+ | PageBreakNode
700
701
  | SymbolNode
701
702
  | HyperlinkNode
702
703
  | ImageNode
@@ -874,6 +875,20 @@ export interface CanonicalParagraphFormatting {
874
875
  suppressLineNumbers?: boolean;
875
876
  suppressAutoHyphens?: boolean;
876
877
  paragraphMarkRunProperties?: CanonicalRunFormatting;
878
+ /**
879
+ * `<w:framePr>` — paragraph text-frame properties (ECMA-376 §17.3.1.11).
880
+ * Absent on ordinary paragraphs. When present, the paragraph renders
881
+ * inside a positioned text frame rather than in-flow. Used in CCEP-style
882
+ * templates for side-by-side "instructional column + body column"
883
+ * paragraphs and for drop-caps.
884
+ *
885
+ * Added 2026-04-23 per `docs/KNOWN-ISSUES-VISUAL.md §2.3 "w:framePr
886
+ * 2-column inset text frames"`. Before this type existed, L01's parser
887
+ * fell through to `OpaqueBlockNode` whenever `<w:pPr>` contained
888
+ * `<w:framePr>`, which hid the paragraph from L04 pagination and L11
889
+ * render. Canonical representation unblocks downstream adoption.
890
+ */
891
+ frameProperties?: FrameProperties;
877
892
  /**
878
893
  * Unmodelled direct children of `<w:pPr>` captured verbatim for round-trip.
879
894
  * See `src/io/ooxml/property-grab-bag.ts` for the mechanism and Lane 3 O2
@@ -888,6 +903,80 @@ export interface CanonicalParagraphFormatting {
888
903
  unknownPropertyChildren?: UnknownPropertyChild[];
889
904
  }
890
905
 
906
+ /**
907
+ * Paragraph text-frame properties from `<w:framePr>` (ECMA-376 §17.3.1.11).
908
+ * Pure data — describes a positioned text frame that contains the
909
+ * paragraph. When `frameProperties` is set on a paragraph's formatting,
910
+ * L04 pagination and L11 render treat the paragraph as an out-of-flow
911
+ * frame positioned according to the fields below, not as an in-flow
912
+ * block.
913
+ *
914
+ * All fields optional; absence carries its OOXML default.
915
+ */
916
+ export interface FrameProperties {
917
+ /** `w:w` — frame width in twips. Absence = auto-width from content. */
918
+ widthTwips?: number;
919
+ /** `w:h` — frame height in twips. Interpretation depends on `hRule`. */
920
+ heightTwips?: number;
921
+ /**
922
+ * `w:hRule` — height rule.
923
+ * - `"auto"` (default): height follows content.
924
+ * - `"atLeast"`: content grows the frame; `heightTwips` is the floor.
925
+ * - `"exact"`: content is clipped to `heightTwips`.
926
+ */
927
+ hRule?: "auto" | "atLeast" | "exact";
928
+ /** `w:x` — horizontal absolute position in twips from `hAnchor`. */
929
+ xTwips?: number;
930
+ /** `w:y` — vertical absolute position in twips from `vAnchor`. */
931
+ yTwips?: number;
932
+ /**
933
+ * `w:xAlign` — horizontal alignment keyword; overrides `xTwips` if
934
+ * both are set (Word behavior). `"inside"` / `"outside"` are
935
+ * mirror-aware for even/odd pages.
936
+ */
937
+ xAlign?: "left" | "center" | "right" | "inside" | "outside";
938
+ /**
939
+ * `w:yAlign` — vertical alignment keyword; overrides `yTwips` if
940
+ * both are set. `"inline"` places the frame in the text flow.
941
+ */
942
+ yAlign?: "top" | "center" | "bottom" | "inside" | "outside" | "inline";
943
+ /** `w:hAnchor` — what `x` / `xAlign` is measured from. */
944
+ hAnchor?: "text" | "margin" | "page";
945
+ /** `w:vAnchor` — what `y` / `yAlign` is measured from. */
946
+ vAnchor?: "text" | "margin" | "page";
947
+ /**
948
+ * `w:wrap` — how surrounding text flows around the frame.
949
+ * `"around"`: text wraps on both sides; `"notBeside"`: text stops
950
+ * above/below the frame; `"none"`: no text on the frame line;
951
+ * `"auto"` / `"tight"` / `"through"`: extended wrap modes.
952
+ */
953
+ wrap?: "around" | "auto" | "none" | "notBeside" | "tight" | "through";
954
+ /** `w:hSpace` — horizontal clear-space around the frame, twips. */
955
+ hSpaceTwips?: number;
956
+ /** `w:vSpace` — vertical clear-space around the frame, twips. */
957
+ vSpaceTwips?: number;
958
+ /**
959
+ * `w:dropCap` — drop-cap semantics when the frame holds an initial
960
+ * letter. `"none"` (default): not a drop-cap frame.
961
+ * `"drop"`: character spans `lines` lines, text wraps around it.
962
+ * `"margin"`: character hangs in the margin.
963
+ */
964
+ dropCap?: "none" | "drop" | "margin";
965
+ /** `w:lines` — number of lines the drop-cap spans. Only meaningful when `dropCap !== "none"`. */
966
+ lines?: number;
967
+ /** `w:anchorLock` — prevents the frame's anchor paragraph from moving between pages during reflow. */
968
+ anchorLock?: boolean;
969
+ /**
970
+ * Verbatim source XML of the `<w:framePr>` element. Populated by
971
+ * the parser for lossless round-trip of extension attributes
972
+ * (`w14:*`, `w15:*`, `mc:Ignorable`, etc.) that the fields above do
973
+ * not model. When re-serializing, the writer should prefer the
974
+ * modeled fields and merge in any extension attributes from
975
+ * `rawXml` that aren't covered by the modeled set.
976
+ */
977
+ rawXml?: string;
978
+ }
979
+
891
980
  /**
892
981
  * A single unmodelled direct child of an OOXML property container (pPr,
893
982
  * rPr, tcPr, trPr, tblPr, sectPr). Captured verbatim so the serializer
@@ -956,6 +1045,24 @@ export interface ParagraphNode {
956
1045
  bidi?: boolean;
957
1046
  suppressLineNumbers?: boolean;
958
1047
  cnfStyle?: string;
1048
+ /**
1049
+ * `<w:framePr>` — inline paragraph text-frame properties set
1050
+ * directly on the paragraph (not through the style cascade).
1051
+ * Mirrors `CanonicalParagraphFormatting.frameProperties`; the two
1052
+ * slots coexist because Word writes framePr at both levels: a
1053
+ * style may declare the frame in `docDefaults` / named style
1054
+ * (cascade path), or an individual paragraph may override with
1055
+ * its own `<w:pPr><w:framePr>` (direct path). L03's cascade
1056
+ * resolver prefers the direct-paragraph value when both are set,
1057
+ * matching Word's cascade semantics.
1058
+ *
1059
+ * Added 2026-04-23 per `cross-layer-coord-02.md §11 P1` follow-up
1060
+ * to the earlier FrameProperties landing (`86961dcb`). Without
1061
+ * this slot, direct-paragraph `<w:framePr>` would still flatten
1062
+ * to `OpaqueBlockNode` because the parser had no canonical target
1063
+ * at the node level.
1064
+ */
1065
+ frameProperties?: FrameProperties;
959
1066
  /**
960
1067
  * Preserved w14 extension identifiers for this paragraph.
961
1068
  * Round-trip (§2 A.7) requires these to survive import → export so the
@@ -1514,6 +1621,7 @@ export type InlineNode =
1514
1621
  | TextNode
1515
1622
  | HardBreakNode
1516
1623
  | ColumnBreakNode
1624
+ | PageBreakNode
1517
1625
  | TabNode
1518
1626
  | SymbolNode
1519
1627
  | HyperlinkNode
@@ -1570,6 +1678,24 @@ export interface ColumnBreakNode {
1570
1678
  type: "column_break";
1571
1679
  }
1572
1680
 
1681
+ /**
1682
+ * Explicit page break — `<w:br w:type="page"/>` in OOXML (ECMA-376 §17.3.3.1).
1683
+ * Forces the next content to start on a new page. Emitted by L01's
1684
+ * `parse-main-document.ts` when `<w:br w:type="page"/>` is encountered
1685
+ * inside a paragraph run; consumed by L04's pagination engine which
1686
+ * pushes a page boundary after placing any block whose segments carry
1687
+ * a `page_break`. Added 2026-04-23 per `cross-layer-coord-04.md §1.18.5`
1688
+ * + `cross-layer-coord-03.md §11` to close the explicit-page-break
1689
+ * silently-dropped visual regression.
1690
+ *
1691
+ * Not to be confused with `SectionBreakNode` (`<w:sectPr><w:type
1692
+ * w:val="page"/>`) which is a section-level boundary and carries
1693
+ * section properties; `PageBreakNode` is inline inside a run.
1694
+ */
1695
+ export interface PageBreakNode {
1696
+ type: "page_break";
1697
+ }
1698
+
1573
1699
  export interface TabNode {
1574
1700
  type: "tab";
1575
1701
  }
@@ -1584,7 +1710,7 @@ export interface SymbolNode {
1584
1710
  export interface HyperlinkNode {
1585
1711
  type: "hyperlink";
1586
1712
  href: string;
1587
- children: Array<TextNode | HardBreakNode | ColumnBreakNode | TabNode | SymbolNode>;
1713
+ children: Array<TextNode | HardBreakNode | ColumnBreakNode | PageBreakNode | TabNode | SymbolNode>;
1588
1714
  }
1589
1715
 
1590
1716
  export interface ImageNode {
@@ -2284,6 +2410,12 @@ export function validateCanonicalDocument(
2284
2410
  if (record.subParts !== undefined) {
2285
2411
  validateSubPartsCatalog(record.subParts, "$.subParts", issues);
2286
2412
  }
2413
+ if (record.fieldRegistry !== undefined) {
2414
+ validateFieldRegistry(record.fieldRegistry, "$.fieldRegistry", issues);
2415
+ }
2416
+ if (record.fontTable !== undefined) {
2417
+ validateCanonicalFontTable(record.fontTable, "$.fontTable", issues);
2418
+ }
2287
2419
  validateDocumentReferences(record, issues);
2288
2420
 
2289
2421
  return issues;
@@ -2646,6 +2778,7 @@ function validateDocumentNode(
2646
2778
  return;
2647
2779
  case "hard_break":
2648
2780
  case "column_break":
2781
+ case "page_break":
2649
2782
  case "tab":
2650
2783
  return;
2651
2784
  case "symbol":
@@ -3794,6 +3927,273 @@ function validateExactObjectKeys(
3794
3927
  }
3795
3928
  }
3796
3929
 
3930
+ const SUPPORTED_FIELD_FAMILIES: ReadonlySet<SupportedFieldFamily> = new Set([
3931
+ "REF",
3932
+ "PAGEREF",
3933
+ "NOTEREF",
3934
+ "TOC",
3935
+ "PAGE",
3936
+ "NUMPAGES",
3937
+ "STYLEREF",
3938
+ "SECTIONPAGES",
3939
+ ]);
3940
+
3941
+ const PRESERVE_ONLY_FIELD_FAMILIES: ReadonlySet<PreserveOnlyFieldFamily> = new Set([
3942
+ "DATE",
3943
+ "TIME",
3944
+ "AUTHOR",
3945
+ "FILENAME",
3946
+ "MERGEFIELD",
3947
+ "IF",
3948
+ "SEQ",
3949
+ "INDEX",
3950
+ "TC",
3951
+ "FORMULA",
3952
+ "UNKNOWN",
3953
+ ]);
3954
+
3955
+ const FIELD_REFRESH_STATUSES: ReadonlySet<FieldRefreshStatus> = new Set([
3956
+ "current",
3957
+ "stale",
3958
+ "unresolvable",
3959
+ "preserve-only",
3960
+ ]);
3961
+
3962
+ const FONT_FAMILY_VALUES: ReadonlySet<string> = new Set([
3963
+ "roman",
3964
+ "swiss",
3965
+ "modern",
3966
+ "script",
3967
+ "decorative",
3968
+ ]);
3969
+
3970
+ const FONT_PITCH_VALUES: ReadonlySet<string> = new Set([
3971
+ "fixed",
3972
+ "variable",
3973
+ "default",
3974
+ ]);
3975
+
3976
+ function validateFieldRegistry(
3977
+ value: unknown,
3978
+ path: string,
3979
+ issues: ModelValidationIssue[],
3980
+ ): void {
3981
+ const record = asPlainObject(value, path, issues);
3982
+ if (!record) {
3983
+ return;
3984
+ }
3985
+
3986
+ if (!Array.isArray(record.supported)) {
3987
+ issues.push({ path: `${path}.supported`, message: "supported must be an array." });
3988
+ } else {
3989
+ record.supported.forEach((entry, index) => {
3990
+ validateFieldRegistryEntry(entry, `${path}.supported[${index}]`, issues);
3991
+ });
3992
+ }
3993
+
3994
+ if (!Array.isArray(record.preserveOnly)) {
3995
+ issues.push({ path: `${path}.preserveOnly`, message: "preserveOnly must be an array." });
3996
+ } else {
3997
+ record.preserveOnly.forEach((entry, index) => {
3998
+ validateFieldRegistryEntry(entry, `${path}.preserveOnly[${index}]`, issues);
3999
+ });
4000
+ }
4001
+
4002
+ if (record.tocStructure !== undefined) {
4003
+ validateTocStructure(record.tocStructure, `${path}.tocStructure`, issues);
4004
+ }
4005
+ }
4006
+
4007
+ function validateFieldRegistryEntry(
4008
+ value: unknown,
4009
+ path: string,
4010
+ issues: ModelValidationIssue[],
4011
+ ): void {
4012
+ const record = asPlainObject(value, path, issues);
4013
+ if (!record) {
4014
+ return;
4015
+ }
4016
+
4017
+ if (!Number.isInteger(record.fieldIndex) || (record.fieldIndex as number) < 0) {
4018
+ issues.push({
4019
+ path: `${path}.fieldIndex`,
4020
+ message: "fieldIndex must be a non-negative integer.",
4021
+ });
4022
+ }
4023
+
4024
+ if (
4025
+ typeof record.fieldFamily !== "string" ||
4026
+ !(
4027
+ SUPPORTED_FIELD_FAMILIES.has(record.fieldFamily as SupportedFieldFamily) ||
4028
+ PRESERVE_ONLY_FIELD_FAMILIES.has(record.fieldFamily as PreserveOnlyFieldFamily)
4029
+ )
4030
+ ) {
4031
+ issues.push({
4032
+ path: `${path}.fieldFamily`,
4033
+ message: "fieldFamily must be a SupportedFieldFamily or PreserveOnlyFieldFamily.",
4034
+ });
4035
+ }
4036
+
4037
+ if (typeof record.supported !== "boolean") {
4038
+ issues.push({ path: `${path}.supported`, message: "supported must be a boolean." });
4039
+ }
4040
+
4041
+ expectString(record.instruction, `${path}.instruction`, issues);
4042
+
4043
+ if (record.fieldTarget !== undefined) {
4044
+ expectString(record.fieldTarget, `${path}.fieldTarget`, issues);
4045
+ }
4046
+
4047
+ if (typeof record.displayText !== "string") {
4048
+ issues.push({ path: `${path}.displayText`, message: "displayText must be a string." });
4049
+ }
4050
+
4051
+ if (!Number.isInteger(record.paragraphIndex) || (record.paragraphIndex as number) < 0) {
4052
+ issues.push({
4053
+ path: `${path}.paragraphIndex`,
4054
+ message: "paragraphIndex must be a non-negative integer.",
4055
+ });
4056
+ }
4057
+
4058
+ if (
4059
+ typeof record.refreshStatus !== "string" ||
4060
+ !FIELD_REFRESH_STATUSES.has(record.refreshStatus as FieldRefreshStatus)
4061
+ ) {
4062
+ issues.push({
4063
+ path: `${path}.refreshStatus`,
4064
+ message:
4065
+ "refreshStatus must be one of: current, stale, unresolvable, preserve-only.",
4066
+ });
4067
+ }
4068
+ }
4069
+
4070
+ function validateTocStructure(
4071
+ value: unknown,
4072
+ path: string,
4073
+ issues: ModelValidationIssue[],
4074
+ ): void {
4075
+ const record = asPlainObject(value, path, issues);
4076
+ if (!record) {
4077
+ return;
4078
+ }
4079
+
4080
+ expectString(record.instruction, `${path}.instruction`, issues);
4081
+
4082
+ const levelRange = asPlainObject(record.levelRange, `${path}.levelRange`, issues);
4083
+ if (levelRange) {
4084
+ if (!Number.isInteger(levelRange.from) || (levelRange.from as number) < 1) {
4085
+ issues.push({
4086
+ path: `${path}.levelRange.from`,
4087
+ message: "levelRange.from must be an integer ≥ 1.",
4088
+ });
4089
+ }
4090
+ if (!Number.isInteger(levelRange.to) || (levelRange.to as number) < 1) {
4091
+ issues.push({
4092
+ path: `${path}.levelRange.to`,
4093
+ message: "levelRange.to must be an integer ≥ 1.",
4094
+ });
4095
+ }
4096
+ }
4097
+
4098
+ if (!Array.isArray(record.entries)) {
4099
+ issues.push({ path: `${path}.entries`, message: "entries must be an array." });
4100
+ } else {
4101
+ record.entries.forEach((entry, index) => {
4102
+ const entryRecord = asPlainObject(entry, `${path}.entries[${index}]`, issues);
4103
+ if (!entryRecord) return;
4104
+ if (typeof entryRecord.text !== "string") {
4105
+ issues.push({
4106
+ path: `${path}.entries[${index}].text`,
4107
+ message: "text must be a string.",
4108
+ });
4109
+ }
4110
+ if (
4111
+ !Number.isInteger(entryRecord.level) ||
4112
+ (entryRecord.level as number) < 1 ||
4113
+ (entryRecord.level as number) > 9
4114
+ ) {
4115
+ issues.push({
4116
+ path: `${path}.entries[${index}].level`,
4117
+ message: "level must be an integer in [1..9].",
4118
+ });
4119
+ }
4120
+ if (!Number.isInteger(entryRecord.paragraphIndex)) {
4121
+ issues.push({
4122
+ path: `${path}.entries[${index}].paragraphIndex`,
4123
+ message: "paragraphIndex must be an integer.",
4124
+ });
4125
+ }
4126
+ if (entryRecord.styleId !== undefined) {
4127
+ expectString(entryRecord.styleId, `${path}.entries[${index}].styleId`, issues);
4128
+ }
4129
+ if (entryRecord.bookmarkName !== undefined) {
4130
+ expectString(
4131
+ entryRecord.bookmarkName,
4132
+ `${path}.entries[${index}].bookmarkName`,
4133
+ issues,
4134
+ );
4135
+ }
4136
+ });
4137
+ }
4138
+
4139
+ if (record.status !== "current" && record.status !== "stale") {
4140
+ issues.push({
4141
+ path: `${path}.status`,
4142
+ message: "status must be 'current' or 'stale'.",
4143
+ });
4144
+ }
4145
+ }
4146
+
4147
+ function validateCanonicalFontTable(
4148
+ value: unknown,
4149
+ path: string,
4150
+ issues: ModelValidationIssue[],
4151
+ ): void {
4152
+ const record = asPlainObject(value, path, issues);
4153
+ if (!record) {
4154
+ return;
4155
+ }
4156
+
4157
+ const fonts = asPlainObject(record.fonts, `${path}.fonts`, issues);
4158
+ if (!fonts) {
4159
+ return;
4160
+ }
4161
+
4162
+ for (const [fontKey, fontEntry] of Object.entries(fonts)) {
4163
+ const fontPath = `${path}.fonts[${JSON.stringify(fontKey)}]`;
4164
+ const entry = asPlainObject(fontEntry, fontPath, issues);
4165
+ if (!entry) continue;
4166
+
4167
+ expectString(entry.name, `${fontPath}.name`, issues);
4168
+
4169
+ if (entry.family !== undefined) {
4170
+ if (typeof entry.family !== "string" || !FONT_FAMILY_VALUES.has(entry.family)) {
4171
+ issues.push({
4172
+ path: `${fontPath}.family`,
4173
+ message: "family must be one of: roman, swiss, modern, script, decorative.",
4174
+ });
4175
+ }
4176
+ }
4177
+
4178
+ if (entry.pitch !== undefined) {
4179
+ if (typeof entry.pitch !== "string" || !FONT_PITCH_VALUES.has(entry.pitch)) {
4180
+ issues.push({
4181
+ path: `${fontPath}.pitch`,
4182
+ message: "pitch must be one of: fixed, variable, default.",
4183
+ });
4184
+ }
4185
+ }
4186
+
4187
+ if (entry.charset !== undefined && !Number.isInteger(entry.charset)) {
4188
+ issues.push({ path: `${fontPath}.charset`, message: "charset must be an integer." });
4189
+ }
4190
+
4191
+ if (entry.altName !== undefined) {
4192
+ expectString(entry.altName, `${fontPath}.altName`, issues);
4193
+ }
4194
+ }
4195
+ }
4196
+
3797
4197
  function validateSubPartsCatalog(
3798
4198
  value: unknown,
3799
4199
  path: string,
@@ -98,7 +98,8 @@ export type ProjectedSurfaceMark =
98
98
  | "imprint"
99
99
  | "shadow"
100
100
  | "smallCaps"
101
- | "allCaps";
101
+ | "allCaps"
102
+ | "highlight";
102
103
 
103
104
  export interface ProjectedRunMarks {
104
105
  readonly marks?: ReadonlyArray<ProjectedSurfaceMark>;
@@ -11,16 +11,13 @@
11
11
  * overlay-layer.tsx`) re-exports this via a direct import so its public
12
12
  * API surface is unchanged.
13
13
  *
14
- * **Coordinate-space caveat (known divergence).** The kernel stacks
15
- * pages with `PAGE_GAP_PX = 16` (see `src/runtime/render/render-kernel.ts`)
16
- * while the DOM stacks them with `interGapPx = 48` (see
17
- * `src/ui-tailwind/editor-surface/pm-page-break-decorations.ts`). Using
18
- * `page.frame.topPx` directly as an overlay rect is correct only for
19
- * page 0; for pages 2..N the overlay rect drifts by ~32 px per boundary.
20
- * This helper is the clean substrate; a subsequent Slice-3c reconciles
21
- * the two gap constants before any production consumer wires the warm
22
- * path. Until then the overlay component keeps its DOM-measurement path
23
- * as the default and treats the geometry path as experimental.
14
+ * **Coordinate-space reconciliation RESOLVED 2026-04-23
15
+ * (`LAYOUT_ENGINE_VERSION 51 52`, refactor/10 Slice L11-3).** Kernel
16
+ * `PAGE_GAP_PX` was bumped from 16 to 48 to match the DOM page-break
17
+ * widget's `interGapPx`. `page.frame.topPx` is now usable as an overlay
18
+ * rect for every page, not just page 0. The overlay component flipped
19
+ * to geometry-as-warm-path; the DOM-measurement branch remains as the
20
+ * cold-open fallback only.
24
21
  */
25
22
 
26
23
  import type { GeometryFacet } from "./geometry-facet.ts";