@beyondwork/docx-react-component 1.0.60 → 1.0.61

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 (40) 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/parse-footnotes.ts +11 -0
  11. package/src/io/ooxml/parse-headers-footers.ts +117 -42
  12. package/src/io/ooxml/parse-main-document.ts +20 -8
  13. package/src/io/ooxml/parse-paragraph-formatting.ts +25 -1
  14. package/src/io/ooxml/parse-settings.ts +91 -1
  15. package/src/model/canonical-document.ts +36 -2
  16. package/src/runtime/document-runtime.ts +424 -0
  17. package/src/runtime/footnote-resolver.ts +32 -8
  18. package/src/runtime/layout/layout-engine-version.ts +7 -1
  19. package/src/runtime/layout/measurement-backend-canvas.ts +1 -1
  20. package/src/runtime/layout/measurement-backend-empirical.ts +1 -1
  21. package/src/runtime/layout/paginated-layout-engine.ts +41 -8
  22. package/src/runtime/layout/resolved-formatting-document.ts +11 -9
  23. package/src/runtime/layout/resolved-formatting-state.ts +4 -0
  24. package/src/runtime/numbering-prefix.ts +26 -2
  25. package/src/runtime/surface-projection.ts +75 -14
  26. package/src/runtime/table-schema.ts +26 -0
  27. package/src/ui/WordReviewEditor.tsx +25 -0
  28. package/src/ui/editor-runtime-boundary.ts +1 -0
  29. package/src/ui/editor-shell-view.tsx +8 -0
  30. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +514 -0
  31. package/src/ui-tailwind/editor-surface/pm-schema.ts +14 -0
  32. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +55 -6
  33. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  34. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +4 -0
  35. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -1
  36. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +16 -0
  37. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +319 -0
  38. package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +248 -0
  39. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +4 -0
  40. package/src/ui-tailwind/tw-review-workspace.tsx +54 -3
@@ -132,11 +132,35 @@ function readShading(node: XmlElementNode): ParagraphShading | undefined {
132
132
  const val = node.attributes["w:val"] ?? node.attributes.val;
133
133
  const fill = node.attributes["w:fill"] ?? node.attributes.fill;
134
134
  const color = node.attributes["w:color"] ?? node.attributes.color;
135
- if (!val && !fill && !color) return undefined;
135
+ const themeFill = node.attributes["w:themeFill"] ?? node.attributes.themeFill;
136
+ const themeFillTint = node.attributes["w:themeFillTint"] ?? node.attributes.themeFillTint;
137
+ const themeFillShade = node.attributes["w:themeFillShade"] ?? node.attributes.themeFillShade;
138
+ const themeColor = node.attributes["w:themeColor"] ?? node.attributes.themeColor;
139
+ const themeColorTint = node.attributes["w:themeColorTint"] ?? node.attributes.themeColorTint;
140
+ const themeColorShade = node.attributes["w:themeColorShade"] ?? node.attributes.themeColorShade;
141
+ if (
142
+ !val &&
143
+ !fill &&
144
+ !color &&
145
+ !themeFill &&
146
+ !themeFillTint &&
147
+ !themeFillShade &&
148
+ !themeColor &&
149
+ !themeColorTint &&
150
+ !themeColorShade
151
+ ) {
152
+ return undefined;
153
+ }
136
154
  return {
137
155
  ...(val ? { val } : {}),
138
156
  ...(fill ? { fill } : {}),
139
157
  ...(color ? { color } : {}),
158
+ ...(themeFill ? { themeFill } : {}),
159
+ ...(themeFillTint ? { themeFillTint } : {}),
160
+ ...(themeFillShade ? { themeFillShade } : {}),
161
+ ...(themeColor ? { themeColor } : {}),
162
+ ...(themeColorTint ? { themeColorTint } : {}),
163
+ ...(themeColorShade ? { themeColorShade } : {}),
140
164
  };
141
165
  }
142
166
 
@@ -3,6 +3,7 @@ import type {
3
3
  ClrSchemeMapping,
4
4
  ClrSchemeMappingSlot,
5
5
  DocumentSettings,
6
+ FootnoteProperties,
6
7
  ThemeColorSlot,
7
8
  } from "../../model/canonical-document.ts";
