@beyondwork/docx-react-component 1.0.60 → 1.0.62

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 (42) hide show
  1. package/package.json +33 -44
  2. package/src/api/public-types.ts +41 -0
  3. package/src/io/docx-session.ts +167 -8
  4. package/src/io/export/serialize-footnotes.ts +36 -5
  5. package/src/io/export/serialize-headers-footers.ts +7 -0
  6. package/src/io/export/serialize-main-document.ts +25 -18
  7. package/src/io/export/serialize-paragraph-formatting.ts +6 -0
  8. package/src/io/export/serialize-settings.ts +130 -3
  9. package/src/io/normalize/normalize-text.ts +8 -4
  10. package/src/io/ooxml/classify-embedding.ts +193 -0
  11. package/src/io/ooxml/parse-footnotes.ts +11 -0
  12. package/src/io/ooxml/parse-headers-footers.ts +117 -42
  13. package/src/io/ooxml/parse-main-document.ts +20 -8
  14. package/src/io/ooxml/parse-object.ts +23 -0
  15. package/src/io/ooxml/parse-paragraph-formatting.ts +25 -1
  16. package/src/io/ooxml/parse-settings.ts +91 -1
  17. package/src/model/canonical-document.ts +36 -2
  18. package/src/runtime/document-runtime.ts +424 -0
  19. package/src/runtime/footnote-resolver.ts +32 -8
  20. package/src/runtime/layout/layout-engine-version.ts +7 -1
  21. package/src/runtime/layout/measurement-backend-canvas.ts +1 -1
  22. package/src/runtime/layout/measurement-backend-empirical.ts +1 -1
  23. package/src/runtime/layout/paginated-layout-engine.ts +41 -8
  24. package/src/runtime/layout/resolved-formatting-document.ts +11 -9
  25. package/src/runtime/layout/resolved-formatting-state.ts +4 -0
  26. package/src/runtime/numbering-prefix.ts +26 -2
  27. package/src/runtime/surface-projection.ts +75 -14
  28. package/src/runtime/table-schema.ts +26 -0
  29. package/src/ui/WordReviewEditor.tsx +25 -0
  30. package/src/ui/editor-runtime-boundary.ts +1 -0
  31. package/src/ui/editor-shell-view.tsx +8 -0
  32. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +514 -0
  33. package/src/ui-tailwind/editor-surface/pm-schema.ts +14 -0
  34. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +55 -6
  35. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  36. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +4 -0
  37. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -1
  38. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +16 -0
  39. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +319 -0
  40. package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +248 -0
  41. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +4 -0
  42. package/src/ui-tailwind/tw-review-workspace.tsx +54 -3
@@ -23,6 +23,7 @@ import type {
23
23
  AddCommentResult,
24
24
  AddScopeParams,
25
25
  AddScopeResult,
26
+ ClearHighlightOptions,
26
27
  CommentSidebarSnapshot,
27
28
  CommentSidebarThreadSnapshot,
28
29
  CompatibilityReport,
@@ -159,6 +160,7 @@ import {
159
160
  insertScopeMarkers,
160
161
  removeScopeMarkers,
161
162
  } from "../core/commands/add-scope.ts";
163
+ import { applyFormattingOperationToDocument } from "../core/commands/formatting-commands.ts";
162
164
  import { buildCompatibilityReport } from "../validation/compatibility-engine.ts";
163
165
  import { mergeCompatibilityReports } from "../validation/compatibility-report.ts";
164
166
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
@@ -481,6 +483,22 @@ export interface DocumentRuntime {
481
483
  rejectChange(changeId: string): void;
482
484
  acceptAllChanges(): void;
483
485
  rejectAllChanges(): void;
486
+ /**
487
+ * Clears the highlight (background color) on a range in the currently
488
+ * active story. The caller's selection is NOT moved.
489
+ *
490
+ * When `options.range` is omitted, the current document selection is used.
491
+ *
492
+ * Tracked-changes / suggesting mode is honored: in suggesting mode the
493
+ * clear is recorded as an `rPrChange` property-change suggestion when the
494
+ * resolved range is a single bounded text segment, and is reported via
495
+ * `command_blocked` when the range spans multiple segments.
496
+ *
497
+ * When `options.expandToFullHighlight` is `true`, the resolved range is
498
+ * grown outward before clearing to cover the entire contiguous highlighted
499
+ * span it touches. Expansion stops at paragraph and table-cell boundaries.
500
+ */
501
+ clearHighlight(options?: ClearHighlightOptions): void;
484
502
  openStory(target: EditorStoryTarget): boolean;
485
503
  closeStory(): void;
486
504
  getActiveStory(): EditorStoryTarget;
@@ -3705,6 +3723,143 @@ export function createDocumentRuntime(
3705
3723
  origin: createOrigin("api", clock()),
3706
3724
  });
3707
3725
  },
