@beyondwork/docx-react-component 1.0.56 → 1.0.57

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 (107) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +157 -0
  3. package/src/compare/diff-engine.ts +3 -0
  4. package/src/core/commands/formatting-commands.ts +1 -0
  5. package/src/core/commands/index.ts +17 -11
  6. package/src/core/selection/mapping.ts +18 -1
  7. package/src/core/selection/review-anchors.ts +29 -18
  8. package/src/io/chart-preview-resolver.ts +175 -41
  9. package/src/io/docx-session.ts +57 -2
  10. package/src/io/export/serialize-main-document.ts +82 -0
  11. package/src/io/export/serialize-styles.ts +61 -3
  12. package/src/io/export/table-properties-xml.ts +19 -4
  13. package/src/io/normalize/normalize-text.ts +33 -0
  14. package/src/io/ooxml/parse-anchor.ts +182 -0
  15. package/src/io/ooxml/parse-drawing.ts +319 -0
  16. package/src/io/ooxml/parse-fields.ts +115 -2
  17. package/src/io/ooxml/parse-fill.ts +215 -0
  18. package/src/io/ooxml/parse-font-table.ts +190 -0
  19. package/src/io/ooxml/parse-footnotes.ts +52 -1
  20. package/src/io/ooxml/parse-main-document.ts +241 -1
  21. package/src/io/ooxml/parse-numbering.ts +96 -0
  22. package/src/io/ooxml/parse-picture.ts +107 -0
  23. package/src/io/ooxml/parse-settings.ts +34 -0
  24. package/src/io/ooxml/parse-shapes.ts +87 -0
  25. package/src/io/ooxml/parse-solid-fill.ts +11 -0
  26. package/src/io/ooxml/parse-styles.ts +74 -1
  27. package/src/io/ooxml/parse-theme.ts +60 -0
  28. package/src/io/paste/html-clipboard.ts +449 -0
  29. package/src/io/paste/word-clipboard.ts +5 -1
  30. package/src/legal/_document-root.ts +26 -0
  31. package/src/legal/bookmarks.ts +4 -3
  32. package/src/legal/cross-references.ts +3 -2
  33. package/src/legal/defined-terms.ts +2 -1
  34. package/src/legal/signature-blocks.ts +2 -1
  35. package/src/model/canonical-document.ts +415 -3
  36. package/src/runtime/chart/chart-model-store.ts +73 -10
  37. package/src/runtime/document-runtime.ts +693 -41
  38. package/src/runtime/edit-ops/index.ts +129 -0
  39. package/src/runtime/event-refresh-hints.ts +7 -0
  40. package/src/runtime/field-resolver.ts +341 -0
  41. package/src/runtime/footnote-resolver.ts +55 -0
  42. package/src/runtime/hyperlink-color-resolver.ts +13 -10
  43. package/src/runtime/object-grab/index.ts +51 -0
  44. package/src/runtime/paragraph-style-resolver.ts +105 -0
  45. package/src/runtime/resolved-numbering-geometry.ts +12 -0
  46. package/src/runtime/selection/cursor-ops.ts +186 -15
  47. package/src/runtime/selection/index.ts +17 -1
  48. package/src/runtime/structure-ops/index.ts +77 -0
  49. package/src/runtime/styles-cascade.ts +33 -0
  50. package/src/runtime/surface-projection.ts +186 -12
  51. package/src/runtime/theme-color-resolver.ts +189 -44
  52. package/src/runtime/units.ts +46 -0
  53. package/src/runtime/view-state.ts +13 -2
  54. package/src/ui/WordReviewEditor.tsx +168 -10
  55. package/src/ui/editor-runtime-boundary.ts +94 -1
  56. package/src/ui/editor-shell-view.tsx +1 -1
  57. package/src/ui/runtime-shortcut-dispatch.ts +17 -3
  58. package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
  59. package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
  60. package/src/ui-tailwind/chart/render/area.tsx +22 -4
  61. package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
  62. package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
  63. package/src/ui-tailwind/chart/render/combo.tsx +37 -4
  64. package/src/ui-tailwind/chart/render/line.tsx +28 -5
  65. package/src/ui-tailwind/chart/render/pie.tsx +36 -16
  66. package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
  67. package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
  68. package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
  69. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
  70. package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
  71. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
  72. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
  73. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
  74. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
  75. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
  76. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
  77. package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
  78. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
  79. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
  80. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
  81. package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
  82. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
  83. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
  85. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
  86. package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
  87. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
  88. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
  90. package/src/ui-tailwind/editor-surface/pm-schema.ts +188 -11
  91. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +28 -2
  92. package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
  93. package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
  94. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
  95. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
  96. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
  97. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
  98. package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
  99. package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
  100. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
  101. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
  102. package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
  103. package/src/ui-tailwind/theme/editor-theme.css +1 -0
  104. package/src/ui-tailwind/theme/tokens.css +6 -0
  105. package/src/ui-tailwind/theme/tokens.ts +10 -0
  106. package/src/validation/compatibility-engine.ts +2 -0
  107. package/src/validation/docx-comment-proof.ts +12 -3