8
9
  import { parseXml } from "./xml-parser.ts";
@@ -51,6 +52,13 @@ export function parseSettingsXml(xml: string): DocumentSettings {
51
52
  const compatPartition = compat ? partitionCompat(compat) : undefined;
52
53
  const rootCompatFlags = readRootCompatFlags(settingsElement);
53
54
  const themeFontLangElement = findChildElementOptional(settingsElement, "themeFontLang");
55
+ const defaultTabStop = readDefaultTabStop(settingsElement);
56
+ const footnotePr = readFootnoteLikeProperties(
57
+ findChildElementOptional(settingsElement, "footnotePr"),
58
+ );
59
+ const endnotePr = readFootnoteLikeProperties(
60
+ findChildElementOptional(settingsElement, "endnotePr"),
61
+ );
54
62
  const clrSchemeMapping = parseClrSchemeMapping(settingsElement);
55
63
  const unmodelled = readUnmodelledSettingsChildren(settingsElement);
56
64
 
@@ -71,6 +79,9 @@ export function parseSettingsXml(xml: string): DocumentSettings {
71
79
  ...(themeFontLangElement
72
80
  ? { themeFontLang: { ...themeFontLangElement.attributes } }
73
81
  : {}),
82
+ ...(defaultTabStop !== undefined ? { defaultTabStop } : {}),
83
+ ...(footnotePr ? { footnotePr } : {}),
84
+ ...(endnotePr ? { endnotePr } : {}),
74
85
  ...(clrSchemeMapping !== undefined ? { clrSchemeMapping } : {}),
75
86
  ...(unmodelled.length > 0 ? { unmodelledSettingsChildren: unmodelled } : {}),
76
87
  };
@@ -86,6 +97,9 @@ const MODELLED_SETTINGS_CHILD_NAMES = new Set<string>([
86
97
  "zoom",
87
98
  "compat",
88
99
  "themeFontLang",
100
+ "defaultTabStop",
101
+ "footnotePr",
102
+ "endnotePr",
89
103
  "clrSchemeMapping",
90
104
  ]);
91
105
 
@@ -130,6 +144,83 @@ function readRootCompatFlags(
130
144
  return flags;
131
145
  }
132
146
 
147
+ function readDefaultTabStop(
148
+ settingsElement: XmlElementNode,
149
+ ): number | undefined {
150
+ const element = findChildElementOptional(settingsElement, "defaultTabStop");
151
+ if (!element) return undefined;
152
+ const rawValue = element.attributes["w:val"] ?? element.attributes.val;
153
+ if (!rawValue) return undefined;
154
+ const parsed = Number.parseInt(rawValue, 10);
155
+ return Number.isFinite(parsed) ? parsed : undefined;
156
+ }
157
+
158
+ function readFootnoteLikeProperties(
159
+ element: XmlElementNode | undefined,
160
+ ): FootnoteProperties | undefined {
161
+ if (!element) return undefined;
162
+
163
+ const result: FootnoteProperties = {};
164
+ for (const child of element.children) {
165
+ if (child.type !== "element") continue;
166
+ const name = localName(child.name);
167
+ const val = child.attributes["w:val"] ?? child.attributes.val;
168
+
169
+ if (name === "pos") {
170
+ if (
171
+ val === "pageBottom" ||
172
+ val === "beneathText" ||
173
+ val === "sectEnd" ||
174
+ val === "docEnd"
175
+ ) {
176
+ result.pos = val;
177
+ }
178
+ continue;
179
+ }
180
+
181
+ if (name === "numFmt") {
182
+ if (
183
+ val === "decimal" ||
184
+ val === "upperRoman" ||
185
+ val === "lowerRoman" ||
186
+ val === "upperLetter" ||
187
+ val === "lowerLetter" ||
188
+ val === "ordinal" ||
189
+ val === "cardinalText" ||
190
+ val === "ordinalText" ||
191
+ val === "hex" ||
192
+ val === "chicago" ||
193
+ val === "bullet" ||
194
+ val === "ideographDigital" ||
195
+ val === "japaneseCounting" ||
196
+ val === "arabicAbjad" ||
197
+ val === "arabicAlpha" ||
198
+ val === "none"
199
+ ) {
200
+ result.numFmt = val;
201
+ }
202
+ continue;
203
+ }
204
+
205
+ if (name === "numStart") {
206
+ const parsed = Number.parseInt(val ?? "", 10);
207
+ if (Number.isFinite(parsed)) {
208
+ result.numStart = Math.max(1, parsed);
209
+ }
210
+ continue;
211
+ }
212
+
213
+ if (
214
+ name === "numRestart" &&
215
+ (val === "continuous" || val === "eachSect" || val === "eachPage")
216
+ ) {
217
+ result.numRestart = val;
218
+ }
219
+ }
220
+
221
+ return Object.keys(result).length > 0 ? result : undefined;
222
+ }
223
+
133
224
  interface CompatPartition {
134
225
  compatSettings: CompatSetting[];
135
226
  compatFlags: Record<string, boolean>;
@@ -218,4 +309,3 @@ function readZoomLevel(
218
309
 
219
310
  return { zoomLevel: parsed };
220
311
  }
221
-
@@ -346,14 +346,20 @@ export interface FootnoteDefinition {
346
346
  * footnote/endnote entries with `w:type="separator"` and
347
347
  * `w:type="continuationSeparator"`.
348
348
  *
349
- * Content is stored as raw inner XML (the run children of the <w:p>) so
350
- * Lane 3a page-chrome can render the horizontal rule without re-parsing.
349
+ * The canonical model keeps both the legacy run-only payload and the full
350
+ * first-paragraph XML. The paragraph form closes round-trip fidelity for
351
+ * imported separator paragraph properties, while the run form remains for
352
+ * back-compat with the current note-separator runtime helpers.
351
353
  */
352
354
  export interface FootnoteSeparators {
353
355
  /** Raw XML of the <w:r> children inside the separator paragraph. */
354
356
  separatorContent?: string;
357
+ /** Full XML of the first separator paragraph. */
358
+ separatorParagraphXml?: string;
355
359
  /** Raw XML of the <w:r> children inside the continuationSeparator paragraph. */
356
360
  continuationSeparatorContent?: string;
361
+ /** Full XML of the first continuation-separator paragraph. */
362
+ continuationSeparatorParagraphXml?: string;
357
363
  }
358
364
 
359
365
  export interface FootnoteCollection {
@@ -399,6 +405,22 @@ export interface CompatSetting {
399
405
  export interface DocumentSettings {
400
406
  evenAndOddHeaders?: boolean;
401
407
  zoomLevel?: "pageWidth" | "onePage" | number;
408
+ /**
409
+ * Document-wide default tab stop interval from `<w:defaultTabStop w:val>`.
410
+ * Value is stored in twips and feeds layout measurement whenever a paragraph
411
+ * does not declare an explicit next tab stop.
412
+ */
413
+ defaultTabStop?: number;
414
+ /**
415
+ * Settings-level default footnote configuration from `<w:footnotePr>`.
416
+ * Section-level `SectionProperties.footnotePr` overrides this when present.
417
+ */
418
+ footnotePr?: FootnoteProperties;
419
+ /**
420
+ * Settings-level default endnote configuration from `<w:endnotePr>`.
421
+ * Section-level `SectionProperties.endnotePr` overrides this when present.
422
+ */
423
+ endnotePr?: EndnoteProperties;
402
424
  /**
403
425
  * Ordered list of <w:compatSetting> entries inside <w:compat>. Insertion
404
426
  * order is preserved for serializer diff stability.
@@ -606,6 +628,18 @@ export interface ParagraphShading {
606
628
  fill?: string;
607
629
  color?: string;
608
630
  val?: string;
631
+ /**
632
+ * Theme shading references (§17.3.5). When `themeFill` is set and `fill`
633
+ * is absent or `"auto"`, render-time shading resolves through the theme
634
+ * color resolver. The raw theme attrs remain on the canonical model so
635
+ * export can round-trip the original `<w:shd>` byte-for-byte.
636
+ */
637
+ themeFill?: string;
638
+ themeFillTint?: string;
639
+ themeFillShade?: string;
640
+ themeColor?: string;
641
+ themeColorTint?: string;
642
+ themeColorShade?: string;
609
643
  }
610
644
 
611
645
  /** Body of an OOXML `<w:rPr>` (run properties). All fields optional; absence = "not specified at this level". */
@@ -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
+ }