@beyondwork/docx-react-component 1.0.55 → 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.
- package/package.json +43 -32
- package/src/api/public-types.ts +157 -0
- package/src/compare/diff-engine.ts +3 -0
- package/src/core/commands/formatting-commands.ts +1 -0
- package/src/core/commands/index.ts +17 -11
- package/src/core/selection/mapping.ts +18 -1
- package/src/core/selection/review-anchors.ts +29 -18
- package/src/io/chart-preview-resolver.ts +175 -41
- package/src/io/docx-session.ts +57 -2
- package/src/io/export/serialize-main-document.ts +82 -0
- package/src/io/export/serialize-styles.ts +61 -3
- package/src/io/export/table-properties-xml.ts +19 -4
- package/src/io/normalize/normalize-text.ts +33 -0
- package/src/io/ooxml/parse-anchor.ts +182 -0
- package/src/io/ooxml/parse-drawing.ts +319 -0
- package/src/io/ooxml/parse-fields.ts +115 -2
- package/src/io/ooxml/parse-fill.ts +215 -0
- package/src/io/ooxml/parse-font-table.ts +190 -0
- package/src/io/ooxml/parse-footnotes.ts +52 -1
- package/src/io/ooxml/parse-main-document.ts +241 -1
- package/src/io/ooxml/parse-numbering.ts +96 -0
- package/src/io/ooxml/parse-picture.ts +107 -0
- package/src/io/ooxml/parse-settings.ts +34 -0
- package/src/io/ooxml/parse-shapes.ts +87 -0
- package/src/io/ooxml/parse-solid-fill.ts +11 -0
- package/src/io/ooxml/parse-styles.ts +74 -1
- package/src/io/ooxml/parse-theme.ts +60 -0
- package/src/io/paste/html-clipboard.ts +449 -0
- package/src/io/paste/word-clipboard.ts +5 -1
- package/src/legal/_document-root.ts +26 -0
- package/src/legal/bookmarks.ts +4 -3
- package/src/legal/cross-references.ts +3 -2
- package/src/legal/defined-terms.ts +2 -1
- package/src/legal/signature-blocks.ts +2 -1
- package/src/model/canonical-document.ts +415 -3
- package/src/runtime/chart/chart-model-store.ts +73 -10
- package/src/runtime/document-runtime.ts +693 -41
- package/src/runtime/edit-ops/index.ts +129 -0
- package/src/runtime/event-refresh-hints.ts +7 -0
- package/src/runtime/field-resolver.ts +341 -0
- package/src/runtime/footnote-resolver.ts +55 -0
- package/src/runtime/hyperlink-color-resolver.ts +13 -10
- package/src/runtime/object-grab/index.ts +51 -0
- package/src/runtime/paragraph-style-resolver.ts +105 -0
- package/src/runtime/resolved-numbering-geometry.ts +12 -0
- package/src/runtime/selection/cursor-ops.ts +186 -15
- package/src/runtime/selection/index.ts +17 -1
- package/src/runtime/structure-ops/index.ts +77 -0
- package/src/runtime/styles-cascade.ts +33 -0
- package/src/runtime/surface-projection.ts +186 -12
- package/src/runtime/theme-color-resolver.ts +189 -44
- package/src/runtime/units.ts +46 -0
- package/src/runtime/view-state.ts +13 -2
- package/src/ui/WordReviewEditor.tsx +168 -10
- package/src/ui/editor-runtime-boundary.ts +94 -1
- package/src/ui/editor-shell-view.tsx +1 -1
- package/src/ui/runtime-shortcut-dispatch.ts +17 -3
- package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
- package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
- package/src/ui-tailwind/chart/render/area.tsx +22 -4
- package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
- package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
- package/src/ui-tailwind/chart/render/combo.tsx +37 -4
- package/src/ui-tailwind/chart/render/line.tsx +28 -5
- package/src/ui-tailwind/chart/render/pie.tsx +36 -16
- package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
- package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
- package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
- package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
- package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
- package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
- package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
- package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
- package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
- package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
- package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
- package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
- package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
- package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
- package/src/ui-tailwind/editor-surface/pm-schema.ts +192 -11
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +28 -3
- package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
- package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
- package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
- package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
- package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
- package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
- package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
- package/src/ui-tailwind/theme/editor-theme.css +1 -0
- package/src/ui-tailwind/theme/tokens.css +6 -0
- package/src/ui-tailwind/theme/tokens.ts +10 -0
- package/src/validation/compatibility-engine.ts +2 -0
- 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
|
-
|
|
948
|
+
themeResolver,
|
|
923
949
|
)
|
|
924
950
|
: concretizeThemeColors(
|
|
925
951
|
resolveEffectiveRunFormatting(runResolveInput, document.styles),
|
|
926
|
-
|
|
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
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
17
|
-
* `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
// the fallback
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
}
|