@@ -3,7 +3,9 @@ import type {
3
3
  EditorSurfaceSnapshot,
4
4
  SecondaryStorySurface,
5
5
  SurfaceBlockSnapshot,
6
+ SurfaceDrawingAnchor,
6
7
  SurfaceInlineSegment,
8
+ SurfacePictureEffects,
7
9
  SurfaceTableCellSnapshot,
8
10
  SurfaceTableRowSnapshot,
9
11
  SurfaceTextMark,
@@ -30,6 +32,9 @@ import type {
30
32
  TableCellBorders,
31
33
  TableNode,
32
34
  TextMark,
35
+ DrawingFrameNode,
36
+ PictureContent,
37
+ ShapeContent,
33
38
  VmlShapeNode,
34
39
  WordArtNode,
35
40
  } from "../model/canonical-document.ts";
@@ -57,7 +62,7 @@ import {
57
62
  resolveNumberingMarkerRunFormatting,
58
63
  } from "./paragraph-style-resolver.ts";
59
64
  import { resolveHyperlinkRunFormatting } from "./hyperlink-color-resolver.ts";
60
- import { concretizeThemeColors } from "./theme-color-resolver.ts";
65
+ import { concretizeThemeColors, ThemeColorResolver } from "./theme-color-resolver.ts";
61
66
  import type { CanonicalParagraphFormatting, CanonicalRunFormatting } from "../model/canonical-document.ts";
62
67
 
63
68
  interface ParagraphAccumulator {
@@ -93,6 +98,13 @@ export function createEditorSurfaceSnapshot(
93
98
  const blocks: SurfaceBlockSnapshot[] = [];
94
99
  const lockedFragmentIds: string[] = [];
95
100
  const numberingPrefixResolver = createNumberingPrefixResolver(document.numbering);
101
+ // Open a chartModelStore build pass tagged with the document envelope.
102
+ // Every `chart_preview` node populated during the pass records its id
103
+ // in the pass's seen-set; `endBuildPass()` below evicts store entries
104
+ // from previous documents (different owner) and stale entries from
105
+ // earlier builds (same owner, not seen this pass). Previously the
106
+ // store grew unbounded across document loads — documented v1 gap.
107
+ chartModelStore.beginBuildPass(document);
96
108
  let cursor = 0;
97
109
  const counters = {
98
110
  paragraph: 0,
@@ -157,6 +169,12 @@ export function createEditorSurfaceSnapshot(
157
169
 
158
170
  const secondaryStories = createSecondaryStorySurfaces(document, numberingPrefixResolver);
159
171
 
172
+ // Close the chartModelStore build pass. Evicts entries from previous
173
+ // documents + stale entries whose ids no longer appear in the current
174
+ // document. Must happen AFTER secondary stories (they may also contain
175
+ // chart_preview nodes).
176
+ chartModelStore.endBuildPass();
177
+
160
178
  return {
161
179
  storySize: cursor,
162
180
  plainText: createPlainText(blocks),
@@ -712,6 +730,12 @@ function createParagraphBlock(
712
730
  const lockedFragmentIds: string[] = [];
713
731
  let cursor = start;
714
732
  const children = Array.isArray(paragraph.children) ? paragraph.children : [];
733
+ // Build once per paragraph block — ThemeColorResolver is a thin wrapper
734
+ // around CanonicalTheme that applies clrMap remapping. Constructed here so
735
+ // it is not recreated on every text segment (inner hot loop).
736
+ const themeResolver = document.subParts?.canonicalTheme
737
+ ? new ThemeColorResolver(document.subParts.canonicalTheme)
738
+ : undefined;
715
739
 
716
740
  for (const child of children) {
717
741
  const result = appendInlineSegments(
@@ -722,6 +746,7 @@ function createParagraphBlock(
722
746
  promoteSecondaryStoryTextBoxes,
723
747
  undefined,
724
748
  cullBuild,
749
+ themeResolver,
725
750
  );
726
751
  cursor = result.nextCursor;
727
752
  lockedFragmentIds.push(...result.lockedFragmentIds);
@@ -885,6 +910,7 @@ function appendInlineSegments(
885
910
  promoteSecondaryStoryTextBoxes: boolean,
886
911
  hyperlinkHref?: string,
887
912
  cullBuild: boolean = false,
913
+ themeResolver?: ThemeColorResolver,
888
914
  ): { nextCursor: number; lockedFragmentIds: string[] } {
889
915
  switch (node.type) {
890
916
  case "text": {
@@ -919,11 +945,11 @@ function appendInlineSegments(
919
945
  ? resolveHyperlinkRunFormatting(
920
946
  runResolveInput,
921
947
  document.styles,
922
- document.subParts?.resolvedTheme,
948
+ themeResolver,
923
949
  )
924
950
  : concretizeThemeColors(
925
951
  resolveEffectiveRunFormatting(runResolveInput, document.styles),
926
- document.subParts?.resolvedTheme,
952
+ themeResolver,
927
953
  );
928
954
  paragraph.segments.push({
929
955
  segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
@@ -967,6 +993,7 @@ function appendInlineSegments(
967
993
  promoteSecondaryStoryTextBoxes,
968
994
  node.href,
969
995
  cullBuild,
996
+ themeResolver,
970
997
  );
971
998
  cursor = result.nextCursor;
972
999
  }
@@ -1020,15 +1047,18 @@ function appendInlineSegments(
1020
1047
  let parsedChartId: string | undefined;
1021
1048
  if (node.parsedData) {
1022
1049
  parsedChartId = stableChartId(node.rawXml);
1023
- if (!chartModelStore.has(parsedChartId)) {
1024
- const { widthPx, heightPx } = extractChartDimensions(node.rawXml);
1025
- chartModelStore.set(parsedChartId, {
1026
- model: node.parsedData,
1027
- widthPx,
1028
- heightPx,
1029
- theme: undefined,
1030
- });
1031
- }
1050
+ // Always call `set` (even when the entry exists) so the active
1051
+ // `chartModelStore` build pass records the id in its seen-set —
1052
+ // the pass uses the seen-set to evict stale entries from earlier
1053
+ // builds. The entry object is a tiny reference so the set cost
1054
+ // is negligible.
1055
+ const { widthPx, heightPx } = extractChartDimensions(node.rawXml);
1056
+ chartModelStore.set(parsedChartId, {
1057
+ model: node.parsedData,
1058
+ widthPx,
1059
+ heightPx,
1060
+ theme: undefined,
1061
+ });
1032
1062
  }
1033
1063
  return appendComplexPreviewSegment(paragraph, node, start, "Embedded chart", createChartDetail(node), {
1034
1064
  previewMediaId: node.previewMediaId,
@@ -1064,6 +1094,58 @@ function appendInlineSegments(
1064
1094
  );
1065
1095
  }
1066
1096
  return appendComplexPreviewSegment(paragraph, node, start, "Legacy VML drawing", createVmlDetail(node));
1097
+ case "drawing_frame": {
1098
+ const c = node.content;
1099
+ if (c.type === "picture") {
1100
+ const mediaId = c.mediaId ?? `drawing-frame-${start}`;
1101
+ const state: "editable" | "missing" = c.mediaId ? "editable" : "missing";
1102
+ const anchor = surfaceAnchorFromGeometry(node.anchor);
1103
+ const pictureEffects = surfacePictureEffectsFromContent(c);
1104
+ paragraph.segments.push({
1105
+ segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
1106
+ kind: "image",
1107
+ from: start,
1108
+ to: start + 1,
1109
+ mediaId,
1110
+ state,
1111
+ display: node.anchor.display,
1112
+ detail: `Drawing frame image (${node.anchor.extent.widthEmu}\u00d7${node.anchor.extent.heightEmu} EMU).`,
1113
+ ...(anchor ? { anchor } : {}),
1114
+ ...(pictureEffects ? { pictureEffects } : {}),
1115
+ });
1116
+ return { nextCursor: start + 1, lockedFragmentIds: [] };
1117
+ }
1118
+ if (c.type === "shape") {
1119
+ const label = c.isTextBox ? "Text box" : "Drawing shape";
1120
+ const detail = `DrawingFrame shape (${node.anchor.wrapMode}).`;
1121
+ const anchor = surfaceAnchorFromGeometry(node.anchor);
1122
+ const txbxText = c.isTextBox ? extractTxbxFirstText(c.txbxBlocks) : undefined;
1123
+ const surfaceFill = c.fill;
1124
+ paragraph.segments.push({
1125
+ segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
1126
+ kind: "shape",
1127
+ from: start,
1128
+ to: start + 1,
1129
+ label,
1130
+ detail,
1131
+ ...(anchor ? { anchor } : {}),
1132
+ ...(c.geometry !== undefined ? { geometry: c.geometry } : {}),
1133
+ ...(surfaceFill ? { fill: surfaceFill } : {}),
1134
+ ...(c.line ? { line: c.line } : {}),
1135
+ ...(c.isTextBox ? { isTextBox: true } : {}),
1136
+ ...(txbxText ? { txbxText } : {}),
1137
+ });
1138
+ return { nextCursor: start + 1, lockedFragmentIds: [] };
1139
+ }
1140
+ const rawXml = "rawXml" in c ? c.rawXml : "";
1141
+ const label =
1142
+ c.type === "chart_preview"
1143
+ ? "Embedded chart"
1144
+ : c.type === "smartart_preview"
1145
+ ? "SmartArt diagram"
1146
+ : "Drawing frame";
1147
+ return appendComplexPreviewSegment(paragraph, { rawXml } as DrawingFrameNode["content"] & { rawXml: string }, start, label, `DrawingFrame ${c.type} (${node.anchor.wrapMode}).`);
1148
+ }
1067
1149
  case "symbol":
1068
1150
  paragraph.segments.push({
1069
1151
  segmentId: `${paragraph.blockId}-segment-${paragraph.segments.length}`,
@@ -1126,6 +1208,7 @@ function appendInlineSegments(
1126
1208
  promoteSecondaryStoryTextBoxes,
1127
1209
  refHyperlinkHref ?? hyperlinkHref,
1128
1210
  cullBuild,
1211
+ themeResolver,
1129
1212
  );
1130
1213
  cursor = result.nextCursor;
1131
1214
  lockedIds.push(...result.lockedFragmentIds);
@@ -1181,6 +1264,95 @@ function appendInlineSegments(
1181
1264
  }
1182
1265
  }
1183
1266
 
1267
+ /**
1268
+ * V2c.4 — Map a canonical `AnchorGeometry` onto the surface anchor type.
1269
+ * Returns `undefined` when the anchor carries only the trivial inline
1270
+ * defaults (no positioning / wrap / dist data) so the emitted segment
1271
+ * stays minimal for the common inline-image case.
1272
+ */
1273
+ function surfaceAnchorFromGeometry(
1274
+ anchor: DrawingFrameNode["anchor"],
1275
+ ): SurfaceDrawingAnchor | undefined {
1276
+ // Cheap inline-default short-circuit: inline pictures with no extra
1277
+ // metadata (no positionH/V, no distMargins, no docPr, no behindDoc/etc.)
1278
+ // don't need the anchor bag — N9 float-wrap consumers only care about
1279
+ // floating drawings or anchors with custom positioning.
1280
+ const trivialInline =
1281
+ anchor.display === "inline" &&
1282
+ anchor.wrapMode === "none" &&
1283
+ !anchor.positionH &&
1284
+ !anchor.positionV &&
1285
+ !anchor.distMargins &&
1286
+ anchor.relativeHeight === undefined &&
1287
+ anchor.behindDoc === undefined &&
1288
+ anchor.layoutInCell === undefined &&
1289
+ anchor.allowOverlap === undefined &&
1290
+ anchor.simplePos === undefined &&
1291
+ !anchor.docPr;
1292
+ if (trivialInline) return undefined;
1293
+ return {
1294
+ display: anchor.display,
1295
+ wrapMode: anchor.wrapMode,
1296
+ extent: { ...anchor.extent },
1297
+ ...(anchor.positionH ? { positionH: { ...anchor.positionH } } : {}),
1298
+ ...(anchor.positionV ? { positionV: { ...anchor.positionV } } : {}),
1299
+ ...(anchor.distMargins ? { distMargins: { ...anchor.distMargins } } : {}),
1300
+ ...(anchor.relativeHeight !== undefined ? { relativeHeight: anchor.relativeHeight } : {}),
1301
+ ...(anchor.behindDoc !== undefined ? { behindDoc: anchor.behindDoc } : {}),
1302
+ ...(anchor.layoutInCell !== undefined ? { layoutInCell: anchor.layoutInCell } : {}),
1303
+ ...(anchor.allowOverlap !== undefined ? { allowOverlap: anchor.allowOverlap } : {}),
1304
+ ...(anchor.simplePos !== undefined ? { simplePos: anchor.simplePos } : {}),
1305
+ ...(anchor.docPr ? { docPr: { ...anchor.docPr } } : {}),
1306
+ };
1307
+ }
1308
+
1309
+ /**
1310
+ * V2c.4 — Project picture-effect fields from `PictureContent`. Returns
1311
+ * `undefined` when none of the effect fields are set so consumers can
1312
+ * fast-path image rendering.
1313
+ */
1314
+ function surfacePictureEffectsFromContent(
1315
+ content: PictureContent,
1316
+ ): SurfacePictureEffects | undefined {
1317
+ const has =
1318
+ content.srcRect !== undefined ||
1319
+ content.rotation !== undefined ||
1320
+ content.flipH !== undefined ||
1321
+ content.flipV !== undefined ||
1322
+ content.presetGeom !== undefined ||
1323
+ content.stretch !== undefined;
1324
+ if (!has) return undefined;
1325
+ return {
1326
+ ...(content.srcRect ? { srcRect: { ...content.srcRect } } : {}),
1327
+ ...(content.rotation !== undefined ? { rotation: content.rotation } : {}),
1328
+ ...(content.flipH !== undefined ? { flipH: content.flipH } : {}),
1329
+ ...(content.flipV !== undefined ? { flipV: content.flipV } : {}),
1330
+ ...(content.presetGeom !== undefined ? { presetGeom: content.presetGeom } : {}),
1331
+ ...(content.stretch !== undefined ? { stretch: content.stretch } : {}),
1332
+ };
1333
+ }
1334
+
1335
+ /**
1336
+ * V2c.5 — Extract the first paragraph's plain text from a parsed
1337
+ * `txbxBlocks` tree for the `txbxText` segment preview. The recursion
1338
+ * is shallow — we only walk paragraph-shaped blocks at the top level
1339
+ * and read the immediate run content. Returns `undefined` when no text
1340
+ * is present.
1341
+ */
1342
+ function extractTxbxFirstText(
1343
+ blocks: ShapeContent["txbxBlocks"],
1344
+ ): string | undefined {
1345
+ if (!blocks || blocks.length === 0) return undefined;
1346
+ for (const block of blocks) {
1347
+ if (block.type !== "paragraph") continue;
1348
+ const runs = (block as { runs?: ReadonlyArray<{ text?: string }> }).runs;
1349
+ if (!runs) continue;
1350
+ const text = runs.map((r) => r.text ?? "").join("").trim();
1351
+ if (text) return text;
1352
+ }
1353
+ return undefined;
1354
+ }
1355
+
1184
1356
  function appendComplexPreviewSegment(
1185
1357
  paragraph: ParagraphAccumulator,
1186
1358
  node: { rawXml: string },
@@ -1616,6 +1788,8 @@ function summarizePreviewInline(node: InlineNode): string {
1616
1788
  return node.text ? `[WordArt: ${node.text}]` : "[WordArt]";
1617
1789
  case "vml_shape":
1618
1790
  return node.text ? `[VML: ${node.text}]` : "[Legacy VML drawing]";
1791
+ case "drawing_frame":
1792
+ return node.content.type === "picture" ? "[Image]" : "[Drawing]";
1619
1793
  }
1620
1794
  }
1621
1795
 
@@ -1,44 +1,112 @@
1
1
  /**
2
- * Resolve a (`<w:color>`-shaped) theme-color reference to a concrete hex
3
- * string, applying the `w:themeTint` / `w:themeShade` HSL-luminance
4
- * modulation per ECMA-376 §17.18.85 / §17.18.83.
2
+ * Unified runtime color resolver for every OOXML theme reference.
5
3
  *
6
- * Usage:
7
- * - The IO layer (`parse-run-formatting.ts`) captures
8
- * `colorThemeSlot` / `colorThemeTint` / `colorThemeShade` on
9
- * `CanonicalRunFormatting` as raw strings (preserved for
10
- * byte-stable round-trip).
11
- * - The render/cascade layer calls `resolveThemeColorHex(rPr, theme)`
12
- * to collapse the reference to the actual hex colour the browser
13
- * should paint. The original fields stay intact so export still
14
- * round-trips the theme reference (not the computed hex).
4
+ * Covers:
5
+ * - `<w:color>` §17.18.85/§17.18.83 byte-form `w:themeTint`/`w:themeShade`
6
+ * (HSL luminance modulation).
7
+ * - `<a:schemeClr>` §20.1.2.3.x DrawingML modifier chain
8
+ * `lumMod → lumOff → satMod → hueMod → tint → shade` (per-100,000 units).
9
+ * - `<w:clrSchemeMapping>` §17.15.1.17 style-slot scheme-slot remap
10
+ * (including the implicit identity default when settings.xml omits it).
11
+ * - `<w:color w:val="auto"/>` the `t1` style slot via the clrMap.
15
12
  *
16
- * Lane 3 L2.c. Mirrors LibreOffice's `Color::ApplyTintOrShade` at
17
- * `vendor/libreoffice/include/tools/color.hxx:200-240` (shape only; no
18
- * code copied our implementation is pure TypeScript against the
19
- * ECMA-376 formulae).
13
+ * Cascade layer entry points:
14
+ * - `ThemeColorResolver` class build once per `CanonicalTheme` snapshot,
15
+ * key the cache on `(themeHash, clrMapHash)` per CLAUDE.md §3.
16
+ * - `concretizeThemeColors(cascade, resolver)` — run-formatting post-pass.
20
17
  *
21
- * **Scope boundary:** this resolver handles the standard tint/shade
22
- * pair. Theme-color-mapping (clrSchemeMapping how "accent1" maps to a
23
- * different `<w:clrScheme>` slot in older Word versions) is NOT applied
24
- * here — `resolveThemeColor` from `src/io/ooxml/parse-theme.ts` is the
25
- * authoritative slot→hex lookup and future work will layer scheme
26
- * mapping into it.
18
+ * LibreOffice parity: port of `vendor/libreoffice/oox/source/drawingml/color.cxx`
19
+ * (shape only; no code copied). Modifier order + HSL/sRGB color-space split
20
+ * match LO; modifiers are applied in declaration order do NOT sort.
21
+ *
22
+ * The original `colorThemeSlot` / `Tint` / `Shade` fields are preserved on
23
+ * returned cascades so the export path can re-emit the original theme
24
+ * reference byte-for-byte.
27
25
  */
28
26
 
29
- import type { CanonicalRunFormatting, ResolvedTheme } from "../model/canonical-document.ts";
27
+ import type { CanonicalRunFormatting, CanonicalTheme, ClrSchemeMappingSlot, ResolvedTheme } from "../model/canonical-document.ts";
30
28
  import { resolveThemeColor } from "../io/ooxml/parse-theme.ts";
31
29
 
30
+ /**
31
+ * DrawingML color modifier (ECMA-376 §20.1.2.3.x).
32
+ * Values are in per-100,000 units (e.g. 65_000 = 65%).
33
+ * Apply in declaration order — do NOT sort or reorder.
34
+ *
35
+ * Distinct from w:themeTint / w:themeShade (ECMA-376 §17) which use
36
+ * hex-byte 0x00–0xFF encoding.
37
+ */
38
+ export interface DrawingMlColorMod {
39
+ kind: "lumMod" | "lumOff" | "satMod" | "hueMod" | "shade" | "tint" | "alpha";
40
+ value: number;
41
+ }
42
+
43
+ const DML_UNIT = 100_000;
44
+
45
+ /**
46
+ * Unified runtime theme color resolver.
47
+ * Construct once per CanonicalTheme (keyed on themeHash + clrMapHash).
48
+ * All resolution methods apply clrMap remapping before any slot lookup.
49
+ */
50
+ export class ThemeColorResolver {
51
+ private readonly theme: CanonicalTheme;
52
+
53
+ constructor(theme: CanonicalTheme) {
54
+ this.theme = theme;
55
+ }
56
+
57
+ /** Look up a scheme slot with clrMap remapping applied. Returns #RRGGBB or undefined. */
58
+ resolveSchemeSlot(slot: string): string | undefined {
59
+ const mapped = this.theme.clrMap[slot as ClrSchemeMappingSlot] ?? slot;
60
+ const hex = this.theme.clrScheme.colors[mapped];
61
+ return hex ?? undefined;
62
+ }
63
+
64
+ /** Resolve w:color val="auto" → "text 1" style slot (t1) via clrMap. */
65
+ resolveAuto(): string | undefined {
66
+ // "auto" = windowText = "text 1" style slot (t1).
67
+ // Pass "t1" so clrMap remapping is honoured (e.g. <w:clrSchemeMapping w:t1="dk2"/>).
68
+ return this.resolveSchemeSlot("t1");
69
+ }
70
+
71
+ /**
72
+ * Resolve a w:themeColor + w:themeTint/w:themeShade (§17 byte form, 0x00–0xFF).
73
+ * Delegates to the existing applyThemeTintShade for the HSL transform.
74
+ */
75
+ resolveWordThemeColor(
76
+ colorThemeSlot: string,
77
+ colorThemeTint: string | undefined,
78
+ colorThemeShade: string | undefined,
79
+ ): string | undefined {
80
+ const baseHex = this.resolveSchemeSlot(colorThemeSlot);
81
+ if (!baseHex) return undefined;
82
+ return applyThemeTintShade(baseHex, colorThemeTint, colorThemeShade);
83
+ }
84
+
85
+ /**
86
+ * Resolve an a:schemeClr reference with DrawingML modifiers (§20).
87
+ * Modifiers are applied in declaration order. Alpha mods are ignored.
88
+ * Returns undefined when the slot is absent.
89
+ */
90
+ resolveWithModifiers(
91
+ slot: string,
92
+ mods: readonly DrawingMlColorMod[],
93
+ ): string | undefined {
94
+ const baseHex = this.resolveSchemeSlot(slot);
95
+ if (!baseHex) return undefined;
96
+ if (mods.length === 0) return baseHex;
97
+ return applyDmlMods(baseHex.startsWith("#") ? baseHex : `#${baseHex}`, mods);
98
+ }
99
+ }
100
+
32
101
  /**
33
102
  * Collapse `<w:color>`-style theme-slot + tint + shade references into a
34
- * single resolved hex colour. Returns:
35
- * - the raw `colorHex` as-is when it's a direct hex (the theme fields
36
- * are ignored because direct colour wins per ECMA-376 cascade).
37
- * - `"auto"` when the source declared `w:color w:val="auto"` (sentinel
38
- * kept verbatim per our long-standing A.9 round-trip rule).
39
- * - the theme slot's hex tint/shade applied — when the source
40
- * declared `w:themeColor="accent1"` etc. and the theme is available.
41
- * - `undefined` otherwise (no colour declared, or theme slot absent).
103
+ * single resolved hex colour. §17-only byte-form path; does NOT apply
104
+ * `w:clrSchemeMapping` remap.
105
+ *
106
+ * @deprecated Use `ThemeColorResolver.resolveWordThemeColor` instead —
107
+ * it honours clrSchemeMapping. This free function is retained only for
108
+ * its targeted unit tests (`test/io/theme-color-tint-shade.test.ts`) and
109
+ * will be removed once those migrate to the class.
42
110
  */
43
111
  export function resolveThemeColorHex(
44
112
  rPr: Pick<
@@ -81,23 +149,22 @@ export function resolveThemeColorHex(
81
149
  */
82
150
  export function concretizeThemeColors(
83
151
  cascade: CanonicalRunFormatting,
84
- theme: ResolvedTheme | undefined,
152
+ resolver: ThemeColorResolver | undefined,
85
153
  ): CanonicalRunFormatting {
86
154
  if (!cascade.colorThemeSlot) return cascade;
87
155
  if (cascade.colorHex && cascade.colorHex !== "auto") return cascade;
88
- // When colorHex is "auto" we intentionally bypass it during theme
89
- // resolution: Word's cascade treats `<w:color w:val="auto"
90
- // w:themeColor="accent1"/>` as "paint with the theme slot; auto is
91
- // the fallback if theme is missing." Pass a stripped input to the
92
- // resolver so `colorHex === "auto"` doesn't short-circuit.
93
- const resolved = resolveThemeColorHex(
94
- {
95
- colorThemeSlot: cascade.colorThemeSlot,
96
- colorThemeTint: cascade.colorThemeTint,
97
- colorThemeShade: cascade.colorThemeShade,
98
- },
99
- theme,
156
+ if (!resolver) return cascade;
157
+
158
+ // `colorHex === "auto"` with a themeColor slot means "paint via the theme
159
+ // slot; auto is only the fallback when theme is missing" so we always
160
+ // resolve via the slot here. clrSchemeMapping remap is applied inside
161
+ // `resolver.resolveWordThemeColor`.
162
+ const resolved = resolver.resolveWordThemeColor(
163
+ cascade.colorThemeSlot,
164
+ cascade.colorThemeTint,
165
+ cascade.colorThemeShade,
100
166
  );
167
+
101
168
  if (!resolved || resolved === "auto") return cascade;
102
169
  if (resolved === cascade.colorHex) return cascade;
103
170
  return { ...cascade, colorHex: resolved };
@@ -174,6 +241,84 @@ function formatHexColor(rgb: { r: number; g: number; b: number }): string {
174
241
  return `${to2(rgb.r)}${to2(rgb.g)}${to2(rgb.b)}`;
175
242
  }
176
243
 
244
+ // ---------------------------------------------------------------------------
245
+ // DrawingML modifier pipeline (§20 forms — per-100,000 units)
246
+ // ---------------------------------------------------------------------------
247
+
248
+ function applyDmlMods(
249
+ hex: string,
250
+ mods: readonly DrawingMlColorMod[],
251
+ ): string {
252
+ let rgb = parseHexToRgbDml(hex);
253
+ for (const mod of mods) {
254
+ const frac = mod.value / DML_UNIT;
255
+ switch (mod.kind) {
256
+ case "lumMod": {
257
+ const hsl = rgbToHsl(rgb);
258
+ rgb = hslToRgb({ ...hsl, l: clamp01(hsl.l * frac) });
259
+ break;
260
+ }
261
+ case "lumOff": {
262
+ const hsl = rgbToHsl(rgb);
263
+ rgb = hslToRgb({ ...hsl, l: clamp01(hsl.l + frac) });
264
+ break;
265
+ }
266
+ case "satMod": {
267
+ const hsl = rgbToHsl(rgb);
268
+ rgb = hslToRgb({ ...hsl, s: clamp01(hsl.s * frac) });
269
+ break;
270
+ }
271
+ case "hueMod": {
272
+ const hsl = rgbToHsl(rgb);
273
+ // Multiplicative scale per LO color.cxx, not additive rotation
274
+ rgb = hslToRgb({ ...hsl, h: ((hsl.h * frac) % 360 + 360) % 360 });
275
+ break;
276
+ }
277
+ case "shade": {
278
+ // §20.1.2.3.31: sRGB space (NOT HSL) — multiply channels toward black
279
+ rgb = {
280
+ r: clamp255(rgb.r * frac),
281
+ g: clamp255(rgb.g * frac),
282
+ b: clamp255(rgb.b * frac),
283
+ };
284
+ break;
285
+ }
286
+ case "tint": {
287
+ // §20.1.2.3.34: sRGB space — C' = C * frac + 255 * (1 - frac)
288
+ const inv = 1 - frac;
289
+ rgb = {
290
+ r: clamp255(rgb.r * frac + 255 * inv),
291
+ g: clamp255(rgb.g * frac + 255 * inv),
292
+ b: clamp255(rgb.b * frac + 255 * inv),
293
+ };
294
+ break;
295
+ }
296
+ case "alpha":
297
+ break; // opacity not reflected in sRGB output
298
+ }
299
+ }
300
+ return formatDmlHex(rgb);
301
+ }
302
+
303
+ function parseHexToRgbDml(hex: string): { r: number; g: number; b: number } {
304
+ const n = Number.parseInt(hex.slice(1), 16);
305
+ return { r: (n >>> 16) & 0xff, g: (n >>> 8) & 0xff, b: n & 0xff };
306
+ }
307
+
308
+ function formatDmlHex(rgb: { r: number; g: number; b: number }): string {
309
+ const h2 = (n: number): string =>
310
+ Math.round(clamp255(n)).toString(16).padStart(2, "0").toUpperCase();
311
+ return `#${h2(rgb.r)}${h2(rgb.g)}${h2(rgb.b)}`;
312
+ }
313
+
314
+ function clamp01(x: number): number {
315
+ return x < 0 ? 0 : x > 1 ? 1 : x;
316
+ }
317
+
318
+ function clamp255(x: number): number {
319
+ return x < 0 ? 0 : x > 255 ? 255 : x;
320
+ }
321
+
177
322
  function rgbToHsl(rgb: { r: number; g: number; b: number }): {
178
323
  h: number;
179
324
  s: number;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * OOXML unit conversions used across the runtime + UI.
3
+ *
4
+ * Hoisted from per-file constants to deduplicate the magic numbers that
5
+ * appear in `pm-schema.ts`, `chart-model-store.ts`, `float-wrap-resolver.ts`,
6
+ * `shape-renderer.ts`, and several other call sites. Renaming an alias
7
+ * here propagates everywhere; renaming a literal would not.
8
+ *
9
+ * Conventions:
10
+ * - All EMU helpers assume 96 dpi (the default OOXML rendering DPI).
11
+ * - Twip helpers assume 1440 twips per inch (OOXML standard).
12
+ * - Rotation values use OOXML's 60 000ths of a degree (`a:xfrm a:rot`).
13
+ * - Picture crop (`a:srcRect`) values use 1/1000 of a percent per edge.
14
+ */
15
+
16
+ /** EMU (English Metric Units) per CSS pixel at 96 dpi. */
17
+ export const EMU_PER_PX = 9525;
18
+
19
+ /** OOXML rotation units per degree (`a:xfrm a:rot` = 60 000ths°). */
20
+ export const ROTATION_UNITS_PER_DEGREE = 60000;
21
+
22
+ /** OOXML picture-crop units per percent (`a:srcRect` uses 1/1000 of a percent). */
23
+ export const SRCRECT_UNITS_PER_PERCENT = 1000;
24
+
25
+ /** Twips per CSS pixel at 96 dpi (1440 twips/inch ÷ 96 px/inch = 15). */
26
+ export const TWIPS_PER_PX = 15;
27
+
28
+ /** Convert EMU → CSS pixels at 96 dpi. */
29
+ export function emuToPx(emu: number): number {
30
+ return emu / EMU_PER_PX;
31
+ }
32
+
33
+ /** Convert OOXML rotation units → CSS degrees. */
34
+ export function rotationToDeg(units: number): number {
35
+ return units / ROTATION_UNITS_PER_DEGREE;
36
+ }
37
+
38
+ /** Convert an `a:srcRect` edge value → CSS percent. */
39
+ export function srcRectToPercent(units: number): number {
40
+ return units / SRCRECT_UNITS_PER_PERCENT;
41
+ }
42
+
43
+ /** Convert twips → CSS pixels at 96 dpi. */
44
+ export function twipsToPx(twips: number): number {
45
+ return twips / TWIPS_PER_PX;
46
+ }
@@ -403,6 +403,14 @@ function deriveActiveObjectFrame(
403
403
  };
404
404
  }
405
405
 
406
+ if (segment.kind === "shape") {
407
+ return {
408
+ kind: "shape",
409
+ anchorPos: segment.from,
410
+ display: segment.anchor?.display === "floating" ? "floating" : "inline",
411
+ };
412
+ }
413
+
406
414
  const objectKind = inferOpaqueObjectKind(segment);
407
415
  if (!objectKind) {
408
416
  return null;
@@ -491,7 +499,7 @@ function normalizeZoomLevel(
491
499
  function findObjectSegmentAtPosition(
492
500
  segments: readonly SurfaceInlineSegment[],
493
501
  position: number,
494
- ): Extract<SurfaceInlineSegment, { kind: "image" | "opaque_inline" }> | null {
502
+ ): Extract<SurfaceInlineSegment, { kind: "image" | "opaque_inline" | "shape" }> | null {
495
503
  for (const segment of segments) {
496
504
  if (!isObjectLikeSegment(segment)) {
497
505
  continue;
@@ -505,10 +513,13 @@ function findObjectSegmentAtPosition(
505
513
 
506
514
  function isObjectLikeSegment(
507
515
  segment: SurfaceInlineSegment,
508
- ): segment is Extract<SurfaceInlineSegment, { kind: "image" | "opaque_inline" }> {
516
+ ): segment is Extract<SurfaceInlineSegment, { kind: "image" | "opaque_inline" | "shape" }> {
509
517
  if (segment.kind === "image") {
510
518
  return true;
511
519
  }
520
+ if (segment.kind === "shape") {
521
+ return true;
522
+ }
512
523
  if (segment.kind !== "opaque_inline") {
513
524
  return false;
514
525
  }