@stll/folio-core 0.48.0 → 0.49.0
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/dist/ai-edits/apply.js +565 -121
- package/dist/ai-edits/minimal-replacement.d.ts +64 -0
- package/dist/ai-edits/minimal-replacement.js +282 -0
- package/dist/controller/layoutPipeline.js +11 -1
- package/dist/display-list/build/paragraphPrimitives.js +63 -7
- package/dist/display-list/build/textBoxPrimitives.js +1 -1
- package/dist/display-list/build/unsupported.d.ts +2 -2
- package/dist/display-list/build/unsupported.js +0 -0
- package/dist/display-list/dom/renderDisplayListToDom.js +0 -1
- package/dist/docx/blockContentParser.js +2 -2
- package/dist/docx/bulletMarkers.d.ts +18 -2
- package/dist/docx/bulletMarkers.js +27 -2
- package/dist/docx/drawingGroupChildren.d.ts +60 -0
- package/dist/docx/drawingGroupChildren.js +176 -0
- package/dist/docx/drawingUtils.js +3 -0
- package/dist/docx/groupDrawingParser.d.ts +7 -1
- package/dist/docx/groupDrawingParser.js +123 -32
- package/dist/docx/packageParts.d.ts +45 -1
- package/dist/docx/packageParts.js +93 -2
- package/dist/docx/paraIdAttribute.d.ts +3 -2
- package/dist/docx/paragraphParser.js +91 -7
- package/dist/docx/paragraphTextBoxEnrichment.js +126 -4
- package/dist/docx/runParser.js +10 -0
- package/dist/docx/selectiveSaveFlags.d.ts +10 -3
- package/dist/docx/selectiveSaveFlags.js +1 -1
- package/dist/docx/selectiveXmlPatch.d.ts +56 -34
- package/dist/docx/selectiveXmlPatch.js +222 -115
- package/dist/docx/serializer/groupTextBoxWriteBack.d.ts +12 -0
- package/dist/docx/serializer/groupTextBoxWriteBack.js +101 -0
- package/dist/docx/serializer/paragraphSerializer.js +5 -4
- package/dist/docx/serializer/runSerializer.d.ts +5 -1
- package/dist/docx/serializer/runSerializer.js +4 -3
- package/dist/docx/shapeAlternateContent.d.ts +25 -0
- package/dist/docx/shapeAlternateContent.js +52 -0
- package/dist/docx/textBoxParser.d.ts +10 -2
- package/dist/docx/textBoxParser.js +9 -3
- package/dist/internal/headlessRevisionResolution.js +50 -13
- package/dist/layout-bridge/convert/toFlowBlocks.js +26 -4
- package/dist/layout-engine/index.d.ts +2 -2
- package/dist/layout-engine/index.js +33 -4
- package/dist/layout-engine/keep-together.d.ts +5 -2
- package/dist/layout-engine/keep-together.js +5 -1
- package/dist/layout-engine/measure/advanceComposition.js +49 -3
- package/dist/layout-engine/measure/measureBlocks.js +5 -3
- package/dist/layout-engine/measure/measureContainer.js +46 -3
- package/dist/layout-engine/measure/measureHelpers.js +13 -3
- package/dist/layout-engine/measure/measureParagraph.js +44 -81
- package/dist/layout-engine/measure/smallCapsCasing.d.ts +56 -0
- package/dist/layout-engine/measure/smallCapsCasing.js +88 -0
- package/dist/layout-engine/paginator.d.ts +2 -0
- package/dist/layout-engine/paginator.js +5 -1
- package/dist/layout-engine/paragraphSpacing.d.ts +4 -2
- package/dist/layout-engine/paragraphSpacing.js +2 -1
- package/dist/layout-engine/tableRowBreak.d.ts +8 -1
- package/dist/layout-engine/tableRowBreak.js +22 -1
- package/dist/layout-engine/types.d.ts +21 -2
- package/dist/layout-painter/renderParagraph.js +41 -4
- package/dist/layout-painter/renderTextBox.d.ts +12 -2
- package/dist/layout-painter/renderTextBox.js +26 -2
- package/dist/prosemirror/alternateContentAttrs.d.ts +9 -0
- package/dist/prosemirror/alternateContentAttrs.js +35 -0
- package/dist/prosemirror/attrs/index.js +90 -0
- package/dist/prosemirror/commands/comments.js +51 -41
- package/dist/prosemirror/contentControlRevisions.d.ts +43 -0
- package/dist/prosemirror/contentControlRevisions.js +84 -0
- package/dist/prosemirror/conversion/fromProseDoc.js +46 -22
- package/dist/prosemirror/conversion/toProseDoc.js +42 -19
- package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.d.ts +7 -7
- package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.js +7 -7
- package/dist/prosemirror/extensions/nodes/FieldExtension.js +6 -3
- package/dist/prosemirror/extensions/nodes/SdtExtension.js +9 -2
- package/dist/prosemirror/extensions/nodes/ShapeExtension.js +1 -0
- package/dist/prosemirror/extensions/nodes/TextBoxExtension.js +3 -0
- package/dist/prosemirror/listMarker.js +2 -2
- package/dist/prosemirror/paragraphDirection.d.ts +29 -2
- package/dist/prosemirror/paragraphDirection.js +21 -2
- package/dist/prosemirror/plugins/suggestionMode.js +37 -8
- package/dist/prosemirror/rejoinRunCarriers.d.ts +27 -0
- package/dist/prosemirror/rejoinRunCarriers.js +66 -0
- package/dist/prosemirror/runIdentityAcrossRevisions.d.ts +10 -0
- package/dist/prosemirror/runIdentityAcrossRevisions.js +90 -0
- package/dist/prosemirror/schema/nodes.d.ts +37 -5
- package/dist/types/content.d.ts +2 -2
- package/package.json +2 -2
- package/dist/layout-engine/justifiedLineFit.d.ts +0 -7
- package/dist/layout-engine/justifiedLineFit.js +0 -6
|
@@ -108,24 +108,53 @@ function buildParagraphOffsetIndex(xml) {
|
|
|
108
108
|
for (const [id, range] of ranges) if (seenCount.get(id) === 1) index.set(id, range);
|
|
109
109
|
return index;
|
|
110
110
|
}
|
|
111
|
+
/** The containers {@link scanParagraphs} tracks, by the literal tag the part writes. */
|
|
112
|
+
const TRACKED_CONTAINERS = [
|
|
113
|
+
{
|
|
114
|
+
name: "mc:Fallback",
|
|
115
|
+
key: "fallback"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: "mc:AlternateContent",
|
|
119
|
+
key: "alternateContent"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "w:txbxContent",
|
|
123
|
+
key: "textBox"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: "w:tc",
|
|
127
|
+
key: "tableCell"
|
|
128
|
+
}
|
|
129
|
+
].map(({ name, key }) => ({
|
|
130
|
+
key,
|
|
131
|
+
open: `<${name}`,
|
|
132
|
+
close: `</${name}>`
|
|
133
|
+
}));
|
|
111
134
|
/**
|
|
112
135
|
* Every `<w:p>` element of `xml`, in the document order of its opening tags,
|
|
113
|
-
* with the `w14:paraId` that tag carries.
|
|
136
|
+
* with the `w14:paraId` that tag carries and the containers around it.
|
|
114
137
|
*
|
|
115
|
-
* Document order is what makes the array an ordinal space:
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* {@link countParagraphElements} counts it
|
|
121
|
-
*
|
|
122
|
-
*
|
|
138
|
+
* Document order is what makes the array an ordinal space: within one story
|
|
139
|
+
* (see {@link ParagraphContainer}), the *n*th paragraph of the source part and
|
|
140
|
+
* the *n*th paragraph of the model's serialization name the same paragraph,
|
|
141
|
+
* which is the only way to address a paragraph the producer gave no id. A
|
|
142
|
+
* `<w:p>` nested inside another (inside `mc:AlternateContent`, a text box) is
|
|
143
|
+
* one entry of its own, exactly as {@link countParagraphElements} counts it.
|
|
144
|
+
* An unterminated paragraph keeps `end === start`: it occupies its ordinal but
|
|
145
|
+
* no splice can be built from it.
|
|
123
146
|
*/
|
|
124
147
|
function scanParagraphs(xml) {
|
|
125
148
|
const paragraphs = [];
|
|
126
149
|
const open = [];
|
|
150
|
+
const depth = {
|
|
151
|
+
fallback: 0,
|
|
152
|
+
alternateContent: 0,
|
|
153
|
+
textBox: 0,
|
|
154
|
+
tableCell: 0
|
|
155
|
+
};
|
|
127
156
|
let pos = 0;
|
|
128
|
-
while (pos < xml.length) {
|
|
157
|
+
scan: while (pos < xml.length) {
|
|
129
158
|
const tagStart = xml.indexOf("<", pos);
|
|
130
159
|
if (tagStart === -1) break;
|
|
131
160
|
if (xml.startsWith("</w:p>", tagStart)) {
|
|
@@ -136,6 +165,20 @@ function scanParagraphs(xml) {
|
|
|
136
165
|
pos = end;
|
|
137
166
|
continue;
|
|
138
167
|
}
|
|
168
|
+
for (const { key, open: openLiteral, close } of TRACKED_CONTAINERS) {
|
|
169
|
+
if (xml.startsWith(close, tagStart)) {
|
|
170
|
+
depth[key] = Math.max(0, depth[key] - 1);
|
|
171
|
+
pos = tagStart + close.length;
|
|
172
|
+
continue scan;
|
|
173
|
+
}
|
|
174
|
+
if (xml.startsWith(openLiteral, tagStart) && isXmlNameBoundary(xml[tagStart + openLiteral.length])) {
|
|
175
|
+
const tagEnd = xml.indexOf(">", tagStart);
|
|
176
|
+
if (tagEnd === -1) break scan;
|
|
177
|
+
if (xml[tagEnd - 1] !== "/") depth[key] += 1;
|
|
178
|
+
pos = tagEnd + 1;
|
|
179
|
+
continue scan;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
139
182
|
if (!xml.startsWith("<w:p", tagStart) || !isXmlNameBoundary(xml[tagStart + 4])) {
|
|
140
183
|
pos = tagStart + 1;
|
|
141
184
|
continue;
|
|
@@ -144,17 +187,25 @@ function scanParagraphs(xml) {
|
|
|
144
187
|
if (tagEnd === -1) break;
|
|
145
188
|
const openTag = xml.slice(tagStart, tagEnd + 1);
|
|
146
189
|
const paraId = /\bw14:paraId="(?<id>[^"]+)"/u.exec(openTag)?.groups?.["id"];
|
|
190
|
+
const container = {
|
|
191
|
+
inFallback: depth.fallback > 0,
|
|
192
|
+
inAlternateContent: depth.alternateContent > 0,
|
|
193
|
+
textBoxDepth: depth.textBox,
|
|
194
|
+
tableDepth: depth.tableCell
|
|
195
|
+
};
|
|
147
196
|
if (xml[tagEnd - 1] === "/") paragraphs.push({
|
|
148
197
|
start: tagStart,
|
|
149
198
|
end: tagEnd + 1,
|
|
150
|
-
paraId
|
|
199
|
+
paraId,
|
|
200
|
+
container
|
|
151
201
|
});
|
|
152
202
|
else {
|
|
153
203
|
open.push(paragraphs.length);
|
|
154
204
|
paragraphs.push({
|
|
155
205
|
start: tagStart,
|
|
156
206
|
end: tagStart,
|
|
157
|
-
paraId
|
|
207
|
+
paraId,
|
|
208
|
+
container
|
|
158
209
|
});
|
|
159
210
|
}
|
|
160
211
|
pos = tagEnd + 1;
|
|
@@ -208,98 +259,169 @@ const withoutMintedIds = (paragraphXml, sourceOpenTag) => {
|
|
|
208
259
|
if (tagEnd === -1) return paragraphXml;
|
|
209
260
|
return paragraphXml.slice(0, tagEnd + 1).replace(MINTED_PARA_ID_ATTRIBUTE, (attribute) => sourceOpenTag.includes(attribute.trim()) ? attribute : "") + paragraphXml.slice(tagEnd + 1);
|
|
210
261
|
};
|
|
262
|
+
const storyOf = ({ textBoxDepth }) => textBoxDepth > 0 ? "text-box" : "main";
|
|
263
|
+
const sameContainer = (a, b) => a.inAlternateContent === b.inAlternateContent && a.textBoxDepth === b.textBoxDepth && a.tableDepth === b.tableDepth;
|
|
264
|
+
const addressableParagraphs = (xml) => {
|
|
265
|
+
const stories = /* @__PURE__ */ new Map();
|
|
266
|
+
const ordinals = /* @__PURE__ */ new Map();
|
|
267
|
+
const byParaId = /* @__PURE__ */ new Map();
|
|
268
|
+
for (const paragraph of scanParagraphs(xml)) {
|
|
269
|
+
if (paragraph.container.inFallback) continue;
|
|
270
|
+
const story = storyOf(paragraph.container);
|
|
271
|
+
const sequence = stories.get(story) ?? [];
|
|
272
|
+
ordinals.set(paragraph, sequence.length);
|
|
273
|
+
sequence.push(paragraph);
|
|
274
|
+
stories.set(story, sequence);
|
|
275
|
+
if (paragraph.paraId !== void 0) {
|
|
276
|
+
const named = byParaId.get(paragraph.paraId) ?? [];
|
|
277
|
+
named.push(paragraph);
|
|
278
|
+
byParaId.set(paragraph.paraId, named);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
stories,
|
|
283
|
+
ordinals,
|
|
284
|
+
byParaId
|
|
285
|
+
};
|
|
286
|
+
};
|
|
287
|
+
/** The paraIds written on `<w:p>` elements inside `mc:Fallback`. */
|
|
288
|
+
const fallbackParaIds = (xml) => new Set(scanParagraphs(xml).filter(({ container, paraId }) => container.inFallback && paraId !== void 0).map(({ paraId }) => paraId));
|
|
289
|
+
/**
|
|
290
|
+
* The prefix of the part's root element. The scan reads WordprocessingML by
|
|
291
|
+
* its conventional `w:` prefix, so a part that binds it to another prefix has
|
|
292
|
+
* no paragraphs the scan can see, and a spliced `w:` paragraph would not
|
|
293
|
+
* resolve under its root.
|
|
294
|
+
*/
|
|
295
|
+
const rootElementName = (xml) => /<(?<name>[A-Za-z_][\w.-]*(?::[\w.-]+)?)/u.exec(xml)?.groups?.["name"];
|
|
296
|
+
/**
|
|
297
|
+
* The id of the nearest paragraph on one side of `paragraph` in its story that
|
|
298
|
+
* both parts name exactly once: the witness that places it in its story.
|
|
299
|
+
*/
|
|
300
|
+
const nearestSharedParaId = (own, other, paragraph, step) => {
|
|
301
|
+
const sequence = own.stories.get(storyOf(paragraph.container)) ?? [];
|
|
302
|
+
for (let ordinal = (own.ordinals.get(paragraph) ?? -1) + step; ordinal >= 0 && ordinal < sequence.length; ordinal += step) {
|
|
303
|
+
const paraId = sequence[ordinal]?.paraId;
|
|
304
|
+
if (paraId !== void 0 && own.byParaId.get(paraId)?.length === 1 && other.byParaId.get(paraId)?.length === 1) return paraId;
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
};
|
|
211
308
|
/**
|
|
212
309
|
* Route every changed paragraph from the model's serialization to its region
|
|
213
310
|
* of the source part.
|
|
214
311
|
*
|
|
215
312
|
* One owner for the question the patch turns on: *which paragraph of the file
|
|
216
|
-
* is this model paragraph?*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
313
|
+
* is this model paragraph?* The answer is local to the paragraph. A paraId the
|
|
314
|
+
* source writes once names it, robust to anything the model does elsewhere; a
|
|
315
|
+
* paragraph the source never named is located by its ordinal within its story,
|
|
316
|
+
* which is sound exactly when {@link resolveParagraphIdentities} says that
|
|
317
|
+
* story lines up. Nothing else in the part has to agree: the model may drop a
|
|
318
|
+
* text box it cannot read or write a Fallback it does not own, and an edit in
|
|
319
|
+
* the main flow is still one paragraph of the main flow.
|
|
320
|
+
*
|
|
321
|
+
* What the routing still refuses is a paragraph it cannot place with
|
|
322
|
+
* certainty, or one whose place changed:
|
|
323
|
+
* - a paraId missing from, or written twice by, either part (among the
|
|
324
|
+
* paragraphs outside `mc:Fallback`);
|
|
325
|
+
* - an id the source writes only inside `mc:Fallback`, which the model does
|
|
326
|
+
* not own;
|
|
327
|
+
* - a paragraph inside `mc:AlternateContent`, whose Fallback repeats it and
|
|
328
|
+
* would contradict the edited Choice;
|
|
329
|
+
* - a paragraph whose container (table-cell nesting, text box, alternate
|
|
330
|
+
* content) differs between the two parts;
|
|
331
|
+
* - an authored paragraph whose nearest shared neighbours differ, i.e. that
|
|
332
|
+
* moved within its story;
|
|
333
|
+
* - an id-less paragraph whose story's ordinals do not line up.
|
|
223
334
|
*/
|
|
224
335
|
const routeChangedParagraphs = (originalXml, serializedXml, changedIds) => {
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
336
|
+
const rootName = rootElementName(originalXml);
|
|
337
|
+
if (rootName !== void 0 && !rootName.startsWith("w:")) return {
|
|
338
|
+
type: "refused",
|
|
339
|
+
reason: `non-canonical-wordprocessingml-prefix: ${rootName}`
|
|
340
|
+
};
|
|
341
|
+
const original = addressableParagraphs(originalXml);
|
|
342
|
+
const serialized = addressableParagraphs(serializedXml);
|
|
343
|
+
let originalFallbackIds;
|
|
344
|
+
const storyAlignment = /* @__PURE__ */ new Map();
|
|
345
|
+
const storyAligned = (story) => {
|
|
346
|
+
let aligned = storyAlignment.get(story);
|
|
347
|
+
if (aligned === void 0) {
|
|
348
|
+
aligned = resolveParagraphIdentities({
|
|
349
|
+
sourceParaIds: (original.stories.get(story) ?? []).map(({ paraId }) => paraId),
|
|
350
|
+
serializedParaIds: (serialized.stories.get(story) ?? []).map(({ paraId }) => paraId)
|
|
351
|
+
}).ordinalsAligned;
|
|
352
|
+
storyAlignment.set(story, aligned);
|
|
353
|
+
}
|
|
354
|
+
return aligned;
|
|
355
|
+
};
|
|
235
356
|
const splices = [];
|
|
236
357
|
for (const id of changedIds) {
|
|
237
|
-
const
|
|
238
|
-
|
|
358
|
+
const inOriginal = original.byParaId.get(id) ?? [];
|
|
359
|
+
const inSerialized = serialized.byParaId.get(id) ?? [];
|
|
360
|
+
if (inOriginal.length > 1) return {
|
|
239
361
|
type: "refused",
|
|
240
362
|
reason: `duplicate-paraId-in-original: ${id}`
|
|
241
363
|
};
|
|
242
|
-
|
|
243
|
-
if (originalCount === 1 && serializedCount === 0) return {
|
|
364
|
+
if (inOriginal.length === 1 && inSerialized.length === 0) return {
|
|
244
365
|
type: "refused",
|
|
245
366
|
reason: `paraId-not-found-in-serialized: ${id}`
|
|
246
367
|
};
|
|
247
|
-
if (
|
|
368
|
+
if (inSerialized.length === 0) return {
|
|
248
369
|
type: "refused",
|
|
249
370
|
reason: `paraId-not-found-in-original: ${id}`
|
|
250
371
|
};
|
|
251
|
-
if (
|
|
372
|
+
if (inSerialized.length > 1) return {
|
|
252
373
|
type: "refused",
|
|
253
374
|
reason: `duplicate-paraId-in-serialized: ${id}`
|
|
254
375
|
};
|
|
255
|
-
const
|
|
256
|
-
if (
|
|
257
|
-
type: "refused",
|
|
258
|
-
reason: `paraId-not-found-in-serialized: ${id}`
|
|
259
|
-
};
|
|
260
|
-
const replacement = serialized[identity.ordinal];
|
|
261
|
-
if (!replacement || replacement.end <= replacement.start) return {
|
|
376
|
+
const replacement = inSerialized[0];
|
|
377
|
+
if (replacement.end <= replacement.start) return {
|
|
262
378
|
type: "refused",
|
|
263
379
|
reason: `unterminated-paragraph: ${id}`
|
|
264
380
|
};
|
|
265
381
|
const newXml = serializedXml.slice(replacement.start, replacement.end);
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
});
|
|
278
|
-
break;
|
|
279
|
-
}
|
|
280
|
-
case "minted": {
|
|
281
|
-
if (!ordinalsAligned) return {
|
|
282
|
-
type: "refused",
|
|
283
|
-
reason: `unaligned-paragraph-ordinals: ${id}`
|
|
284
|
-
};
|
|
285
|
-
const source = original[identity.ordinal];
|
|
286
|
-
if (!source || source.end <= source.start) return {
|
|
287
|
-
type: "refused",
|
|
288
|
-
reason: `paraId-not-found-in-original: ${id}`
|
|
289
|
-
};
|
|
290
|
-
splices.push({
|
|
291
|
-
start: source.start,
|
|
292
|
-
end: source.end,
|
|
293
|
-
newXml: withoutMintedIds(newXml, originalXml.slice(source.start, source.end))
|
|
294
|
-
});
|
|
295
|
-
break;
|
|
296
|
-
}
|
|
297
|
-
case "anonymous": return {
|
|
382
|
+
const authored = inOriginal[0];
|
|
383
|
+
let source;
|
|
384
|
+
if (authored) source = authored;
|
|
385
|
+
else {
|
|
386
|
+
originalFallbackIds ??= fallbackParaIds(originalXml);
|
|
387
|
+
if (originalFallbackIds.has(id)) return {
|
|
388
|
+
type: "refused",
|
|
389
|
+
reason: `paraId-only-in-fallback: ${id}`
|
|
390
|
+
};
|
|
391
|
+
const story = storyOf(replacement.container);
|
|
392
|
+
if (!storyAligned(story)) return {
|
|
298
393
|
type: "refused",
|
|
299
|
-
reason: `
|
|
394
|
+
reason: `unaligned-paragraph-ordinals: ${id}`
|
|
300
395
|
};
|
|
301
|
-
|
|
396
|
+
const positional = original.stories.get(story)?.[serialized.ordinals.get(replacement) ?? -1];
|
|
397
|
+
if (!positional) return {
|
|
398
|
+
type: "refused",
|
|
399
|
+
reason: `paraId-not-found-in-original: ${id}`
|
|
400
|
+
};
|
|
401
|
+
source = positional;
|
|
302
402
|
}
|
|
403
|
+
if (source.end <= source.start) return {
|
|
404
|
+
type: "refused",
|
|
405
|
+
reason: `unterminated-paragraph: ${id}`
|
|
406
|
+
};
|
|
407
|
+
if (source.container.inAlternateContent) return {
|
|
408
|
+
type: "refused",
|
|
409
|
+
reason: `paragraph-in-alternate-content: ${id}`
|
|
410
|
+
};
|
|
411
|
+
if (!sameContainer(source.container, replacement.container)) return {
|
|
412
|
+
type: "refused",
|
|
413
|
+
reason: `container-changed: ${id}`
|
|
414
|
+
};
|
|
415
|
+
if (authored && (nearestSharedParaId(original, serialized, source, -1) !== nearestSharedParaId(serialized, original, replacement, -1) || nearestSharedParaId(original, serialized, source, 1) !== nearestSharedParaId(serialized, original, replacement, 1))) return {
|
|
416
|
+
type: "refused",
|
|
417
|
+
reason: `paragraph-moved: ${id}`
|
|
418
|
+
};
|
|
419
|
+
const sourceXml = originalXml.slice(source.start, source.end);
|
|
420
|
+
splices.push({
|
|
421
|
+
start: source.start,
|
|
422
|
+
end: source.end,
|
|
423
|
+
newXml: authored ? newXml : withoutMintedIds(newXml, sourceXml)
|
|
424
|
+
});
|
|
303
425
|
}
|
|
304
426
|
return {
|
|
305
427
|
type: "routed",
|
|
@@ -307,60 +429,56 @@ const routeChangedParagraphs = (originalXml, serializedXml, changedIds) => {
|
|
|
307
429
|
};
|
|
308
430
|
};
|
|
309
431
|
/**
|
|
310
|
-
* Validate that a selective patch can be safely applied
|
|
432
|
+
* Validate that a selective patch can be safely applied: every changed
|
|
433
|
+
* paragraph routes to exactly one region of the original part (see
|
|
434
|
+
* {@link routeChangedParagraphs} for what is refused and why).
|
|
311
435
|
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
* - Paragraph count matches between original and serialized (unless disabled)
|
|
436
|
+
* The rest of the part does not have to match the model's serialization. The
|
|
437
|
+
* splice keeps every unchanged byte of the source, so a paragraph the model
|
|
438
|
+
* represents differently elsewhere (a text box it re-reads, a Fallback it
|
|
439
|
+
* does not own) is simply kept as the source wrote it.
|
|
317
440
|
*/
|
|
318
|
-
function validatePatchSafety(originalXml, serializedXml, changedIds
|
|
441
|
+
function validatePatchSafety(originalXml, serializedXml, changedIds) {
|
|
319
442
|
if (changedIds.size === 0) return { safe: true };
|
|
320
443
|
const routing = routeChangedParagraphs(originalXml, serializedXml, changedIds);
|
|
321
|
-
|
|
444
|
+
return routing.type === "refused" ? {
|
|
322
445
|
safe: false,
|
|
323
446
|
reason: routing.reason
|
|
324
|
-
};
|
|
325
|
-
if (options.checkParagraphCount === false) return { safe: true };
|
|
326
|
-
const originalCount = countParagraphElements(originalXml);
|
|
327
|
-
const serializedCount = countParagraphElements(serializedXml);
|
|
328
|
-
if (originalCount !== serializedCount) return {
|
|
329
|
-
safe: false,
|
|
330
|
-
reason: `paragraph-count-mismatch: original=${originalCount}, serialized=${serializedCount}`
|
|
331
|
-
};
|
|
332
|
-
return { safe: true };
|
|
447
|
+
} : { safe: true };
|
|
333
448
|
}
|
|
334
449
|
/**
|
|
335
450
|
* Build a patched document.xml by splicing new paragraph XML into
|
|
336
451
|
* the original at the correct offsets. Only changed paragraphs
|
|
337
452
|
* are replaced; everything else is preserved byte-for-byte.
|
|
338
453
|
*
|
|
339
|
-
* Returns null
|
|
454
|
+
* Returns null when a changed paragraph cannot be routed (see
|
|
455
|
+
* {@link validatePatchSafety}) or {@link spliceXml} refuses the result; the
|
|
456
|
+
* caller then falls back to a full repack, whose parts are all re-serialized
|
|
457
|
+
* from one model.
|
|
340
458
|
*/
|
|
341
459
|
function buildPatchedDocumentXml(originalXml, serializedXml, changedIds) {
|
|
342
460
|
if (changedIds.size === 0) return originalXml;
|
|
343
|
-
|
|
344
|
-
return
|
|
461
|
+
const routing = routeChangedParagraphs(originalXml, serializedXml, changedIds);
|
|
462
|
+
return routing.type === "refused" ? null : spliceXml(originalXml, routing.splices);
|
|
345
463
|
}
|
|
346
464
|
/**
|
|
347
465
|
* Build a patched note part (word/footnotes.xml / word/endnotes.xml) by
|
|
348
466
|
* splicing edited note paragraphs into the original, preserving unchanged
|
|
349
467
|
* content byte-for-byte.
|
|
350
468
|
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
469
|
+
* The routing is the document's: the model only retains the normal notes, so
|
|
470
|
+
* the serialized note XML omits the separator / continuationSeparator
|
|
471
|
+
* paragraphs the original part still carries, and splicing by `paraId` keeps
|
|
472
|
+
* those separators and every unedited note byte-exact while replacing only the
|
|
473
|
+
* edited ones. (An id-less note paragraph does not route here, because the
|
|
474
|
+
* separators misalign its story's ordinals; {@link buildPatchedNotePartXml}
|
|
475
|
+
* addresses it by note instead.)
|
|
356
476
|
*
|
|
357
477
|
* Returns null if any changed id is missing or ambiguous in either input, so
|
|
358
478
|
* the caller can fall back to preserving the original part verbatim.
|
|
359
479
|
*/
|
|
360
480
|
function buildPatchedNoteXml(originalXml, serializedXml, changedIds) {
|
|
361
|
-
|
|
362
|
-
if (!validatePatchSafety(originalXml, serializedXml, changedIds, { checkParagraphCount: false }).safe) return null;
|
|
363
|
-
return spliceChangedParagraphs(originalXml, serializedXml, changedIds);
|
|
481
|
+
return buildPatchedDocumentXml(originalXml, serializedXml, changedIds);
|
|
364
482
|
}
|
|
365
483
|
/**
|
|
366
484
|
* Apply `splices` to `xml`, end-to-start so earlier offsets stay valid.
|
|
@@ -379,17 +497,6 @@ const spliceXml = (xml, splices) => {
|
|
|
379
497
|
return patchBreaksCommentRangeBalance(xml, result) ? null : result;
|
|
380
498
|
};
|
|
381
499
|
/**
|
|
382
|
-
* Replace each changed paragraph in `originalXml` with its re-serialized form
|
|
383
|
-
* extracted from `serializedXml`. Assumes safety has already been validated.
|
|
384
|
-
* Returns null if an offset or extraction unexpectedly fails, or if
|
|
385
|
-
* {@link spliceXml} refuses the result; the caller then falls back to a full
|
|
386
|
-
* repack, whose parts are all re-serialized from one model.
|
|
387
|
-
*/
|
|
388
|
-
function spliceChangedParagraphs(originalXml, serializedXml, changedIds) {
|
|
389
|
-
const routing = routeChangedParagraphs(originalXml, serializedXml, changedIds);
|
|
390
|
-
return routing.type === "refused" ? null : spliceXml(originalXml, routing.splices);
|
|
391
|
-
}
|
|
392
|
-
/**
|
|
393
500
|
* Depth-count the end of the element that opens at `start` (an `<openLiteral…>`
|
|
394
501
|
* offset), returning its full range. Nested same-name opens increment depth;
|
|
395
502
|
* the matching `closeTag` (or a self-close) at depth 0 ends it. The boundary
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { document_d_exports } from "../../types/document.js";
|
|
2
|
+
//#region src/docx/serializer/groupTextBoxWriteBack.d.ts
|
|
3
|
+
type SerializeTextBody = (blocks: document_d_exports.ShapeTextBody["content"]) => string;
|
|
4
|
+
/**
|
|
5
|
+
* The paragraph content as it should be written: lifted text boxes dropped,
|
|
6
|
+
* and the drawing of each group whose text changed carrying the new text.
|
|
7
|
+
*
|
|
8
|
+
* Returns the paragraph's own content when it holds no lifted text box.
|
|
9
|
+
*/
|
|
10
|
+
declare const writeGroupTextBoxesBack: (paragraph: document_d_exports.Paragraph, serializeTextBody: SerializeTextBody) => readonly document_d_exports.ParagraphContent[];
|
|
11
|
+
//#endregion
|
|
12
|
+
export { writeGroupTextBoxesBack };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { groupTextContentFingerprint, groupXmlFingerprint, replaceGroupTextBoxContent } from "../drawingGroupChildren.js";
|
|
2
|
+
//#region src/docx/serializer/groupTextBoxWriteBack.ts
|
|
3
|
+
/** Every run in document order, through the containers a text box is lifted from. */
|
|
4
|
+
const forEachRun = (content, onRun) => {
|
|
5
|
+
for (const item of content) switch (item.type) {
|
|
6
|
+
case "run":
|
|
7
|
+
onRun(item);
|
|
8
|
+
break;
|
|
9
|
+
case "hyperlink":
|
|
10
|
+
forEachRun(item.children, onRun);
|
|
11
|
+
break;
|
|
12
|
+
case "inlineSdt":
|
|
13
|
+
case "insertion":
|
|
14
|
+
case "deletion":
|
|
15
|
+
case "moveFrom":
|
|
16
|
+
case "moveTo":
|
|
17
|
+
forEachRun(item.content, onRun);
|
|
18
|
+
break;
|
|
19
|
+
default: break;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
/** The same walk, rebuilding each container whose runs changed. */
|
|
23
|
+
const mapRuns = (content, mapRun) => content.map((item) => {
|
|
24
|
+
switch (item.type) {
|
|
25
|
+
case "run": return mapRun(item);
|
|
26
|
+
case "hyperlink": return {
|
|
27
|
+
...item,
|
|
28
|
+
children: mapRuns(item.children, mapRun)
|
|
29
|
+
};
|
|
30
|
+
case "inlineSdt":
|
|
31
|
+
case "insertion":
|
|
32
|
+
case "deletion":
|
|
33
|
+
case "moveFrom":
|
|
34
|
+
case "moveTo": return {
|
|
35
|
+
...item,
|
|
36
|
+
content: mapRuns(item.content, mapRun)
|
|
37
|
+
};
|
|
38
|
+
default: return item;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
const isGroupDrawing = (content) => content.type === "drawing" && content.rawXml !== void 0 && content.rawXml.includes("wgp");
|
|
42
|
+
const isGroupChild = (content) => content.type === "shape" && content.shape.groupChild !== void 0;
|
|
43
|
+
/**
|
|
44
|
+
* The paragraph content as it should be written: lifted text boxes dropped,
|
|
45
|
+
* and the drawing of each group whose text changed carrying the new text.
|
|
46
|
+
*
|
|
47
|
+
* Returns the paragraph's own content when it holds no lifted text box.
|
|
48
|
+
*/
|
|
49
|
+
const writeGroupTextBoxesBack = (paragraph, serializeTextBody) => {
|
|
50
|
+
const members = [];
|
|
51
|
+
const groups = [];
|
|
52
|
+
forEachRun(paragraph.content, (run) => {
|
|
53
|
+
for (const content of run.content) if (isGroupDrawing(content)) groups.push({
|
|
54
|
+
drawing: content,
|
|
55
|
+
fingerprint: groupXmlFingerprint(content.rawXml)
|
|
56
|
+
});
|
|
57
|
+
else if (isGroupChild(content)) {
|
|
58
|
+
const wanted = content.shape.groupChild?.group;
|
|
59
|
+
members.push({
|
|
60
|
+
member: content,
|
|
61
|
+
group: groups.findLast((candidate) => candidate.fingerprint === wanted)
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
if (members.length === 0) return paragraph.content;
|
|
66
|
+
const absorbed = /* @__PURE__ */ new Set();
|
|
67
|
+
const editsByDrawing = /* @__PURE__ */ new Map();
|
|
68
|
+
const claimedPaths = /* @__PURE__ */ new Map();
|
|
69
|
+
for (const { member, group } of members) {
|
|
70
|
+
const groupChild = member.shape.groupChild;
|
|
71
|
+
if (!group || !groupChild) continue;
|
|
72
|
+
const pathKey = groupChild.path.join("/");
|
|
73
|
+
const claimed = claimedPaths.get(group.drawing) ?? /* @__PURE__ */ new Set();
|
|
74
|
+
if (claimed.has(pathKey)) continue;
|
|
75
|
+
claimed.add(pathKey);
|
|
76
|
+
claimedPaths.set(group.drawing, claimed);
|
|
77
|
+
absorbed.add(member);
|
|
78
|
+
const content = member.shape.textBody?.content ?? [];
|
|
79
|
+
if (groupTextContentFingerprint(content) === groupChild.content) continue;
|
|
80
|
+
const edits = editsByDrawing.get(group.drawing) ?? [];
|
|
81
|
+
edits.push({
|
|
82
|
+
path: groupChild.path,
|
|
83
|
+
contentXml: serializeTextBody(content)
|
|
84
|
+
});
|
|
85
|
+
editsByDrawing.set(group.drawing, edits);
|
|
86
|
+
}
|
|
87
|
+
const rewritten = /* @__PURE__ */ new Map();
|
|
88
|
+
for (const [drawing, edits] of editsByDrawing) {
|
|
89
|
+
const rawXml = drawing.rawXml === void 0 ? void 0 : replaceGroupTextBoxContent(drawing.rawXml, edits);
|
|
90
|
+
if (rawXml !== void 0) rewritten.set(drawing, {
|
|
91
|
+
...drawing,
|
|
92
|
+
rawXml
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return mapRuns(paragraph.content, (run) => run.content.some((content) => absorbed.has(content) || rewritten.has(content)) ? {
|
|
96
|
+
...run,
|
|
97
|
+
content: run.content.filter((content) => !absorbed.has(content)).map((content) => rewritten.get(content) ?? content)
|
|
98
|
+
} : run);
|
|
99
|
+
};
|
|
100
|
+
//#endregion
|
|
101
|
+
export { writeGroupTextBoxesBack };
|
|
@@ -7,8 +7,9 @@ import { DATE_UTC_ATTRIBUTE } from "../trackedChangeInfo.js";
|
|
|
7
7
|
import { toTransitionalNamespaceUri } from "../transitionalSpelling.js";
|
|
8
8
|
import { captureVerbatimXml, createCapturedXmlSanitizer } from "../verbatimCapture.js";
|
|
9
9
|
import { NAMESPACES, OOXML_NAMESPACE_SCOPE, cloneElement, getChildElements, getLocalName, parseXml } from "../xmlParser.js";
|
|
10
|
+
import { writeGroupTextBoxesBack } from "./groupTextBoxWriteBack.js";
|
|
10
11
|
import { markupRangeAttributes, moveBookmarkAttributes, serializeBookmarkMarker } from "./markupRangeAttributes.js";
|
|
11
|
-
import { serializeRun } from "./runSerializer.js";
|
|
12
|
+
import { serializeRun, serializeShapeTextBody } from "./runSerializer.js";
|
|
12
13
|
import { serializeSdtWrapper } from "./sdtPropertiesSerializer.js";
|
|
13
14
|
import { serializeSectionProperties } from "./sectionPropertiesSerializer.js";
|
|
14
15
|
import { serializeTextFormatting } from "./textFormattingSerializer.js";
|
|
@@ -321,7 +322,7 @@ function serializeSimpleField(field) {
|
|
|
321
322
|
*/
|
|
322
323
|
function serializeComplexField(field) {
|
|
323
324
|
const parts = [];
|
|
324
|
-
const structuralFormatting = field.formatting ?? field.fieldResult[0]?.formatting;
|
|
325
|
+
const structuralFormatting = field.formatting ?? (field.fieldResultIsFallback ? void 0 : field.fieldResult[0]?.formatting);
|
|
325
326
|
const rPrXml = structuralFormatting ? serializeTextFormatting(structuralFormatting) : "";
|
|
326
327
|
const beginAttrs = ["w:fldCharType=\"begin\"", ...fieldStateAttributes(field)];
|
|
327
328
|
parts.push(`<w:r>${rPrXml}<w:fldChar ${beginAttrs.join(" ")}/></w:r>`);
|
|
@@ -331,7 +332,7 @@ function serializeComplexField(field) {
|
|
|
331
332
|
parts.push(`<w:r>${rPrXml}<w:instrText${spaceAttr}>${escapeXmlText(field.instruction)}</w:instrText></w:r>`);
|
|
332
333
|
}
|
|
333
334
|
parts.push(`<w:r>${rPrXml}<w:fldChar w:fldCharType="separate"/></w:r>`);
|
|
334
|
-
parts.push(...field.fieldResult.map((run) => serializeRun(run)));
|
|
335
|
+
if (!field.fieldResultIsFallback) parts.push(...field.fieldResult.map((run) => serializeRun(run)));
|
|
335
336
|
parts.push(`<w:r>${rPrXml}<w:fldChar w:fldCharType="end"/></w:r>`);
|
|
336
337
|
return parts.join("");
|
|
337
338
|
}
|
|
@@ -558,7 +559,7 @@ function serializeParagraph(paragraph) {
|
|
|
558
559
|
sectionProperties: paragraph.sectionProperties
|
|
559
560
|
}));
|
|
560
561
|
let pendingRenderedPageBreak = paragraph.renderedPageBreakBefore === true;
|
|
561
|
-
for (const content of paragraph
|
|
562
|
+
for (const content of writeGroupTextBoxesBack(paragraph, serializeShapeTextBody)) {
|
|
562
563
|
let contentXml = serializeParagraphContent(content);
|
|
563
564
|
if (contentXml) {
|
|
564
565
|
if (pendingRenderedPageBreak) {
|
|
@@ -5,6 +5,10 @@ import { document_d_exports } from "../../types/document.js";
|
|
|
5
5
|
* to keep IDs deterministic across saves.
|
|
6
6
|
*/
|
|
7
7
|
declare function resetAutoIdCounter(): void;
|
|
8
|
+
/** Serialize text body content for shapes/textboxes */
|
|
9
|
+
declare function serializeShapeTextBody(blocks: Extract<document_d_exports.BlockContent, {
|
|
10
|
+
type: "paragraph" | "table";
|
|
11
|
+
}>[]): string;
|
|
8
12
|
/**
|
|
9
13
|
* Serialize a run to OOXML XML (w:r)
|
|
10
14
|
*
|
|
@@ -44,4 +48,4 @@ declare function createBreakRun(breakType?: "page" | "column" | "textWrapping",
|
|
|
44
48
|
*/
|
|
45
49
|
declare function createTabRun(formatting?: document_d_exports.TextFormatting): document_d_exports.Run;
|
|
46
50
|
//#endregion
|
|
47
|
-
export { createBreakRun, createEmptyRun, createTabRun, createTextRun, getRunPlainText, hasRunFormatting, resetAutoIdCounter, serializeRun, serializeRuns };
|
|
51
|
+
export { createBreakRun, createEmptyRun, createTabRun, createTextRun, getRunPlainText, hasRunFormatting, resetAutoIdCounter, serializeRun, serializeRuns, serializeShapeTextBody };
|
|
@@ -8,6 +8,7 @@ import { DECORATIVE_EXTENSION_URI, DECORATIVE_NAMESPACE } from "../imageParser.j
|
|
|
8
8
|
import { canReplayEditableImageRawXml } from "../imageRawXml.js";
|
|
9
9
|
import { serializeNonVisualDrawingNames } from "../nonVisualDrawingProps.js";
|
|
10
10
|
import { runHoldsPayload } from "../runPayload.js";
|
|
11
|
+
import { replayableShapeAlternateContent } from "../shapeAlternateContent.js";
|
|
11
12
|
import { requiredWrapPolygon, serializeWrapPolygon } from "../wrapPolygon.js";
|
|
12
13
|
import { serializeParagraph } from "./paragraphSerializer.js";
|
|
13
14
|
import { serializeTable } from "./tableSerializer.js";
|
|
@@ -148,7 +149,7 @@ function serializeFill(fill) {
|
|
|
148
149
|
if (fill.type === "gradient" && fill.gradient) {
|
|
149
150
|
const g = fill.gradient;
|
|
150
151
|
return `<a:gradFill><a:gsLst>${g.stops.map((s) => `<a:gs pos="${s.position}">${serializeDrawingColor(s.color)}</a:gs>`).join("")}</a:gsLst>${(() => {
|
|
151
|
-
if (g.type === "linear") return `<a:lin ang="${(g.angle ?? 0) * 6e4}" scaled="1"/>`;
|
|
152
|
+
if (g.type === "linear") return `<a:lin ang="${(g.angle ?? 0) * 6e4}" scaled="${g.scaled === false ? 0 : 1}"/>`;
|
|
152
153
|
let path = "shape";
|
|
153
154
|
if (g.type === "radial") path = "circle";
|
|
154
155
|
else if (g.type === "rectangular") path = "rect";
|
|
@@ -544,7 +545,7 @@ function serializeRunContent(content) {
|
|
|
544
545
|
case "drawing":
|
|
545
546
|
if (content.rawXml && canReplayEditableImageRawXml(content)) return content.rawXml;
|
|
546
547
|
return serializeDrawingContent(content);
|
|
547
|
-
case "shape": return serializeShapeContent(content);
|
|
548
|
+
case "shape": return replayableShapeAlternateContent(content) ?? serializeShapeContent(content);
|
|
548
549
|
default: return "";
|
|
549
550
|
}
|
|
550
551
|
}
|
|
@@ -633,4 +634,4 @@ function createTabRun(formatting) {
|
|
|
633
634
|
};
|
|
634
635
|
}
|
|
635
636
|
//#endregion
|
|
636
|
-
export { createBreakRun, createEmptyRun, createTabRun, createTextRun, getRunPlainText, hasRunFormatting, resetAutoIdCounter, serializeRun, serializeRuns };
|
|
637
|
+
export { createBreakRun, createEmptyRun, createTabRun, createTextRun, getRunPlainText, hasRunFormatting, resetAutoIdCounter, serializeRun, serializeRuns, serializeShapeTextBody };
|