@beyondwork/docx-react-component 1.0.47 → 1.0.48
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 +1 -1
- package/src/api/public-types.ts +115 -1
- package/src/compare/diff-engine.ts +4 -0
- package/src/core/commands/add-scope.ts +257 -0
- package/src/core/commands/formatting-commands.ts +2 -0
- package/src/core/schema/text-schema.ts +95 -1
- package/src/core/state/text-transaction.ts +17 -5
- package/src/io/chart-preview-resolver.ts +27 -0
- package/src/io/docx-session.ts +226 -38
- package/src/io/export/serialize-main-document.ts +37 -0
- package/src/io/export/serialize-settings.ts +421 -0
- package/src/io/export/serialize-styles.ts +10 -0
- package/src/io/normalize/normalize-text.ts +1 -0
- package/src/io/ooxml/chart/parse-axis.ts +277 -0
- package/src/io/ooxml/chart/parse-chart-space.ts +813 -0
- package/src/io/ooxml/chart/parse-series.ts +570 -0
- package/src/io/ooxml/chart/resolve-color.ts +251 -0
- package/src/io/ooxml/chart/types.ts +420 -0
- package/src/io/ooxml/parse-block-structure.ts +99 -0
- package/src/io/ooxml/parse-complex-content.ts +87 -2
- package/src/io/ooxml/parse-main-document.ts +115 -1
- package/src/io/ooxml/parse-scope-markers.ts +184 -0
- package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
- package/src/io/ooxml/parse-settings.ts +97 -1
- package/src/io/ooxml/parse-styles.ts +65 -0
- package/src/io/ooxml/parse-theme.ts +2 -127
- package/src/io/ooxml/xml-attr-helpers.ts +59 -1
- package/src/io/ooxml/xml-parser.ts +142 -0
- package/src/model/canonical-document.ts +94 -0
- package/src/model/scope-markers.ts +144 -0
- package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
- package/src/runtime/collab/checkpoint-election.ts +75 -0
- package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
- package/src/runtime/collab/checkpoint-store.ts +115 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab/index.ts +22 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
- package/src/runtime/collab/runtime-collab-sync.ts +279 -0
- package/src/runtime/document-runtime.ts +214 -16
- package/src/runtime/editor-surface/capabilities.ts +63 -50
- package/src/runtime/layout/layout-engine-version.ts +8 -1
- package/src/runtime/prerender/cache-envelope.ts +19 -7
- package/src/runtime/prerender/cache-key.ts +25 -14
- package/src/runtime/prerender/canonical-document-hash.ts +63 -0
- package/src/runtime/prerender/customxml-cache.ts +211 -0
- package/src/runtime/prerender/customxml-probe.ts +78 -0
- package/src/runtime/prerender/prerender-document.ts +74 -7
- package/src/runtime/scope-resolver.ts +148 -0
- package/src/runtime/scope-tag-registry.ts +10 -0
- package/src/runtime/surface-projection.ts +8 -1
- package/src/ui/WordReviewEditor.tsx +30 -0
- package/src/ui/editor-runtime-boundary.ts +6 -1
- package/src/ui/runtime-shortcut-dispatch.ts +12 -7
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decompose a `settings.xml` document into a verbatim-preserving blueprint
|
|
3
|
+
* so the export-side serializer can perform graft mode: replace modelled
|
|
4
|
+
* top-level children with re-emitted XML while leaving every unmodelled
|
|
5
|
+
* child (`<w:defaultTabStop>`, `<w:characterSpacingControl>`,
|
|
6
|
+
* `<w:documentProtection>`, mail-merge state, etc.) byte-identical to the
|
|
7
|
+
* source.
|
|
8
|
+
*
|
|
9
|
+
* This is intentionally a separate, narrower scanner from the canonical
|
|
10
|
+
* parser at `parse-settings.ts`. The canonical parser throws away raw text;
|
|
11
|
+
* this scanner keeps every byte. The two run independently — neither
|
|
12
|
+
* affects the other — because the blueprint is a serializer-side concern.
|
|
13
|
+
*
|
|
14
|
+
* Authority: ECMA-376 §17.15 (settings.xml schema). Comments and the XML
|
|
15
|
+
* declaration are preserved as part of the prelude / interstitial strings,
|
|
16
|
+
* so a no-edit graft round-trips byte-identically to the source.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface SettingsBlueprintChild {
|
|
20
|
+
/**
|
|
21
|
+
* Local name of the top-level child (e.g. "compat", "compatSetting",
|
|
22
|
+
* "themeFontLang", "defaultTabStop").
|
|
23
|
+
*/
|
|
24
|
+
localName: string;
|
|
25
|
+
/**
|
|
26
|
+
* Verbatim XML for the child element including its full content (for
|
|
27
|
+
* non-self-closing elements). Does NOT include any leading/trailing
|
|
28
|
+
* whitespace — that lives on `interstitialBefore`.
|
|
29
|
+
*/
|
|
30
|
+
rawXml: string;
|
|
31
|
+
/**
|
|
32
|
+
* Whitespace + comments that appear between the previous boundary (the
|
|
33
|
+
* settings open tag for the first child, the previous child's `rawXml`
|
|
34
|
+
* end for subsequent children) and the start of this child's `rawXml`.
|
|
35
|
+
* The serializer must re-emit this verbatim before each child.
|
|
36
|
+
*/
|
|
37
|
+
interstitialBefore: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SettingsBlueprint {
|
|
41
|
+
/**
|
|
42
|
+
* Everything before the `<w:settings>` opening tag — the XML declaration
|
|
43
|
+
* if present plus any leading whitespace. Empty string when neither is
|
|
44
|
+
* present.
|
|
45
|
+
*/
|
|
46
|
+
prelude: string;
|
|
47
|
+
/**
|
|
48
|
+
* The `<w:settings ...>` opening tag verbatim, including every xmlns
|
|
49
|
+
* declaration and any other root attributes. If the source uses a
|
|
50
|
+
* self-closing form (`<w:settings ... />`), this captures the full
|
|
51
|
+
* self-closing tag and `settingsCloseTag` is the empty string.
|
|
52
|
+
*/
|
|
53
|
+
settingsOpenTag: string;
|
|
54
|
+
/**
|
|
55
|
+
* Top-level children of `<w:settings>` in document order with their
|
|
56
|
+
* verbatim raw XML and the interstitial whitespace/comments before each.
|
|
57
|
+
*/
|
|
58
|
+
topLevelChildren: SettingsBlueprintChild[];
|
|
59
|
+
/**
|
|
60
|
+
* Whitespace + comments between the last child's `rawXml` end and the
|
|
61
|
+
* `</w:settings>` closing tag. The serializer must re-emit this verbatim
|
|
62
|
+
* after the last child.
|
|
63
|
+
*/
|
|
64
|
+
trailingWhitespace: string;
|
|
65
|
+
/**
|
|
66
|
+
* The `</w:settings>` closing tag verbatim. Empty string when the source
|
|
67
|
+
* used a self-closing `<w:settings/>` form.
|
|
68
|
+
*/
|
|
69
|
+
settingsCloseTag: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Scan a settings.xml document and decompose it into a verbatim-preserving
|
|
74
|
+
* blueprint. Throws if the document does not contain a `<w:settings>`
|
|
75
|
+
* root element.
|
|
76
|
+
*/
|
|
77
|
+
export function parseSettingsBlueprint(xml: string): SettingsBlueprint {
|
|
78
|
+
const settingsTagStart = findSettingsOpenTagStart(xml);
|
|
79
|
+
if (settingsTagStart < 0) {
|
|
80
|
+
throw new Error("parseSettingsBlueprint: no <w:settings> element found");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const prelude = xml.slice(0, settingsTagStart);
|
|
84
|
+
const settingsTagEnd = findTagEnd(xml, settingsTagStart);
|
|
85
|
+
if (settingsTagEnd < 0) {
|
|
86
|
+
throw new Error("parseSettingsBlueprint: unterminated <w:settings> tag");
|
|
87
|
+
}
|
|
88
|
+
const settingsOpenTag = xml.slice(settingsTagStart, settingsTagEnd + 1);
|
|
89
|
+
const isSelfClosing = settingsOpenTag.endsWith("/>");
|
|
90
|
+
|
|
91
|
+
if (isSelfClosing) {
|
|
92
|
+
return {
|
|
93
|
+
prelude,
|
|
94
|
+
settingsOpenTag,
|
|
95
|
+
topLevelChildren: [],
|
|
96
|
+
trailingWhitespace: "",
|
|
97
|
+
settingsCloseTag: "",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Walk children inside <w:settings>...</w:settings>.
|
|
102
|
+
const closeTagInfo = findSettingsCloseTag(xml, settingsTagEnd + 1);
|
|
103
|
+
if (!closeTagInfo) {
|
|
104
|
+
throw new Error("parseSettingsBlueprint: missing </w:settings> closing tag");
|
|
105
|
+
}
|
|
106
|
+
const innerStart = settingsTagEnd + 1;
|
|
107
|
+
const innerEnd = closeTagInfo.start;
|
|
108
|
+
|
|
109
|
+
const { children, trailing } = scanTopLevelChildren(xml, innerStart, innerEnd);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
prelude,
|
|
113
|
+
settingsOpenTag,
|
|
114
|
+
topLevelChildren: children,
|
|
115
|
+
trailingWhitespace: trailing,
|
|
116
|
+
settingsCloseTag: closeTagInfo.tag,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function findSettingsOpenTagStart(xml: string): number {
|
|
121
|
+
// Scan past <?xml ... ?> and <!-- comments --> until we hit a < that
|
|
122
|
+
// begins an element. Then verify that element is <w:settings.
|
|
123
|
+
let cursor = 0;
|
|
124
|
+
while (cursor < xml.length) {
|
|
125
|
+
if (xml.startsWith("<?", cursor)) {
|
|
126
|
+
const end = xml.indexOf("?>", cursor);
|
|
127
|
+
if (end < 0) return -1;
|
|
128
|
+
cursor = end + 2;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (xml.startsWith("<!--", cursor)) {
|
|
132
|
+
const end = xml.indexOf("-->", cursor);
|
|
133
|
+
if (end < 0) return -1;
|
|
134
|
+
cursor = end + 3;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const lt = xml.indexOf("<", cursor);
|
|
138
|
+
if (lt < 0) return -1;
|
|
139
|
+
if (xml.startsWith("<?", lt) || xml.startsWith("<!--", lt)) {
|
|
140
|
+
cursor = lt;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// First real element. Confirm it's <w:settings (or fallback `settings`).
|
|
144
|
+
if (xml.startsWith("<w:settings", lt) || xml.startsWith("<settings", lt)) {
|
|
145
|
+
// Verify the next char is whitespace, '>', or '/' — not a longer name like <w:settingsExtra.
|
|
146
|
+
const after = lt + (xml.startsWith("<w:settings", lt) ? "<w:settings".length : "<settings".length);
|
|
147
|
+
const ch = xml[after];
|
|
148
|
+
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === ">" || ch === "/") {
|
|
149
|
+
return lt;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return -1;
|
|
153
|
+
}
|
|
154
|
+
return -1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function findTagEnd(xml: string, tagStart: number): number {
|
|
158
|
+
// Skip until we find the closing > that terminates THIS tag.
|
|
159
|
+
// Attribute values cannot contain unescaped '>' so a naive scan works.
|
|
160
|
+
for (let i = tagStart; i < xml.length; i++) {
|
|
161
|
+
if (xml[i] === ">") return i;
|
|
162
|
+
}
|
|
163
|
+
return -1;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function findSettingsCloseTag(
|
|
167
|
+
xml: string,
|
|
168
|
+
startFrom: number,
|
|
169
|
+
): { start: number; tag: string } | null {
|
|
170
|
+
// Find the LAST </w:settings> (or </settings>) — the document body cannot
|
|
171
|
+
// legally contain a nested settings element, so the first occurrence is
|
|
172
|
+
// also the last; we still scan to end-of-string defensively.
|
|
173
|
+
const candidates: Array<"</w:settings>" | "</settings>"> = [
|
|
174
|
+
"</w:settings>",
|
|
175
|
+
"</settings>",
|
|
176
|
+
];
|
|
177
|
+
let bestIdx = -1;
|
|
178
|
+
let bestTag = "";
|
|
179
|
+
for (const candidate of candidates) {
|
|
180
|
+
const idx = xml.indexOf(candidate, startFrom);
|
|
181
|
+
if (idx >= 0 && (bestIdx < 0 || idx < bestIdx)) {
|
|
182
|
+
bestIdx = idx;
|
|
183
|
+
bestTag = candidate;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (bestIdx < 0) return null;
|
|
187
|
+
return { start: bestIdx, tag: bestTag };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function scanTopLevelChildren(
|
|
191
|
+
xml: string,
|
|
192
|
+
innerStart: number,
|
|
193
|
+
innerEnd: number,
|
|
194
|
+
): { children: SettingsBlueprintChild[]; trailing: string } {
|
|
195
|
+
const children: SettingsBlueprintChild[] = [];
|
|
196
|
+
let cursor = innerStart;
|
|
197
|
+
|
|
198
|
+
while (cursor < innerEnd) {
|
|
199
|
+
// Capture interstitial: whitespace + comments + processing instructions
|
|
200
|
+
// until the next element start.
|
|
201
|
+
const interstitialStart = cursor;
|
|
202
|
+
cursor = skipInterstitial(xml, cursor, innerEnd);
|
|
203
|
+
const interstitial = xml.slice(interstitialStart, cursor);
|
|
204
|
+
|
|
205
|
+
if (cursor >= innerEnd) {
|
|
206
|
+
// Pure trailing whitespace — no more children.
|
|
207
|
+
return { children, trailing: interstitial };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (xml[cursor] !== "<") {
|
|
211
|
+
// Bare text content at the top level isn't legal in settings.xml;
|
|
212
|
+
// surface it via interstitial-as-trailing and stop.
|
|
213
|
+
return { children, trailing: interstitial + xml.slice(cursor, innerEnd) };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Begin scanning an element.
|
|
217
|
+
const elementStart = cursor;
|
|
218
|
+
const tagEnd = findTagEnd(xml, elementStart);
|
|
219
|
+
if (tagEnd < 0 || tagEnd >= innerEnd) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
"parseSettingsBlueprint: unterminated tag inside <w:settings>",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
const openTag = xml.slice(elementStart, tagEnd + 1);
|
|
225
|
+
const localName = readLocalNameFromOpenTag(openTag);
|
|
226
|
+
|
|
227
|
+
let elementEnd: number;
|
|
228
|
+
if (openTag.endsWith("/>")) {
|
|
229
|
+
// Self-closing.
|
|
230
|
+
elementEnd = tagEnd + 1;
|
|
231
|
+
} else {
|
|
232
|
+
// Find matching closing tag, accounting for nested same-named elements.
|
|
233
|
+
const closingPattern = `</${getQualifiedName(openTag)}>`;
|
|
234
|
+
const matchEnd = findMatchingClose(
|
|
235
|
+
xml,
|
|
236
|
+
tagEnd + 1,
|
|
237
|
+
innerEnd,
|
|
238
|
+
getQualifiedName(openTag),
|
|
239
|
+
);
|
|
240
|
+
if (matchEnd < 0) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`parseSettingsBlueprint: missing closing ${closingPattern}`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
elementEnd = matchEnd;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const rawXml = xml.slice(elementStart, elementEnd);
|
|
249
|
+
children.push({ localName, rawXml, interstitialBefore: interstitial });
|
|
250
|
+
cursor = elementEnd;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { children, trailing: "" };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function skipInterstitial(xml: string, from: number, end: number): number {
|
|
257
|
+
let cursor = from;
|
|
258
|
+
while (cursor < end) {
|
|
259
|
+
if (xml.startsWith("<!--", cursor)) {
|
|
260
|
+
const stop = xml.indexOf("-->", cursor);
|
|
261
|
+
if (stop < 0 || stop + 3 > end) return cursor;
|
|
262
|
+
cursor = stop + 3;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (xml.startsWith("<?", cursor)) {
|
|
266
|
+
const stop = xml.indexOf("?>", cursor);
|
|
267
|
+
if (stop < 0 || stop + 2 > end) return cursor;
|
|
268
|
+
cursor = stop + 2;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const ch = xml[cursor];
|
|
272
|
+
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
|
|
273
|
+
cursor++;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
return cursor;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function readLocalNameFromOpenTag(tag: string): string {
|
|
282
|
+
// tag looks like '<w:foo attr="bar"/>' or '<foo>' or '<w:foo>'.
|
|
283
|
+
const inside = tag.slice(1, tag.endsWith("/>") ? -2 : -1).trim();
|
|
284
|
+
const space = inside.search(/\s/u);
|
|
285
|
+
const qualified = space < 0 ? inside : inside.slice(0, space);
|
|
286
|
+
const colon = qualified.indexOf(":");
|
|
287
|
+
return colon < 0 ? qualified : qualified.slice(colon + 1);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function getQualifiedName(tag: string): string {
|
|
291
|
+
const inside = tag.slice(1, tag.endsWith("/>") ? -2 : -1).trim();
|
|
292
|
+
const space = inside.search(/\s/u);
|
|
293
|
+
return space < 0 ? inside : inside.slice(0, space);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function findMatchingClose(
|
|
297
|
+
xml: string,
|
|
298
|
+
from: number,
|
|
299
|
+
end: number,
|
|
300
|
+
qualifiedName: string,
|
|
301
|
+
): number {
|
|
302
|
+
// Walk forward, tracking nesting depth for elements with the same
|
|
303
|
+
// qualifiedName so nested same-name elements don't terminate early.
|
|
304
|
+
const openPattern = `<${qualifiedName}`;
|
|
305
|
+
const closePattern = `</${qualifiedName}>`;
|
|
306
|
+
let cursor = from;
|
|
307
|
+
let depth = 1;
|
|
308
|
+
while (cursor < end) {
|
|
309
|
+
// Skip comments + PIs so a '<' inside a comment doesn't count.
|
|
310
|
+
if (xml.startsWith("<!--", cursor)) {
|
|
311
|
+
const stop = xml.indexOf("-->", cursor);
|
|
312
|
+
if (stop < 0 || stop + 3 > end) return -1;
|
|
313
|
+
cursor = stop + 3;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (xml.startsWith("<?", cursor)) {
|
|
317
|
+
const stop = xml.indexOf("?>", cursor);
|
|
318
|
+
if (stop < 0 || stop + 2 > end) return -1;
|
|
319
|
+
cursor = stop + 2;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (xml.startsWith(closePattern, cursor)) {
|
|
323
|
+
depth--;
|
|
324
|
+
if (depth === 0) {
|
|
325
|
+
return cursor + closePattern.length;
|
|
326
|
+
}
|
|
327
|
+
cursor += closePattern.length;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (xml.startsWith(openPattern, cursor)) {
|
|
331
|
+
// Verify the next char makes this a real same-name open tag (not e.g.
|
|
332
|
+
// <w:compatSetting when looking for <w:compat).
|
|
333
|
+
const after = cursor + openPattern.length;
|
|
334
|
+
const ch = xml[after];
|
|
335
|
+
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === ">" || ch === "/") {
|
|
336
|
+
// Find tag end to know whether it's self-closing.
|
|
337
|
+
const tagEnd = findTagEnd(xml, cursor);
|
|
338
|
+
if (tagEnd < 0 || tagEnd >= end) return -1;
|
|
339
|
+
if (xml[tagEnd - 1] !== "/") {
|
|
340
|
+
depth++;
|
|
341
|
+
}
|
|
342
|
+
cursor = tagEnd + 1;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
cursor++;
|
|
347
|
+
}
|
|
348
|
+
return -1;
|
|
349
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
CompatSetting,
|
|
3
|
+
DocumentSettings,
|
|
4
|
+
} from "../../model/canonical-document.ts";
|
|
2
5
|
|
|
3
6
|
interface XmlElementNode {
|
|
4
7
|
type: "element";
|
|
@@ -27,6 +30,11 @@ export function parseSettingsXml(xml: string): DocumentSettings {
|
|
|
27
30
|
|
|
28
31
|
const evenAndOddHeaders = findChildElementOptional(settingsElement, "evenAndOddHeaders");
|
|
29
32
|
const zoom = findChildElementOptional(settingsElement, "zoom");
|
|
33
|
+
const compat = findChildElementOptional(settingsElement, "compat");
|
|
34
|
+
const compatPartition = compat ? partitionCompat(compat) : undefined;
|
|
35
|
+
const rootCompatFlags = readRootCompatFlags(settingsElement);
|
|
36
|
+
const themeFontLangElement = findChildElementOptional(settingsElement, "themeFontLang");
|
|
37
|
+
const unmodelled = readUnmodelledSettingsChildren(settingsElement);
|
|
30
38
|
|
|
31
39
|
return {
|
|
32
40
|
...(evenAndOddHeaders
|
|
@@ -35,9 +43,97 @@ export function parseSettingsXml(xml: string): DocumentSettings {
|
|
|
35
43
|
}
|
|
36
44
|
: {}),
|
|
37
45
|
...(zoom ? readZoomLevel(zoom) : {}),
|
|
46
|
+
...(compatPartition && compatPartition.compatSettings.length > 0
|
|
47
|
+
? { compatSettings: compatPartition.compatSettings }
|
|
48
|
+
: {}),
|
|
49
|
+
...(compatPartition && Object.keys(compatPartition.compatFlags).length > 0
|
|
50
|
+
? { compatFlags: compatPartition.compatFlags }
|
|
51
|
+
: {}),
|
|
52
|
+
...(Object.keys(rootCompatFlags).length > 0 ? { rootCompatFlags } : {}),
|
|
53
|
+
...(themeFontLangElement
|
|
54
|
+
? { themeFontLang: { ...themeFontLangElement.attributes } }
|
|
55
|
+
: {}),
|
|
56
|
+
...(unmodelled.length > 0 ? { unmodelledSettingsChildren: unmodelled } : {}),
|
|
38
57
|
};
|
|
39
58
|
}
|
|
40
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Modelled top-level <w:settings> child local names. Anything not in this
|
|
62
|
+
* set (and not in ROOT_COMPAT_FLAG_NAMES) ends up in
|
|
63
|
+
* `unmodelledSettingsChildren` for the Phase 2 serializer to validate.
|
|
64
|
+
*/
|
|
65
|
+
const MODELLED_SETTINGS_CHILD_NAMES = new Set<string>([
|
|
66
|
+
"evenAndOddHeaders",
|
|
67
|
+
"zoom",
|
|
68
|
+
"compat",
|
|
69
|
+
"themeFontLang",
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
function readUnmodelledSettingsChildren(
|
|
73
|
+
settingsElement: XmlElementNode,
|
|
74
|
+
): string[] {
|
|
75
|
+
const names: string[] = [];
|
|
76
|
+
for (const child of settingsElement.children) {
|
|
77
|
+
if (child.type !== "element") continue;
|
|
78
|
+
const local = localName(child.name);
|
|
79
|
+
if (MODELLED_SETTINGS_CHILD_NAMES.has(local)) continue;
|
|
80
|
+
if (ROOT_COMPAT_FLAG_NAMES.has(local)) continue;
|
|
81
|
+
names.push(local);
|
|
82
|
+
}
|
|
83
|
+
return names;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Settings-level compat-adjacent flag elements (NOT inside <w:compat>) that
|
|
88
|
+
* the strict OpenXML SDK validator flags. Kept as a small allow-list; extend
|
|
89
|
+
* as the corpus reveals more.
|
|
90
|
+
*
|
|
91
|
+
* Exported because the export-side graft serializer
|
|
92
|
+
* (`src/io/export/serialize-settings.ts`) needs the same allow-list to know
|
|
93
|
+
* which top-level source children are "modelled" and therefore subject to
|
|
94
|
+
* canonical replacement.
|
|
95
|
+
*/
|
|
96
|
+
export const ROOT_COMPAT_FLAG_NAMES: ReadonlySet<string> = new Set<string>([
|
|
97
|
+
"doNotEmbedSmartTags",
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
function readRootCompatFlags(
|
|
101
|
+
settingsElement: XmlElementNode,
|
|
102
|
+
): Record<string, boolean> {
|
|
103
|
+
const flags: Record<string, boolean> = {};
|
|
104
|
+
for (const child of settingsElement.children) {
|
|
105
|
+
if (child.type !== "element") continue;
|
|
106
|
+
const local = localName(child.name);
|
|
107
|
+
if (!ROOT_COMPAT_FLAG_NAMES.has(local)) continue;
|
|
108
|
+
flags[local] = readOnOffValue(child, true);
|
|
109
|
+
}
|
|
110
|
+
return flags;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface CompatPartition {
|
|
114
|
+
compatSettings: CompatSetting[];
|
|
115
|
+
compatFlags: Record<string, boolean>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function partitionCompat(compatElement: XmlElementNode): CompatPartition {
|
|
119
|
+
const compatSettings: CompatSetting[] = [];
|
|
120
|
+
const compatFlags: Record<string, boolean> = {};
|
|
121
|
+
for (const child of compatElement.children) {
|
|
122
|
+
if (child.type !== "element") continue;
|
|
123
|
+
const local = localName(child.name);
|
|
124
|
+
if (local === "compatSetting") {
|
|
125
|
+
compatSettings.push({
|
|
126
|
+
name: child.attributes["w:name"] ?? child.attributes.name ?? "",
|
|
127
|
+
uri: child.attributes["w:uri"] ?? child.attributes.uri ?? "",
|
|
128
|
+
value: child.attributes["w:val"] ?? child.attributes.val ?? "",
|
|
129
|
+
});
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
compatFlags[local] = readOnOffValue(child, true);
|
|
133
|
+
}
|
|
134
|
+
return { compatSettings, compatFlags };
|
|
135
|
+
}
|
|
136
|
+
|
|
41
137
|
function findChildElementOptional(
|
|
42
138
|
node: XmlElementNode,
|
|
43
139
|
childLocalName: string,
|
|
@@ -153,6 +153,7 @@ export function parseStylesXml(xml: string): ParseStylesResult {
|
|
|
153
153
|
switch (styleType) {
|
|
154
154
|
case "paragraph": {
|
|
155
155
|
const nextStyle = readLinkedStyleId(child, "next");
|
|
156
|
+
const linkedStyleId = readLinkedStyleId(child, "link");
|
|
156
157
|
const outlineLevel = readParagraphStyleOutlineLevel(child);
|
|
157
158
|
const numbering = readParagraphStyleNumbering(child);
|
|
158
159
|
const pPrNode = findChildElementOptional(child, "pPr");
|
|
@@ -170,10 +171,12 @@ export function parseStylesXml(xml: string): ParseStylesResult {
|
|
|
170
171
|
...(numbering ? { numbering } : {}),
|
|
171
172
|
...(paragraphProperties ? { paragraphProperties } : {}),
|
|
172
173
|
...(runProperties ? { runProperties } : {}),
|
|
174
|
+
...(linkedStyleId ? { linkedStyleId } : {}),
|
|
173
175
|
};
|
|
174
176
|
break;
|
|
175
177
|
}
|
|
176
178
|
case "character": {
|
|
179
|
+
const linkedStyleId = readLinkedStyleId(child, "link");
|
|
177
180
|
const rPrNode = findChildElementOptional(child, "rPr");
|
|
178
181
|
const runProperties = readRunProperties(rPrNode);
|
|
179
182
|
characters[styleId] = {
|
|
@@ -183,6 +186,7 @@ export function parseStylesXml(xml: string): ParseStylesResult {
|
|
|
183
186
|
isDefault,
|
|
184
187
|
...(basedOn ? { basedOn } : {}),
|
|
185
188
|
...(runProperties ? { runProperties } : {}),
|
|
189
|
+
...(linkedStyleId ? { linkedStyleId } : {}),
|
|
186
190
|
};
|
|
187
191
|
break;
|
|
188
192
|
}
|
|
@@ -209,6 +213,8 @@ export function parseStylesXml(xml: string): ParseStylesResult {
|
|
|
209
213
|
}
|
|
210
214
|
}
|
|
211
215
|
|
|
216
|
+
resolveStyleLinkReciprocals(paragraphs, characters, diagnostics);
|
|
217
|
+
|
|
212
218
|
const hasLatent = Object.keys(latentStyles).length > 0;
|
|
213
219
|
diagnostics.push(
|
|
214
220
|
`parsed ${Object.keys(paragraphs).length} paragraph, ` +
|
|
@@ -250,6 +256,65 @@ function readLinkedStyleId(
|
|
|
250
256
|
return el.attributes["w:val"] ?? el.attributes.val ?? undefined;
|
|
251
257
|
}
|
|
252
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Second-pass resolver for `<w:link>` on paragraph ↔ character style pairs.
|
|
261
|
+
*
|
|
262
|
+
* Mirrors LibreOffice's StyleSheetTable.cxx around line 1533 ("Update the
|
|
263
|
+
* styles that were created before their linked styles"): after every style
|
|
264
|
+
* is ingested, walk the two catalogs and synthesize the reciprocal
|
|
265
|
+
* `linkedStyleId` on a partner whose source XML declared no `<w:link>` of
|
|
266
|
+
* its own. This makes the canonical catalog symmetric regardless of source
|
|
267
|
+
* declaration order.
|
|
268
|
+
*
|
|
269
|
+
* Conflict handling is conservative: if a style already declares its own
|
|
270
|
+
* linkedStyleId pointing at a different target, the existing value is left
|
|
271
|
+
* intact and a diagnostic is emitted so a future debugger can trace the
|
|
272
|
+
* conflicting source-side assertions.
|
|
273
|
+
*
|
|
274
|
+
* Dangling references (a `<w:link>` that points at a styleId not in either
|
|
275
|
+
* catalog) are preserved verbatim and logged as a diagnostic — matching
|
|
276
|
+
* LibreOffice's "keep the XML, warn the author" stance.
|
|
277
|
+
*/
|
|
278
|
+
function resolveStyleLinkReciprocals(
|
|
279
|
+
paragraphs: Record<string, ParagraphStyleDefinition>,
|
|
280
|
+
characters: Record<string, CharacterStyleDefinition>,
|
|
281
|
+
diagnostics: string[],
|
|
282
|
+
): void {
|
|
283
|
+
const walkers: Array<{
|
|
284
|
+
catalog:
|
|
285
|
+
| Record<string, ParagraphStyleDefinition>
|
|
286
|
+
| Record<string, CharacterStyleDefinition>;
|
|
287
|
+
partnerCatalog:
|
|
288
|
+
| Record<string, ParagraphStyleDefinition>
|
|
289
|
+
| Record<string, CharacterStyleDefinition>;
|
|
290
|
+
label: string;
|
|
291
|
+
}> = [
|
|
292
|
+
{ catalog: paragraphs, partnerCatalog: characters, label: "paragraph" },
|
|
293
|
+
{ catalog: characters, partnerCatalog: paragraphs, label: "character" },
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
for (const { catalog, partnerCatalog, label } of walkers) {
|
|
297
|
+
for (const style of Object.values(catalog)) {
|
|
298
|
+
const target = style.linkedStyleId;
|
|
299
|
+
if (!target) continue;
|
|
300
|
+
const partner = partnerCatalog[target];
|
|
301
|
+
if (!partner) {
|
|
302
|
+
diagnostics.push(
|
|
303
|
+
`style ${label} "${style.styleId}" declares <w:link w:val="${target}"/> but no matching ${label === "paragraph" ? "character" : "paragraph"} style was found; link preserved as dangling`,
|
|
304
|
+
);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (partner.linkedStyleId === undefined) {
|
|
308
|
+
partner.linkedStyleId = style.styleId;
|
|
309
|
+
} else if (partner.linkedStyleId !== style.styleId) {
|
|
310
|
+
diagnostics.push(
|
|
311
|
+
`style ${label} "${style.styleId}" links to "${target}" but partner already links to "${partner.linkedStyleId}"; partner link retained`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
253
318
|
function readParagraphStyleOutlineLevel(
|
|
254
319
|
styleNode: XmlElementNode,
|
|
255
320
|
): number | undefined {
|