3726
+ clearHighlight(options) {
3727
+ const resolvedRange = resolveClearHighlightRange(
3728
+ options?.range,
3729
+ state.selection,
3730
+ );
3731
+ if (!resolvedRange) {
3732
+ return;
3733
+ }
3734
+ if (viewState.documentMode === "viewing") {
3735
+ this.emitBlockedCommand("clearHighlight", [{
3736
+ code: "document_viewing_mode",
3737
+ message: "Cannot clear highlight in viewing mode.",
3738
+ }]);
3739
+ return;
3740
+ }
3741
+
3742
+ const surfaceBlocks = getActiveStorySurfaceBlocks();
3743
+ if (!surfaceBlocks) {
3744
+ return;
3745
+ }
3746
+
3747
+ const inputFrom = Math.min(resolvedRange.from, resolvedRange.to);
3748
+ const inputTo = Math.max(resolvedRange.from, resolvedRange.to);
3749
+ let targetFrom = inputFrom;
3750
+ let targetTo = inputTo;
3751
+ if (options?.expandToFullHighlight === true) {
3752
+ const expanded = expandRangeToHighlightExtent(
3753
+ surfaceBlocks,
3754
+ inputFrom,
3755
+ inputTo,
3756
+ );
3757
+ targetFrom = expanded.from;
3758
+ targetTo = expanded.to;
3759
+ }
3760
+ if (targetFrom === targetTo) {
3761
+ return;
3762
+ }
3763
+
3764
+ const activeStoryDocument =
3765
+ activeStory.kind === "main"
3766
+ ? state.document
3767
+ : {
3768
+ ...state.document,
3769
+ content: {
3770
+ type: "doc" as const,
3771
+ children: [...getStoryBlocks(state.document, activeStory)],
3772
+ },
3773
+ };
3774
+ const syntheticSnapshot: RuntimeRenderSnapshot = {
3775
+ ...cachedRenderSnapshot,
3776
+ ...(activeStory.kind === "main" ? {} : { activeStory: MAIN_STORY_TARGET }),
3777
+ selection: {
3778
+ anchor: targetFrom,
3779
+ head: targetTo,
3780
+ isCollapsed: false,
3781
+ activeRange: {
3782
+ kind: "range",
3783
+ from: targetFrom,
3784
+ to: targetTo,
3785
+ assoc: { start: -1, end: 1 },
3786
+ },
3787
+ },
3788
+ };
3789
+
3790
+ const suggesting =
3791
+ getEffectiveDocumentMode(state.selection) === "suggesting";
3792
+ if (suggesting) {
3793
+ if (activeStory.kind !== "main") {
3794
+ this.emitBlockedCommand("clearHighlight", [{
3795
+ code: "suggesting_unsupported",
3796
+ message: `"clearHighlight" is not supported in suggesting mode for this story.`,
3797
+ }]);
3798
+ return;
3799
+ }
3800
+ const segment = findSingleSelectedTextSegment(syntheticSnapshot);
3801
+ if (!segment) {
3802
+ this.emitBlockedCommand("clearHighlight", [{
3803
+ code: "suggesting_unsupported",
3804
+ message: `"clearHighlight" requires one bounded text segment in suggesting mode.`,
3805
+ }]);
3806
+ return;
3807
+ }
3808
+ const beforeXml = buildRunPropertyBeforeXml(segment);
3809
+ const mutation = applyFormattingOperationToDocument(
3810
+ activeStoryDocument,
3811
+ syntheticSnapshot,
3812
+ { type: "set-highlight-color", color: null },
3813
+ );
3814
+ if (!mutation.changed) {
3815
+ return;
3816
+ }
3817
+ const timestamp = clock();
3818
+ const nextDocument = appendPropertyChangeSuggestion(
3819
+ mutation.document,
3820
+ { from: segment.from, to: segment.to },
3821
+ {
3822
+ originalRevisionType: "rPrChange",
3823
+ xmlTag: "rPrChange",
3824
+ beforeXml,
3825
+ semanticKind: "formatting-change",
3826
+ storyTarget: activeStory,
3827
+ authorId: defaultAuthorId ?? undefined,
3828
+ },
3829
+ timestamp,
3830
+ );
3831
+ this.dispatch({
3832
+ type: "document.replace",
3833
+ document: nextDocument,
3834
+ mapping: createEmptyMapping(),
3835
+ origin: createOrigin("api", timestamp),
3836
+ });
3837
+ return;
3838
+ }
3839
+
3840
+ const result = applyFormattingOperationToDocument(
3841
+ activeStoryDocument,
3842
+ syntheticSnapshot,
3843
+ { type: "set-highlight-color", color: null },
3844
+ );
3845
+ if (!result.changed) {
3846
+ return;
3847
+ }
3848
+ const nextDocument =
3849
+ activeStory.kind === "main"
3850
+ ? result.document
3851
+ : replaceStoryBlocks(
3852
+ state.document,
3853
+ activeStory,
3854
+ result.document.content.children,
3855
+ );
3856
+ this.dispatch({
3857
+ type: "document.replace",
3858
+ document: nextDocument,
3859
+ mapping: createEmptyMapping(),
3860
+ origin: createOrigin("api", clock()),
3861
+ });
3862
+ },
3708
3863
  openStory(target) {
3709
3864
  const normalizedTarget =
3710
3865
  target.kind === "header" || target.kind === "footer"
@@ -3815,6 +3970,7 @@ export function createDocumentRuntime(
3815
3970
  collection,
3816
3971
  collectSectionPropertiesInOrder(state.document),
3817
3972
  state.document,
3973
+ state.document.subParts?.settings,
3818
3974
  );
3819
3975
  },
