@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.
Files changed (86) hide show
  1. package/dist/ai-edits/apply.js +565 -121
  2. package/dist/ai-edits/minimal-replacement.d.ts +64 -0
  3. package/dist/ai-edits/minimal-replacement.js +282 -0
  4. package/dist/controller/layoutPipeline.js +11 -1
  5. package/dist/display-list/build/paragraphPrimitives.js +63 -7
  6. package/dist/display-list/build/textBoxPrimitives.js +1 -1
  7. package/dist/display-list/build/unsupported.d.ts +2 -2
  8. package/dist/display-list/build/unsupported.js +0 -0
  9. package/dist/display-list/dom/renderDisplayListToDom.js +0 -1
  10. package/dist/docx/blockContentParser.js +2 -2
  11. package/dist/docx/bulletMarkers.d.ts +18 -2
  12. package/dist/docx/bulletMarkers.js +27 -2
  13. package/dist/docx/drawingGroupChildren.d.ts +60 -0
  14. package/dist/docx/drawingGroupChildren.js +176 -0
  15. package/dist/docx/drawingUtils.js +3 -0
  16. package/dist/docx/groupDrawingParser.d.ts +7 -1
  17. package/dist/docx/groupDrawingParser.js +123 -32
  18. package/dist/docx/packageParts.d.ts +45 -1
  19. package/dist/docx/packageParts.js +93 -2
  20. package/dist/docx/paraIdAttribute.d.ts +3 -2
  21. package/dist/docx/paragraphParser.js +91 -7
  22. package/dist/docx/paragraphTextBoxEnrichment.js +126 -4
  23. package/dist/docx/runParser.js +10 -0
  24. package/dist/docx/selectiveSaveFlags.d.ts +10 -3
  25. package/dist/docx/selectiveSaveFlags.js +1 -1
  26. package/dist/docx/selectiveXmlPatch.d.ts +56 -34
  27. package/dist/docx/selectiveXmlPatch.js +222 -115
  28. package/dist/docx/serializer/groupTextBoxWriteBack.d.ts +12 -0
  29. package/dist/docx/serializer/groupTextBoxWriteBack.js +101 -0
  30. package/dist/docx/serializer/paragraphSerializer.js +5 -4
  31. package/dist/docx/serializer/runSerializer.d.ts +5 -1
  32. package/dist/docx/serializer/runSerializer.js +4 -3
  33. package/dist/docx/shapeAlternateContent.d.ts +25 -0
  34. package/dist/docx/shapeAlternateContent.js +52 -0
  35. package/dist/docx/textBoxParser.d.ts +10 -2
  36. package/dist/docx/textBoxParser.js +9 -3
  37. package/dist/internal/headlessRevisionResolution.js +50 -13
  38. package/dist/layout-bridge/convert/toFlowBlocks.js +26 -4
  39. package/dist/layout-engine/index.d.ts +2 -2
  40. package/dist/layout-engine/index.js +33 -4
  41. package/dist/layout-engine/keep-together.d.ts +5 -2
  42. package/dist/layout-engine/keep-together.js +5 -1
  43. package/dist/layout-engine/measure/advanceComposition.js +49 -3
  44. package/dist/layout-engine/measure/measureBlocks.js +5 -3
  45. package/dist/layout-engine/measure/measureContainer.js +46 -3
  46. package/dist/layout-engine/measure/measureHelpers.js +13 -3
  47. package/dist/layout-engine/measure/measureParagraph.js +44 -81
  48. package/dist/layout-engine/measure/smallCapsCasing.d.ts +56 -0
  49. package/dist/layout-engine/measure/smallCapsCasing.js +88 -0
  50. package/dist/layout-engine/paginator.d.ts +2 -0
  51. package/dist/layout-engine/paginator.js +5 -1
  52. package/dist/layout-engine/paragraphSpacing.d.ts +4 -2
  53. package/dist/layout-engine/paragraphSpacing.js +2 -1
  54. package/dist/layout-engine/tableRowBreak.d.ts +8 -1
  55. package/dist/layout-engine/tableRowBreak.js +22 -1
  56. package/dist/layout-engine/types.d.ts +21 -2
  57. package/dist/layout-painter/renderParagraph.js +41 -4
  58. package/dist/layout-painter/renderTextBox.d.ts +12 -2
  59. package/dist/layout-painter/renderTextBox.js +26 -2
  60. package/dist/prosemirror/alternateContentAttrs.d.ts +9 -0
  61. package/dist/prosemirror/alternateContentAttrs.js +35 -0
  62. package/dist/prosemirror/attrs/index.js +90 -0
  63. package/dist/prosemirror/commands/comments.js +51 -41
  64. package/dist/prosemirror/contentControlRevisions.d.ts +43 -0
  65. package/dist/prosemirror/contentControlRevisions.js +84 -0
  66. package/dist/prosemirror/conversion/fromProseDoc.js +46 -22
  67. package/dist/prosemirror/conversion/toProseDoc.js +42 -19
  68. package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.d.ts +7 -7
  69. package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.js +7 -7
  70. package/dist/prosemirror/extensions/nodes/FieldExtension.js +6 -3
  71. package/dist/prosemirror/extensions/nodes/SdtExtension.js +9 -2
  72. package/dist/prosemirror/extensions/nodes/ShapeExtension.js +1 -0
  73. package/dist/prosemirror/extensions/nodes/TextBoxExtension.js +3 -0
  74. package/dist/prosemirror/listMarker.js +2 -2
  75. package/dist/prosemirror/paragraphDirection.d.ts +29 -2
  76. package/dist/prosemirror/paragraphDirection.js +21 -2
  77. package/dist/prosemirror/plugins/suggestionMode.js +37 -8
  78. package/dist/prosemirror/rejoinRunCarriers.d.ts +27 -0
  79. package/dist/prosemirror/rejoinRunCarriers.js +66 -0
  80. package/dist/prosemirror/runIdentityAcrossRevisions.d.ts +10 -0
  81. package/dist/prosemirror/runIdentityAcrossRevisions.js +90 -0
  82. package/dist/prosemirror/schema/nodes.d.ts +37 -5
  83. package/dist/types/content.d.ts +2 -2
  84. package/package.json +2 -2
  85. package/dist/layout-engine/justifiedLineFit.d.ts +0 -7
  86. 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: the *n*th entry of
