@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
|
@@ -146,6 +146,117 @@ export function extractBookmarksFromBodyXml(bodyXml: string): ParsedBookmarkNode
|
|
|
146
146
|
return results;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// ─── FieldGroup stream walker ─────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A fully-parsed fldChar triad: begin → instrText* → separate → display → end.
|
|
153
|
+
* Unlike `ParsedComplexFieldNode`, this captures cross-paragraph triads by
|
|
154
|
+
* walking the body-level run list flattened across all block elements.
|
|
155
|
+
*
|
|
156
|
+
* Fail-closed: an incomplete triad (begin with no end) is silently discarded.
|
|
157
|
+
*/
|
|
158
|
+
export interface FieldGroup {
|
|
159
|
+
/** Joined instrText content (trimmed). */
|
|
160
|
+
instruction: string;
|
|
161
|
+
/** Raw XML of all runs between the separate and end fldChar runs. */
|
|
162
|
+
displayContent: string;
|
|
163
|
+
/** Byte offset in sourceXml where the begin run starts. */
|
|
164
|
+
start: number;
|
|
165
|
+
/** Byte offset (exclusive) where the end run ends. */
|
|
166
|
+
end: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Stream-walk all block elements in a body XML string and emit one
|
|
171
|
+
* `FieldGroup` per complete fldChar triad. Handles cross-paragraph
|
|
172
|
+
* triads (e.g., TOC fields that span multiple <w:p> siblings).
|
|
173
|
+
*
|
|
174
|
+
* Fail-closed: a begin with no matching end is silently dropped.
|
|
175
|
+
*/
|
|
176
|
+
export function streamWalkFieldGroups(bodyXml: string): FieldGroup[] {
|
|
177
|
+
const root = parseXml(bodyXml);
|
|
178
|
+
const bodyEl =
|
|
179
|
+
findFirstChild(root, "body") ??
|
|
180
|
+
findFirstChild(root, "document") ??
|
|
181
|
+
root;
|
|
182
|
+
|
|
183
|
+
const allRuns: XmlElementNode[] = [];
|
|
184
|
+
collectRunsDeep(bodyEl, allRuns);
|
|
185
|
+
|
|
186
|
+
return extractFieldGroupsFromRuns(allRuns, bodyXml);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function collectRunsDeep(node: XmlElementNode, out: XmlElementNode[]): void {
|
|
190
|
+
for (const child of node.children) {
|
|
191
|
+
if (child.type !== "element") continue;
|
|
192
|
+
if (localName(child.name) === "r") {
|
|
193
|
+
out.push(child);
|
|
194
|
+
} else {
|
|
195
|
+
collectRunsDeep(child, out);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function extractFieldGroupsFromRuns(
|
|
201
|
+
runs: XmlElementNode[],
|
|
202
|
+
sourceXml: string,
|
|
203
|
+
): FieldGroup[] {
|
|
204
|
+
type State = "idle" | "in-instr" | "in-content";
|
|
205
|
+
let state: State = "idle";
|
|
206
|
+
let instrParts: string[] = [];
|
|
207
|
+
let fieldStart = -1;
|
|
208
|
+
let contentStart = -1;
|
|
209
|
+
const results: FieldGroup[] = [];
|
|
210
|
+
|
|
211
|
+
for (const run of runs) {
|
|
212
|
+
const fldChar = findFirstChildEl(run, "fldChar");
|
|
213
|
+
const instrText = findFirstChildEl(run, "instrText");
|
|
214
|
+
|
|
215
|
+
if (fldChar) {
|
|
216
|
+
const charType = (
|
|
217
|
+
fldChar.attributes["w:fldCharType"] ??
|
|
218
|
+
fldChar.attributes.fldCharType ??
|
|
219
|
+
""
|
|
220
|
+
).toLowerCase();
|
|
221
|
+
|
|
222
|
+
if (charType === "begin") {
|
|
223
|
+
state = "in-instr";
|
|
224
|
+
instrParts = [];
|
|
225
|
+
fieldStart = run.start;
|
|
226
|
+
contentStart = -1;
|
|
227
|
+
} else if (charType === "separate" && state === "in-instr") {
|
|
228
|
+
state = "in-content";
|
|
229
|
+
contentStart = run.end;
|
|
230
|
+
} else if (charType === "end") {
|
|
231
|
+
if (state === "in-content" || state === "in-instr") {
|
|
232
|
+
const displayContent =
|
|
233
|
+
contentStart >= 0 && contentStart <= run.start
|
|
234
|
+
? sourceXml.slice(contentStart, run.start)
|
|
235
|
+
: "";
|
|
236
|
+
results.push({
|
|
237
|
+
instruction: instrParts.join("").trim(),
|
|
238
|
+
displayContent,
|
|
239
|
+
start: fieldStart,
|
|
240
|
+
end: run.end,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
state = "idle";
|
|
244
|
+
instrParts = [];
|
|
245
|
+
fieldStart = -1;
|
|
246
|
+
contentStart = -1;
|
|
247
|
+
}
|
|
248
|
+
} else if (instrText && state === "in-instr") {
|
|
249
|
+
const text = instrText.children
|
|
250
|
+
.filter((c): c is XmlTextNode => c.type === "text")
|
|
251
|
+
.map((c) => c.text)
|
|
252
|
+
.join("");
|
|
253
|
+
instrParts.push(text);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return results;
|
|
258
|
+
}
|
|
259
|
+
|
|
149
260
|
// ─── Element-level parsers (exported for unit testing) ────────────────────────
|
|
150
261
|
|
|
151
262
|
export function parseFldSimple(
|
|
@@ -309,7 +420,7 @@ export function buildFieldRegistry(
|
|
|
309
420
|
paragraphIndex = pIdx;
|
|
310
421
|
if (node.type === "field") {
|
|
311
422
|
const classification = node.fieldFamily
|
|
312
|
-
? { family: node.fieldFamily, supported: isSupportedFieldFamily(node.fieldFamily), target: node.fieldTarget }
|
|
423
|
+
? { family: node.fieldFamily, supported: isSupportedFieldFamily(node.fieldFamily), target: node.fieldTarget, switches: node.switches }
|
|
313
424
|
: classifyFieldInstruction(node.instruction);
|
|
314
425
|
const displayText = flattenFieldText(node.children);
|
|
315
426
|
const entry: FieldRegistryEntry = {
|
|
@@ -321,6 +432,7 @@ export function buildFieldRegistry(
|
|
|
321
432
|
displayText,
|
|
322
433
|
paragraphIndex,
|
|
323
434
|
refreshStatus: node.refreshStatus ?? (classification.supported ? "stale" : "preserve-only"),
|
|
435
|
+
...(classification.switches ? { switches: classification.switches } : {}),
|
|
324
436
|
};
|
|
325
437
|
if (classification.supported) {
|
|
326
438
|
supported.push(entry);
|
|
@@ -338,7 +450,7 @@ export function buildFieldRegistry(
|
|
|
338
450
|
paragraphIndex = pIdx;
|
|
339
451
|
if (node.type === "field") {
|
|
340
452
|
const classification = node.fieldFamily
|
|
341
|
-
? { family: node.fieldFamily, supported: isSupportedFieldFamily(node.fieldFamily), target: node.fieldTarget }
|
|
453
|
+
? { family: node.fieldFamily, supported: isSupportedFieldFamily(node.fieldFamily), target: node.fieldTarget, switches: node.switches }
|
|
342
454
|
: classifyFieldInstruction(node.instruction);
|
|
343
455
|
const displayText = flattenFieldText(node.children);
|
|
344
456
|
const entry: FieldRegistryEntry = {
|
|
@@ -350,6 +462,7 @@ export function buildFieldRegistry(
|
|
|
350
462
|
displayText,
|
|
351
463
|
paragraphIndex,
|
|
352
464
|
refreshStatus: node.refreshStatus ?? (classification.supported ? "stale" : "preserve-only"),
|
|
465
|
+
...(classification.switches ? { switches: classification.switches } : {}),
|
|
353
466
|
};
|
|
354
467
|
if (classification.supported) {
|
|
355
468
|
supported.push(entry);
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parse-fill.ts
|
|
3
|
+
*
|
|
4
|
+
* Shared fill-parsing primitives for DrawingML cascade reads. Covers
|
|
5
|
+
* `a:solidFill`, `a:noFill`, `a:gradFill`, and `a:pattFill`.
|
|
6
|
+
*
|
|
7
|
+
* Exposed for:
|
|
8
|
+
* - `parse-shapes.ts::parseShapeContent` (wps:wsp → ShapeContent.fill) — CO4
|
|
9
|
+
* - `src/io/ooxml/chart/**` (chart-style + series fill cascade) — Lane 5 Stage 2+
|
|
10
|
+
*
|
|
11
|
+
* The `colorType` discriminant on solid-fill keeps `schemeClr` tokens raw so
|
|
12
|
+
* CO1's ThemeColorResolver can resolve them to effective rgb later. Gradient
|
|
13
|
+
* and pattern colors use the same `ColorToken` shape for consistency.
|
|
14
|
+
*
|
|
15
|
+
* Legacy export: `parseSolidFill` is retained for back-compat with existing
|
|
16
|
+
* call sites; new code should prefer `parseFill` which dispatches across all
|
|
17
|
+
* four fill families.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface XmlElementNode {
|
|
21
|
+
type: "element";
|
|
22
|
+
name: string;
|
|
23
|
+
attributes: Record<string, string>;
|
|
24
|
+
children: XmlNode[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface XmlTextNode {
|
|
28
|
+
type: "text";
|
|
29
|
+
text: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type XmlNode = XmlElementNode | XmlTextNode;
|
|
33
|
+
|
|
34
|
+
export interface ColorToken {
|
|
35
|
+
color: string;
|
|
36
|
+
colorType: "srgbClr" | "schemeClr";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type SolidFillResult =
|
|
40
|
+
| { kind: "solid"; color: string; colorType: "srgbClr" | "schemeClr" }
|
|
41
|
+
| { kind: "none" };
|
|
42
|
+
|
|
43
|
+
export interface GradientStop {
|
|
44
|
+
/** Position along the gradient axis in 0..100000 (= 0..100%). */
|
|
45
|
+
pos: number;
|
|
46
|
+
color: string;
|
|
47
|
+
colorType: "srgbClr" | "schemeClr";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type GradientDirection =
|
|
51
|
+
| { kind: "linear"; angle: number; scaled?: boolean } // angle in 60000ths of degree
|
|
52
|
+
| { kind: "path"; path: "circle" | "rect" | "shape" };
|
|
53
|
+
|
|
54
|
+
export interface GradientFillResult {
|
|
55
|
+
kind: "gradient";
|
|
56
|
+
stops: GradientStop[];
|
|
57
|
+
direction: GradientDirection;
|
|
58
|
+
/** Rotate with shape (a:gradFill rotWithShape). Defaults true per OOXML. */
|
|
59
|
+
rotWithShape?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PatternFillResult {
|
|
63
|
+
kind: "pattern";
|
|
64
|
+
/** Preset pattern token (e.g. "wdDkVert", "pct50"). Raw OOXML value. */
|
|
65
|
+
preset: string;
|
|
66
|
+
fg?: ColorToken;
|
|
67
|
+
bg?: ColorToken;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type FillResult = SolidFillResult | GradientFillResult | PatternFillResult;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parse any OOXML fill child of a shape-property element.
|
|
74
|
+
*
|
|
75
|
+
* Returns the first recognised fill variant found among:
|
|
76
|
+
* - `a:noFill`
|
|
77
|
+
* - `a:solidFill`
|
|
78
|
+
* - `a:gradFill`
|
|
79
|
+
* - `a:pattFill`
|
|
80
|
+
*
|
|
81
|
+
* Returns `undefined` when none of the above are direct children.
|
|
82
|
+
*/
|
|
83
|
+
export function parseFill(parent: XmlElementNode): FillResult | undefined {
|
|
84
|
+
if (findFirstChild(parent, "noFill")) return { kind: "none" };
|
|
85
|
+
const solid = findFirstChild(parent, "solidFill");
|
|
86
|
+
if (solid) return parseSolidFillInner(solid);
|
|
87
|
+
const grad = findFirstChild(parent, "gradFill");
|
|
88
|
+
if (grad) return parseGradientFill(grad);
|
|
89
|
+
const pat = findFirstChild(parent, "pattFill");
|
|
90
|
+
if (pat) return parsePatternFill(pat);
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Legacy `parseSolidFill` entry point (still used by `parse-shapes.ts::readFill`).
|
|
96
|
+
* Returns only `SolidFillResult` — callers that want gradient/pattern should
|
|
97
|
+
* migrate to `parseFill`.
|
|
98
|
+
*/
|
|
99
|
+
export function parseSolidFill(parent: XmlElementNode): SolidFillResult | undefined {
|
|
100
|
+
const noFill = findFirstChild(parent, "noFill");
|
|
101
|
+
if (noFill) return { kind: "none" };
|
|
102
|
+
const solid = findFirstChild(parent, "solidFill");
|
|
103
|
+
if (!solid) return undefined;
|
|
104
|
+
return parseSolidFillInner(solid);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseSolidFillInner(solid: XmlElementNode): SolidFillResult | undefined {
|
|
108
|
+
const srgb = findFirstChild(solid, "srgbClr");
|
|
109
|
+
if (srgb?.attributes.val) {
|
|
110
|
+
return {
|
|
111
|
+
kind: "solid",
|
|
112
|
+
color: srgb.attributes.val.toUpperCase(),
|
|
113
|
+
colorType: "srgbClr",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const scheme = findFirstChild(solid, "schemeClr");
|
|
117
|
+
if (scheme?.attributes.val) {
|
|
118
|
+
return {
|
|
119
|
+
kind: "solid",
|
|
120
|
+
color: scheme.attributes.val,
|
|
121
|
+
colorType: "schemeClr",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseGradientFill(grad: XmlElementNode): GradientFillResult | undefined {
|
|
128
|
+
const gsLst = findFirstChild(grad, "gsLst");
|
|
129
|
+
if (!gsLst) return undefined;
|
|
130
|
+
const stops: GradientStop[] = [];
|
|
131
|
+
for (const child of gsLst.children) {
|
|
132
|
+
if (child.type !== "element") continue;
|
|
133
|
+
if (localName(child.name) !== "gs") continue;
|
|
134
|
+
const posRaw = child.attributes.pos;
|
|
135
|
+
const pos = posRaw !== undefined ? parseInt(posRaw, 10) : 0;
|
|
136
|
+
const token = extractColorToken(child);
|
|
137
|
+
if (!token) continue;
|
|
138
|
+
stops.push({ pos: Number.isFinite(pos) ? pos : 0, ...token });
|
|
139
|
+
}
|
|
140
|
+
if (stops.length === 0) return undefined;
|
|
141
|
+
|
|
142
|
+
const direction = readGradientDirection(grad);
|
|
143
|
+
const rotWithShapeRaw = grad.attributes.rotWithShape;
|
|
144
|
+
const rotWithShape =
|
|
145
|
+
rotWithShapeRaw === undefined ? undefined : rotWithShapeRaw !== "0" && rotWithShapeRaw !== "false";
|
|
146
|
+
|
|
147
|
+
const result: GradientFillResult = { kind: "gradient", stops, direction };
|
|
148
|
+
if (rotWithShape !== undefined) result.rotWithShape = rotWithShape;
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readGradientDirection(grad: XmlElementNode): GradientDirection {
|
|
153
|
+
const lin = findFirstChild(grad, "lin");
|
|
154
|
+
if (lin) {
|
|
155
|
+
const ang = parseInt(lin.attributes.ang ?? "0", 10) || 0;
|
|
156
|
+
const scaledRaw = lin.attributes.scaled;
|
|
157
|
+
const scaled = scaledRaw !== undefined ? scaledRaw !== "0" && scaledRaw !== "false" : undefined;
|
|
158
|
+
const d: GradientDirection = { kind: "linear", angle: ang };
|
|
159
|
+
if (scaled !== undefined) d.scaled = scaled;
|
|
160
|
+
return d;
|
|
161
|
+
}
|
|
162
|
+
const path = findFirstChild(grad, "path");
|
|
163
|
+
if (path) {
|
|
164
|
+
const pathKind = path.attributes.path;
|
|
165
|
+
if (pathKind === "circle" || pathKind === "rect" || pathKind === "shape") {
|
|
166
|
+
return { kind: "path", path: pathKind };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Default: OOXML implicit linear at 0°
|
|
170
|
+
return { kind: "linear", angle: 0 };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parsePatternFill(pat: XmlElementNode): PatternFillResult | undefined {
|
|
174
|
+
const preset = pat.attributes.prst;
|
|
175
|
+
if (!preset) return undefined;
|
|
176
|
+
const fgEl = findFirstChild(pat, "fgClr");
|
|
177
|
+
const bgEl = findFirstChild(pat, "bgClr");
|
|
178
|
+
const result: PatternFillResult = { kind: "pattern", preset };
|
|
179
|
+
if (fgEl) {
|
|
180
|
+
const t = extractColorToken(fgEl);
|
|
181
|
+
if (t) result.fg = t;
|
|
182
|
+
}
|
|
183
|
+
if (bgEl) {
|
|
184
|
+
const t = extractColorToken(bgEl);
|
|
185
|
+
if (t) result.bg = t;
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function extractColorToken(parent: XmlElementNode): ColorToken | undefined {
|
|
191
|
+
const srgb = findFirstChild(parent, "srgbClr");
|
|
192
|
+
if (srgb?.attributes.val) {
|
|
193
|
+
return { color: srgb.attributes.val.toUpperCase(), colorType: "srgbClr" };
|
|
194
|
+
}
|
|
195
|
+
const scheme = findFirstChild(parent, "schemeClr");
|
|
196
|
+
if (scheme?.attributes.val) {
|
|
197
|
+
return { color: scheme.attributes.val, colorType: "schemeClr" };
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function findFirstChild(
|
|
203
|
+
node: XmlElementNode,
|
|
204
|
+
local: string,
|
|
205
|
+
): XmlElementNode | undefined {
|
|
206
|
+
for (const child of node.children) {
|
|
207
|
+
if (child.type === "element" && localName(child.name) === local) return child;
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function localName(name: string): string {
|
|
213
|
+
const i = name.indexOf(":");
|
|
214
|
+
return i >= 0 ? name.slice(i + 1) : name;
|
|
215
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse fontTable.xml into a CanonicalFontTable.
|
|
3
|
+
*
|
|
4
|
+
* Reads every <w:font> child of the root <w:fonts> element and materializes
|
|
5
|
+
* name/family/pitch/charset/altName. panose and sig metadata is not needed
|
|
6
|
+
* by consumers and is skipped.
|
|
7
|
+
*
|
|
8
|
+
* Key insight from LO FontTable.cxx: w:family is qualitative ("roman",
|
|
9
|
+
* "swiss", "modern", "script", "decorative") — do not treat as numeric.
|
|
10
|
+
* w:charset="02" marks Symbol fonts; consumers must handle glyph remapping.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
CanonicalFontEntry,
|
|
15
|
+
CanonicalFontTable,
|
|
16
|
+
} from "../../model/canonical-document.ts";
|
|
17
|
+
|
|
18
|
+
interface XmlElementNode {
|
|
19
|
+
type: "element";
|
|
20
|
+
name: string;
|
|
21
|
+
attributes: Record<string, string>;
|
|
22
|
+
children: XmlNode[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface XmlTextNode {
|
|
26
|
+
type: "text";
|
|
27
|
+
text: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type XmlNode = XmlElementNode | XmlTextNode;
|
|
31
|
+
|
|
32
|
+
const KNOWN_FAMILIES = new Set(["roman", "swiss", "modern", "script", "decorative"]);
|
|
33
|
+
|
|
34
|
+
export function parseFontTable(xml: string): CanonicalFontTable {
|
|
35
|
+
const root = parseXml(xml);
|
|
36
|
+
const fontsElement = findChildElementOptional(root, "fonts") ?? root;
|
|
37
|
+
const fonts: Record<string, CanonicalFontEntry> = {};
|
|
38
|
+
|
|
39
|
+
for (const child of fontsElement.children) {
|
|
40
|
+
if (child.type !== "element") continue;
|
|
41
|
+
if (localName(child.name) !== "font") continue;
|
|
42
|
+
|
|
43
|
+
const name = child.attributes["w:name"] ?? child.attributes["name"];
|
|
44
|
+
if (!name) continue;
|
|
45
|
+
|
|
46
|
+
const entry: CanonicalFontEntry = { name };
|
|
47
|
+
|
|
48
|
+
for (const sub of child.children) {
|
|
49
|
+
if (sub.type !== "element") continue;
|
|
50
|
+
switch (localName(sub.name)) {
|
|
51
|
+
case "family": {
|
|
52
|
+
const raw = sub.attributes["w:val"] ?? sub.attributes["val"];
|
|
53
|
+
if (raw && KNOWN_FAMILIES.has(raw)) {
|
|
54
|
+
entry.family = raw as CanonicalFontEntry["family"];
|
|
55
|
+
}
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case "pitch": {
|
|
59
|
+
const raw = sub.attributes["w:val"] ?? sub.attributes["val"];
|
|
60
|
+
if (raw === "fixed" || raw === "variable" || raw === "default") {
|
|
61
|
+
entry.pitch = raw;
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case "charset": {
|
|
66
|
+
const raw = sub.attributes["w:val"] ?? sub.attributes["val"];
|
|
67
|
+
if (raw !== undefined) {
|
|
68
|
+
const parsed = Number.parseInt(raw, 16);
|
|
69
|
+
if (Number.isFinite(parsed)) entry.charset = parsed;
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case "altName": {
|
|
74
|
+
const raw = sub.attributes["w:val"] ?? sub.attributes["val"];
|
|
75
|
+
if (raw) entry.altName = raw;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fonts[name] = entry;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { fonts };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// XML helpers — same shape as parse-numbering.ts internals
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
function localName(name: string): string {
|
|
92
|
+
const separator = name.indexOf(":");
|
|
93
|
+
return separator >= 0 ? name.slice(separator + 1) : name;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function findChildElementOptional(
|
|
97
|
+
node: XmlElementNode,
|
|
98
|
+
childLocalName: string,
|
|
99
|
+
): XmlElementNode | undefined {
|
|
100
|
+
return node.children.find(
|
|
101
|
+
(entry): entry is XmlElementNode =>
|
|
102
|
+
entry.type === "element" && localName(entry.name) === childLocalName,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseXml(xml: string): XmlElementNode {
|
|
107
|
+
const root: XmlElementNode = {
|
|
108
|
+
type: "element",
|
|
109
|
+
name: "__root__",
|
|
110
|
+
attributes: {},
|
|
111
|
+
children: [],
|
|
112
|
+
};
|
|
113
|
+
const stack: XmlElementNode[] = [root];
|
|
114
|
+
let cursor = 0;
|
|
115
|
+
|
|
116
|
+
while (cursor < xml.length) {
|
|
117
|
+
if (xml.startsWith("<!--", cursor)) {
|
|
118
|
+
const end = xml.indexOf("-->", cursor);
|
|
119
|
+
cursor = end >= 0 ? end + 3 : xml.length;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (xml.startsWith("<?", cursor)) {
|
|
123
|
+
const end = xml.indexOf("?>", cursor);
|
|
124
|
+
cursor = end >= 0 ? end + 2 : xml.length;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (xml[cursor] !== "<") {
|
|
128
|
+
const nextTag = xml.indexOf("<", cursor);
|
|
129
|
+
cursor = nextTag >= 0 ? nextTag : xml.length;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (xml[cursor + 1] === "/") {
|
|
133
|
+
const end = xml.indexOf(">", cursor);
|
|
134
|
+
if (end < 0) throw new Error("Malformed XML: missing closing >.");
|
|
135
|
+
stack.pop();
|
|
136
|
+
cursor = end + 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const tagEnd = findTagEnd(xml, cursor);
|
|
141
|
+
const tagBody = xml.slice(cursor + 1, tagEnd);
|
|
142
|
+
const selfClosing = /\/\s*$/.test(tagBody);
|
|
143
|
+
const { name, attributes } = parseTag(tagBody.replace(/\/\s*$/, "").trim());
|
|
144
|
+
|
|
145
|
+
const element: XmlElementNode = {
|
|
146
|
+
type: "element",
|
|
147
|
+
name,
|
|
148
|
+
attributes,
|
|
149
|
+
children: [],
|
|
150
|
+
};
|
|
151
|
+
stack[stack.length - 1]?.children.push(element);
|
|
152
|
+
if (!selfClosing) stack.push(element);
|
|
153
|
+
cursor = tagEnd + 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return root;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function findTagEnd(xml: string, start: number): number {
|
|
160
|
+
let cursor = start + 1;
|
|
161
|
+
let inQuote: '"' | "'" | null = null;
|
|
162
|
+
while (cursor < xml.length) {
|
|
163
|
+
const ch = xml[cursor];
|
|
164
|
+
if (inQuote) {
|
|
165
|
+
if (ch === inQuote) inQuote = null;
|
|
166
|
+
} else {
|
|
167
|
+
if (ch === '"' || ch === "'") inQuote = ch;
|
|
168
|
+
else if (ch === ">") return cursor;
|
|
169
|
+
}
|
|
170
|
+
cursor++;
|
|
171
|
+
}
|
|
172
|
+
throw new Error("Malformed XML: missing closing >.");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parseTag(body: string): { name: string; attributes: Record<string, string> } {
|
|
176
|
+
const attributes: Record<string, string> = {};
|
|
177
|
+
let cursor = 0;
|
|
178
|
+
while (cursor < body.length && !/\s/.test(body[cursor]!)) cursor++;
|
|
179
|
+
const name = body.slice(0, cursor);
|
|
180
|
+
|
|
181
|
+
const attrRegex = /([\w:\-.]+)\s*=\s*"([^"]*)"|([\w:\-.]+)\s*=\s*'([^']*)'/g;
|
|
182
|
+
let match: RegExpExecArray | null;
|
|
183
|
+
while ((match = attrRegex.exec(body)) !== null) {
|
|
184
|
+
const key = match[1] ?? match[3]!;
|
|
185
|
+
const value = match[2] ?? match[4] ?? "";
|
|
186
|
+
attributes[key] = value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { name, attributes };
|
|
190
|
+
}
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
BlockNode,
|
|
3
3
|
FootnoteCollection,
|
|
4
4
|
FootnoteDefinition,
|
|
5
|
+
FootnoteSeparators,
|
|
5
6
|
InlineNode,
|
|
6
7
|
ParagraphIndentation,
|
|
7
8
|
ParagraphNode,
|
|
@@ -114,7 +115,12 @@ export function parseFootnotesXml(xml: string): FootnoteCollection {
|
|
|
114
115
|
}
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
|
|
118
|
+
const footnoteSeparators = parseFootnoteSeparators(xml);
|
|
119
|
+
return {
|
|
120
|
+
footnotes,
|
|
121
|
+
endnotes,
|
|
122
|
+
...(Object.keys(footnoteSeparators).length > 0 ? { footnoteSeparators } : {}),
|
|
123
|
+
};
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
/**
|
|
@@ -152,6 +158,51 @@ export function parseEndnotesXml(
|
|
|
152
158
|
};
|
|
153
159
|
}
|
|
154
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Extract separator and continuation-separator run XML from footnotes.xml
|
|
163
|
+
* or endnotes.xml. The special notes with `w:type="separator"` and
|
|
164
|
+
* `w:type="continuationSeparator"` contain horizontal-rule content used
|
|
165
|
+
* by the page-chrome renderer at the footnote divider line.
|
|
166
|
+
*/
|
|
167
|
+
export function parseFootnoteSeparators(xml: string): FootnoteSeparators {
|
|
168
|
+
const root = parseXml(xml);
|
|
169
|
+
const footnotesEl = findChildElementOptional(root, "footnotes");
|
|
170
|
+
const endnotesEl = findChildElementOptional(root, "endnotes");
|
|
171
|
+
const containerEl = footnotesEl ?? endnotesEl;
|
|
172
|
+
if (!containerEl) return {};
|
|
173
|
+
|
|
174
|
+
let separatorContent: string | undefined;
|
|
175
|
+
let continuationSeparatorContent: string | undefined;
|
|
176
|
+
|
|
177
|
+
for (const child of containerEl.children) {
|
|
178
|
+
if (child.type !== "element") continue;
|
|
179
|
+
const name = localName(child.name);
|
|
180
|
+
if (name !== "footnote" && name !== "endnote") continue;
|
|
181
|
+
|
|
182
|
+
const rawType = child.attributes["w:type"] ?? child.attributes.type ?? "";
|
|
183
|
+
if (rawType !== "separator" && rawType !== "continuationSeparator") continue;
|
|
184
|
+
|
|
185
|
+
const paraEl = findChildElementOptional(child, "p");
|
|
186
|
+
if (!paraEl) continue;
|
|
187
|
+
|
|
188
|
+
const runXml = paraEl.children
|
|
189
|
+
.filter((c): c is XmlElementNode => c.type === "element" && localName(c.name) === "r")
|
|
190
|
+
.map((r) => serializeElementToXml(r))
|
|
191
|
+
.join("");
|
|
192
|
+
|
|
193
|
+
if (rawType === "separator") {
|
|
194
|
+
separatorContent = runXml;
|
|
195
|
+
} else {
|
|
196
|
+
continuationSeparatorContent = runXml;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
...(separatorContent !== undefined ? { separatorContent } : {}),
|
|
202
|
+
...(continuationSeparatorContent !== undefined ? { continuationSeparatorContent } : {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
155
206
|
// ---- Internal helpers ----
|
|
156
207
|
|
|
157
208
|
function parseNoteElement(
|