3820
3976
  layout: layoutFacet,
@@ -7468,3 +7624,271 @@ async function upgradeMeasurementProvider(
7468
7624
  // fall through — the empirical backend remains in place
7469
7625
  }
7470
7626
  }
7627
+
7628
+ function rangesOverlap(
7629
+ leftFrom: number,
7630
+ leftTo: number,
7631
+ rightFrom: number,
7632
+ rightTo: number,
7633
+ ): boolean {
7634
+ return leftFrom < rightTo && rightFrom < leftTo;
7635
+ }
7636
+
7637
+ function resolveClearHighlightRange(
7638
+ inputRange: EditorAnchorProjection | undefined,
7639
+ selection: import("../core/state/editor-state.ts").SelectionSnapshot,
7640
+ ): { from: number; to: number } | null {
7641
+ if (inputRange !== undefined) {
7642
+ if (inputRange.kind !== "range") return null;
7643
+ return { from: inputRange.from, to: inputRange.to };
7644
+ }
7645
+ const active = selection.activeRange;
7646
+ if (active.kind !== "range") return null;
7647
+ return { from: active.range.from, to: active.range.to };
7648
+ }
7649
+
7650
+ function expandRangeToHighlightExtent(
7651
+ blocks: readonly SurfaceBlockSnapshot[],
7652
+ inputFrom: number,
7653
+ inputTo: number,
7654
+ ): { from: number; to: number } {
7655
+ let from = inputFrom;
7656
+ let to = inputTo;
7657
+ forEachParagraphBlock(blocks, (paragraph) => {
7658
+ if (!rangesOverlap(from, to, paragraph.from, paragraph.to)) {
7659
+ return;
7660
+ }
7661
+ const textSegments = paragraph.segments.filter(
7662
+ (segment): segment is Extract<SurfaceInlineSegment, { kind: "text" }> =>
7663
+ segment.kind === "text",
7664
+ );
7665
+ const expanded = expandRangeWithinParagraph(textSegments, inputFrom, inputTo);
7666
+ if (expanded.from < from) from = expanded.from;
7667
+ if (expanded.to > to) to = expanded.to;
7668
+ });
7669
+ return { from, to };
7670
+ }
7671
+
7672
+ function expandRangeWithinParagraph(
7673
+ segments: Array<Extract<SurfaceInlineSegment, { kind: "text" }>>,
7674
+ inputFrom: number,
7675
+ inputTo: number,
7676
+ ): { from: number; to: number } {
7677
+ let from = inputFrom;
7678
+ let to = inputTo;
7679
+ let touchedLeftIndex = -1;
7680
+ let touchedRightIndex = -1;
7681
+ for (let i = 0; i < segments.length; i += 1) {
7682
+ const segment = segments[i]!;
7683
+ if (!isHighlightedSegment(segment)) continue;
7684
+ if (rangesOverlap(inputFrom, inputTo, segment.from, segment.to)) {
7685
+ if (touchedLeftIndex === -1) touchedLeftIndex = i;
7686
+ touchedRightIndex = i;
7687
+ }
7688
+ }
7689
+ if (touchedLeftIndex === -1) {
7690
+ return { from, to };
7691
+ }
7692
+ for (let i = touchedLeftIndex; i >= 0; i -= 1) {
7693
+ const segment = segments[i]!;
7694
+ if (!isHighlightedSegment(segment)) break;
7695
+ if (segment.to < from) break;
7696
+ if (segment.from < from) from = segment.from;
7697
+ }
7698
+ for (let i = touchedRightIndex; i < segments.length; i += 1) {
7699
+ const segment = segments[i]!;
7700
+ if (!isHighlightedSegment(segment)) break;
7701
+ if (segment.from > to) break;
7702
+ if (segment.to > to) to = segment.to;
7703
+ }
7704
+ return { from, to };
7705
+ }
7706
+
7707
+ function isHighlightedSegment(
7708
+ segment: Extract<SurfaceInlineSegment, { kind: "text" }>,
7709
+ ): boolean {
7710
+ const bg = segment.markAttrs?.backgroundColor;
7711
+ return typeof bg === "string" && bg.length > 0;
7712
+ }
7713
+
7714
+ function forEachParagraphBlock(
7715
+ blocks: readonly SurfaceBlockSnapshot[],
7716
+ visit: (paragraph: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>) => void,
7717
+ ): void {
7718
+ for (const block of blocks) {
7719
+ if (block.kind === "paragraph") {
7720
+ visit(block);
7721
+ continue;
7722
+ }
7723
+ if (block.kind === "table") {
7724
+ for (const row of block.rows) {
7725
+ for (const cell of row.cells) {
7726
+ forEachParagraphBlock(cell.content, visit);
7727
+ }
7728
+ }
7729
+ continue;
7730
+ }
7731
+ if (block.kind === "sdt_block") {
7732
+ forEachParagraphBlock(block.children, visit);
7733
+ }
7734
+ }
7735
+ }
7736
+
7737
+ function findSingleSelectedTextSegment(
7738
+ snapshot: Pick<RuntimeRenderSnapshot, "surface" | "selection">,
7739
+ ): Extract<SurfaceInlineSegment, { kind: "text" }> | null {
7740
+ if (
7741
+ !snapshot.surface ||
7742
+ snapshot.selection.activeRange.kind !== "range" ||
7743
+ snapshot.selection.isCollapsed
7744
+ ) {
7745
+ return null;
7746
+ }
7747
+ const selectionFrom = Math.min(snapshot.selection.anchor, snapshot.selection.head);
7748
+ const selectionTo = Math.max(snapshot.selection.anchor, snapshot.selection.head);
7749
+ const segments = collectSelectedTextSegments(
7750
+ snapshot.surface.blocks,
7751
+ selectionFrom,
7752
+ selectionTo,
7753
+ );
7754
+ if (segments.length !== 1) {
7755
+ return null;
7756
+ }
7757
+ const [segment] = segments;
7758
+ if (!segment || segment.from !== selectionFrom || segment.to !== selectionTo) {
7759
+ return null;
7760
+ }
7761
+ return segment;
7762
+ }
7763
+
7764
+ function collectSelectedTextSegments(
7765
+ blocks: readonly SurfaceBlockSnapshot[],
7766
+ selectionFrom: number,
7767
+ selectionTo: number,
7768
+ output: Array<Extract<SurfaceInlineSegment, { kind: "text" }>> = [],
7769
+ ): Array<Extract<SurfaceInlineSegment, { kind: "text" }>> {
7770
+ for (const block of blocks) {
7771
+ if (block.kind === "paragraph") {
7772
+ for (const segment of block.segments) {
7773
+ if (
7774
+ segment.kind === "text" &&
7775
+ rangesOverlap(selectionFrom, selectionTo, segment.from, segment.to)
7776
+ ) {
7777
+ output.push(segment);
7778
+ }
7779
+ }
7780
+ continue;
7781
+ }
7782
+ if (block.kind === "table") {
7783
+ for (const row of block.rows) {
7784
+ for (const cell of row.cells) {
7785
+ collectSelectedTextSegments(cell.content, selectionFrom, selectionTo, output);
7786
+ }
7787
+ }
7788
+ continue;
7789
+ }
7790
+ if (block.kind === "sdt_block") {
7791
+ collectSelectedTextSegments(block.children, selectionFrom, selectionTo, output);
7792
+ }
7793
+ }
7794
+ return output;
7795
+ }
7796
+
7797
+ function buildRunPropertyBeforeXml(
7798
+ segment: Extract<SurfaceInlineSegment, { kind: "text" }>,
7799
+ ): string {
7800
+ const parts: string[] = [];
7801
+ const marks = new Set(segment.marks ?? []);
7802
+ if (marks.has("bold")) parts.push("<w:b/>");
7803
+ if (marks.has("italic")) parts.push("<w:i/>");
7804
+ if (marks.has("underline")) parts.push("<w:u w:val=\"single\"/>");
7805
+ if (marks.has("strikethrough")) parts.push("<w:strike/>");
7806
+ if (marks.has("superscript")) parts.push("<w:vertAlign w:val=\"superscript\"/>");
7807
+ if (marks.has("subscript")) parts.push("<w:vertAlign w:val=\"subscript\"/>");
7808
+ if (segment.markAttrs?.fontFamily) {
7809
+ parts.push(
7810
+ `<w:rFonts w:ascii="${escapeAttributeXml(segment.markAttrs.fontFamily)}" w:hAnsi="${escapeAttributeXml(segment.markAttrs.fontFamily)}"/>`,
7811
+ );
7812
+ }
7813
+ if (segment.markAttrs?.fontSize !== undefined) {
7814
+ parts.push(`<w:sz w:val="${segment.markAttrs.fontSize}"/>`);
7815
+ }
7816
+ if (segment.markAttrs?.textColor) {
7817
+ parts.push(`<w:color w:val="${escapeAttributeXml(segment.markAttrs.textColor)}"/>`);
7818
+ }
7819
+ if (segment.markAttrs?.backgroundColor) {
7820
+ parts.push(
7821
+ `<w:shd w:val="clear" w:color="auto" w:fill="${escapeAttributeXml(segment.markAttrs.backgroundColor)}"/>`,
7822
+ );
7823
+ }
7824
+ return `<w:rPr>${parts.join("")}</w:rPr>`;
7825
+ }
7826
+
7827
+ function escapeAttributeXml(value: string): string {
7828
+ return value
7829
+ .replace(/&/g, "&amp;")
7830
+ .replace(/</g, "&lt;")
7831
+ .replace(/>/g, "&gt;")
7832
+ .replace(/"/g, "&quot;");
7833
+ }
7834
+
7835
+ function appendPropertyChangeSuggestion(
7836
+ document: CanonicalDocumentEnvelope,
7837
+ anchor: { from: number; to: number },
7838
+ input: {
7839
+ originalRevisionType: "rPrChange" | "pPrChange";
7840
+ xmlTag: "rPrChange" | "pPrChange";
7841
+ beforeXml: string;
7842
+ semanticKind: "formatting-change" | "paragraph-property-change";
7843
+ storyTarget: EditorStoryTarget;
7844
+ authorId?: string;
7845
+ },
7846
+ timestamp: string,
7847
+ ): CanonicalDocumentEnvelope {
7848
+ const existing = document.review.revisions;
7849
+ const changeId = createRuntimeSuggestionChangeId(existing, timestamp);
7850
+ const resolvedAuthorId = input.authorId ?? "unknown";
7851
+ return {
7852
+ ...document,
7853
+ review: {
7854
+ ...document.review,
7855
+ revisions: {
7856
+ ...existing,
7857
+ [changeId]: {
7858
+ changeId,
7859
+ kind: "property-change",
7860
+ anchor: createRangeAnchor(anchor.from, anchor.to, { start: 1, end: -1 }),
7861
+ authorId: resolvedAuthorId,
7862
+ createdAt: timestamp,
7863
+ warningIds: [],
7864
+ metadata: {
7865
+ source: "runtime",
7866
+ storyTarget: input.storyTarget,
7867
+ suggestionId: changeId,
7868
+ semanticKind: input.semanticKind,
7869
+ originalRevisionType: input.originalRevisionType,
7870
+ propertyChangeData: {
7871
+ xmlTag: input.xmlTag,
7872
+ beforeXml: input.beforeXml,
7873
+ },
7874
+ },
7875
+ status: "open",
7876
+ },
7877
+ },
7878
+ },
7879
+ };
7880
+ }
7881
+
7882
+ function createRuntimeSuggestionChangeId(
7883
+ existing: CanonicalDocumentEnvelope["review"]["revisions"],
7884
+ timestamp: string,
7885
+ ): string {
7886
+ const base = `change-${timestamp.replace(/[^0-9]/gu, "")}`;
7887
+ let counter = Object.keys(existing).length + 1;
7888
+ let candidate = `${base}-p${counter}`;
7889
+ while (existing[candidate]) {
7890
+ counter += 1;
7891
+ candidate = `${base}-p${counter}`;
7892
+ }
7893
+ return candidate;
7894
+ }
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  BlockNode,
3
3
  CanonicalDocument,
4
+ DocumentSettings,
4
5
  EndnoteProperties,
5
6
  FootnoteCollection,
6
7
  FootnoteProperties,
@@ -15,14 +16,13 @@ export interface FootnoteResolver {
15
16
  getEndnoteCount(): number;
16
17
  /**
17
18
  * Resolve the effective `<w:footnotePr>` for a given section. Returns the
18
- * section's typed properties when present, or `undefined` when the section
19
- * has no override (callers should fall back to Word defaults). File-level
20
- * defaults from `settings.xml` are not yet wired in — that's a later slice.
19
+ * section's typed properties when present, otherwise the settings-level
20
+ * default from `settings.xml` when available.
21
21
  */
22
22
  getFootnoteProperties(sectionIndex?: number): FootnoteProperties | undefined;
23
23
  /**
24
24
  * Resolve the effective `<w:endnotePr>` for a given section. Same semantics
25
- * as `getFootnoteProperties` but reads `endnotePr` from the section list.
25
+ * as `getFootnoteProperties` but reads `endnotePr`.
26
26
  */
27
27
  getEndnoteProperties(sectionIndex?: number): EndnoteProperties | undefined;
28
28
  /**
@@ -70,6 +70,7 @@ export function createFootnoteResolver(
70
70
  collection: FootnoteCollection,
71
71
  sections?: readonly SectionProperties[],
72
72
  document?: CanonicalDocument,
73
+ settings?: DocumentSettings,
73
74
  ): FootnoteResolver {
74
75
  return {
75
76
  getContinuationSeparatorContent(kind) {
@@ -87,12 +88,20 @@ export function createFootnoteResolver(
87
88
  return Object.keys(collection.endnotes).length;
88
89
  },
89
90
  getFootnoteProperties(sectionIndex) {
90
- if (sectionIndex === undefined || !sections) return undefined;
91
- return sections[sectionIndex]?.footnotePr;
91
+ return resolveFootnoteLikeProperties(
92
+ sectionIndex,
93
+ sections,
94
+ settings?.footnotePr,
95
+ "footnotePr",
96
+ );
92
97
  },
93
98
  getEndnoteProperties(sectionIndex) {
94
- if (sectionIndex === undefined || !sections) return undefined;
95
- return sections[sectionIndex]?.endnotePr;
99
+ return resolveFootnoteLikeProperties(
100
+ sectionIndex,
101
+ sections,
102
+ settings?.endnotePr,
103
+ "endnotePr",
104
+ );
96
105
  },
97
106
  getEndnotesForSection(sectionIndex) {
98
107
  if (!sections || !document) return EMPTY_READONLY_STRING_ARRAY;
@@ -107,6 +116,21 @@ export function createFootnoteResolver(
107
116
 
108
117
  const EMPTY_READONLY_STRING_ARRAY: readonly string[] = Object.freeze([]);
109
118
 
119
+ function resolveFootnoteLikeProperties<T extends FootnoteProperties | EndnoteProperties>(
120
+ sectionIndex: number | undefined,
121
+ sections: readonly SectionProperties[] | undefined,
122
+ defaultProps: T | undefined,
123
+ key: "footnotePr" | "endnotePr",
124
+ ): T | undefined {
125
+ if (sectionIndex !== undefined && sections) {
126
+ const sectionProps = sections[sectionIndex]?.[key];
127
+ if (sectionProps) {
128
+ return sectionProps as T;
129
+ }
130
+ }
131
+ return defaultProps;
132
+ }
133
+
110
134
  /**
111
135
  * Walk the document's top-level block children and collect endnote IDs
112
136
  * referenced from the block range belonging to `targetSectionIndex`.
@@ -215,8 +215,14 @@
215
215
  * CO3.8 `isStaleParaInd` heuristic with a broader `isDegenerateParaInd`
216
216
  * guard (hanging===left → use level geometry) that fixes the APS
217
217
  * Supply paragraph pattern. Cache envelopes from v22 invalidate.
218
+ * 24 — Merge from `main` after the non-SDT closeout. The layout measurement
219
+ * and resolved-formatting stack changed in `measurement-backend-canvas`,
220
+ * `measurement-backend-empirical`, `paginated-layout-engine`,
221
+ * `resolved-formatting-document`, and `resolved-formatting-state`.
222
+ * Persisted prerender/layout caches must invalidate so geometry derived
223
+ * under v23 is never reused against the merged layout pipeline.
218
224
  */
219
- export const LAYOUT_ENGINE_VERSION = 23 as const;
225
+ export const LAYOUT_ENGINE_VERSION = 24 as const;
220
226
 
221
227
  /**
222
228
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -188,7 +188,7 @@ export function createCanvasBackend(
188
188
  case "tab": {
189
189
  // Advance to the next tab stop (in twips).
190
190
  const avgCharWidth = formatting.averageCharWidthTwips;
191
- const defaultTabInterval = 720;
191
+ const defaultTabInterval = formatting.defaultTabInterval;
192
192
  const position = currentLineWidth + formatting.indentLeft;
193
193
  let nextTab = Math.ceil((position + 1) / defaultTabInterval) * defaultTabInterval;
194
194
  for (const tab of formatting.tabStops) {
@@ -187,7 +187,7 @@ function resolveTabAdvance(
187
187
  currentChars: number,
188
188
  ): number {
189
189
  const avgCharWidth = formatting.averageCharWidthTwips;
190
- const defaultTabInterval = 720;
190
+ const defaultTabInterval = formatting.defaultTabInterval;
191
191
  const currentPosition = currentChars * avgCharWidth + formatting.indentLeft;
192
192
 
193
193
  if (formatting.tabStops.length === 0) {