116
- * the source part and the *n*th entry of the model's serialization name the
117
- * same paragraph, which is the only way to address a paragraph the producer
118
- * gave no id. A `<w:p>` nested inside another (inside `mc:AlternateContent`,
119
- * a text box) is one entry of its own, exactly as
120
- * {@link countParagraphElements} counts it, so the two never disagree about
121
- * what an ordinal is. An unterminated paragraph keeps `end === start`: it
122
- * occupies its ordinal but no splice can be built from it.
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?* {@link resolveParagraphIdentities} answers it per
217
- * paragraph, and the union is consumed exhaustively here, so a paragraph the
218
- * source names by id keeps the id lookup — robust to any reordering — and one
219
- * the source never named falls back to its ordinal, which is sound exactly
220
- * when the identity plan says the two sequences line up. A package with ids on
221
- * some paragraphs and not others therefore needs no special case: its authored
222
- * ids are the witnesses that prove the ordinals for the rest.
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 original = scanParagraphs(originalXml);
226
- const serialized = scanParagraphs(serializedXml);
227
- const { identities, ordinalsAligned } = resolveParagraphIdentities({
228
- sourceParaIds: original.map(({ paraId }) => paraId),
229
- serializedParaIds: serialized.map(({ paraId }) => paraId)
230
- });
231
- const originalParaIds = collectParaIds(originalXml);
232
- const serializedParaIds = collectParaIds(serializedXml);
233
- const identityByParaId = /* @__PURE__ */ new Map();
234
- for (const identity of identities) if (identity.type !== "anonymous" && serializedParaIds.get(identity.paraId) === 1) identityByParaId.set(identity.paraId, identity);
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 originalCount = originalParaIds.get(id) ?? 0;
238
- if (originalCount > 1) return {
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
- const serializedCount = serializedParaIds.get(id) ?? 0;
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 (serializedCount === 0) return {
368
+ if (inSerialized.length === 0) return {
248
369
  type: "refused",
249
370
  reason: `paraId-not-found-in-original: ${id}`
250
371
  };
251
- if (serializedCount > 1) return {
372
+ if (inSerialized.length > 1) return {
252
373
  type: "refused",
253
374
  reason: `duplicate-paraId-in-serialized: ${id}`
254
375
  };
255
- const identity = identityByParaId.get(id);
256
- if (!identity) return {
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
- switch (identity.type) {
267
- case "authored": {
268
- const offsets = findParagraphOffsets(originalXml, identity.paraId);
269
- if (!offsets) return {
270
- type: "refused",
271
- reason: `paraId-not-found-in-original: ${id}`
272
- };
273
- splices.push({
274
- start: offsets.start,
275
- end: offsets.end,
276
- newXml
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: `paraId-not-found-in-serialized: ${id}`
394
+ reason: `unaligned-paragraph-ordinals: ${id}`
300
395
  };
301
- default: return identity;
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
- * Checks:
313
- * - Every changed paraId routes to one region of the original XML, by its
314
- * authored id or, when the producer wrote none, by its paragraph ordinal
315
- * - All changed paraIds exist in serialized XML (exactly once)
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, options = {}) {
441
+ function validatePatchSafety(originalXml, serializedXml, changedIds) {
319
442
  if (changedIds.size === 0) return { safe: true };
320
443
  const routing = routeChangedParagraphs(originalXml, serializedXml, changedIds);
321
- if (routing.type === "refused") return {
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 if any step fails.
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
- if (!validatePatchSafety(originalXml, serializedXml, changedIds).safe) return null;
344
- return spliceChangedParagraphs(originalXml, serializedXml, changedIds);
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
- * Unlike {@link buildPatchedDocumentXml} this does NOT require the paragraph
352
- * counts to match: the document model only retains the normal notes, so the
353
- * serialized note XML omits the separator / continuationSeparator paragraphs
354
- * the original part still carries. Splicing by `paraId` keeps those separators
355
- * and every unedited note byte-exact while replacing only the edited ones.
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
- if (changedIds.size === 0) return originalXml;
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.content) {
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 };