@stll/folio-core 0.47.3 → 0.47.5

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 (36) hide show
  1. package/dist/ai-edits/headless.js +46 -11
  2. package/dist/compare/inline-atoms.js +218 -31
  3. package/dist/compare/section-boundary-properties.js +1 -0
  4. package/dist/content-controls/mutateContentControls.js +1 -3
  5. package/dist/docx/commentRangeIntegrity.js +7 -0
  6. package/dist/docx/documentParser.js +55 -3
  7. package/dist/docx/inlineWrapperContent.d.ts +18 -16
  8. package/dist/docx/inlineWrapperContent.js +18 -16
  9. package/dist/docx/normalizeBaseDirection.js +4 -0
  10. package/dist/docx/rezip.js +1 -0
  11. package/dist/docx/serializer/documentSerializer.js +16 -1
  12. package/dist/docx/serializer/paragraphSerializer.js +8 -0
  13. package/dist/docx/serializer/tableSerializer.js +5 -2
  14. package/dist/docx/server/createBilingualDocument.js +23 -14
  15. package/dist/docx/tableParser.js +101 -75
  16. package/dist/markdown/renderRuns.js +4 -0
  17. package/dist/markdown/renderTable.js +46 -12
  18. package/dist/prosemirror/attrs/index.js +67 -3
  19. package/dist/prosemirror/commentReferenceAttrs.js +2 -2
  20. package/dist/prosemirror/commentReferenceIntegrity.d.ts +7 -3
  21. package/dist/prosemirror/commentReferenceIntegrity.js +9 -1
  22. package/dist/prosemirror/conversion/fromProseDoc.js +74 -18
  23. package/dist/prosemirror/conversion/toProseDoc.js +75 -42
  24. package/dist/prosemirror/extensions/marks/HyperlinkExtension.js +13 -6
  25. package/dist/prosemirror/extensions/marks/InlineWrapperExtension.js +24 -6
  26. package/dist/prosemirror/extensions/nodes/CommentReferenceExtension.js +9 -5
  27. package/dist/prosemirror/extensions/nodes/TableExtension.js +2 -2
  28. package/dist/prosemirror/schema/marks.d.ts +11 -0
  29. package/dist/prosemirror/schema/nodes.d.ts +6 -4
  30. package/dist/prosemirror/trackedRunInlineAtoms.d.ts +1 -1
  31. package/dist/prosemirror/trackedRunInlineAtoms.js +1 -1
  32. package/dist/prosemirror/validation.js +48 -7
  33. package/dist/types/content.d.ts +2 -2
  34. package/dist/types/index.d.ts +3 -1
  35. package/dist/utils/mergeDocumentContent.js +1 -7
  36. package/package.json +2 -2
@@ -164,16 +164,49 @@ const FOLIO_RESOLVED_STORY_SERIALIZATION_MISMATCHES = Object.freeze({
164
164
  blockProjection: "block-projection"
165
165
  });
166
166
  var FolioResolvedStorySerializationError = class extends TaggedError("FolioResolvedStorySerializationError") {};
167
+ /** Blocks whose id folio minted, because the package names no id for them. */
168
+ const mintedBlockOrdinals = (snapshot) => {
169
+ const ordinals = /* @__PURE__ */ new Set();
170
+ for (const [ordinal, block] of snapshot.blocks.entries()) if (block.idStability === "positional") ordinals.add(ordinal);
171
+ return ordinals;
172
+ };
167
173
  /**
168
- * `idStability` records how an id entered the current snapshot. A synthesized
169
- * paraId is positional until save writes it into the package, then becomes an
170
- * authored stable id when reopened. Compare the persisted block projection
171
- * without changing that live snapshot identity contract.
174
+ * What a block looks like once the story has been through the package.
175
+ *
176
+ * `idStability` is the snapshot's own fact rather than the package's, so it
177
+ * goes. `minted` names the blocks whose id folio invented for a paragraph the
178
+ * package gives no `w14:paraId`, and each of those stands down to its position.
179
+ * A minted id is derived from the paragraph's text and ordinal, so resolving
180
+ * its text can make an id-less paragraph derive a different id when reopened.
181
+ * The two save paths also disagree about whether it reaches the file at all:
182
+ * the selective patch keeps the author's id-less convention (see
183
+ * `withoutMintedIds`) while a full repack writes the model's ids. Which path
184
+ * ran is not what this check is about, so neither the id nor the choice between
185
+ * them may decide it. An authored id is compared exactly as it is, and a block
186
+ * that moved still fails.
172
187
  */
173
- const resolvedStoryBlockProjection = (block) => {
188
+ const resolvedStoryBlockProjection = (block, ordinal, minted) => {
174
189
  const persisted = { ...block };
175
190
  delete persisted.idStability;
176
- return persisted;
191
+ return minted.has(ordinal) ? {
192
+ ...persisted,
193
+ id: `minted-${String(ordinal)}`
194
+ } : persisted;
195
+ };
196
+ /**
197
+ * The two halves of the expectation, from one projection.
198
+ *
199
+ * The reading text carries each block's id, so it has to be built from the
200
+ * same projected blocks the block comparison uses; formatting the raw snapshot
201
+ * instead would put a minted id back into the text and fail the check the
202
+ * block projection just passed.
203
+ */
204
+ const resolvedStoryProjection = (snapshot, minted) => {
205
+ const blocks = snapshot.blocks.map((block, ordinal) => resolvedStoryBlockProjection(block, ordinal, minted));
206
+ return {
207
+ text: blocks.filter(isFolioAIContentBlock).map(formatBlockForLLM).join("\n"),
208
+ blocks
209
+ };
177
210
  };
178
211
  const numberingLevelsOf = (numbering) => {
179
212
  if (!numbering) return [];
@@ -735,10 +768,11 @@ var FolioDocxReviewer = class FolioDocxReviewer {
735
768
  const resolvedState = resolveReviewedState(sourceState, view);
736
769
  this.setEditableStoryState(story, resolvedState);
737
770
  const snapshot = createStateSnapshot(resolvedState);
771
+ const minted = mintedBlockOrdinals(snapshot);
738
772
  this.resolvedStoryExpectations.set(editableStoryKey(story), {
739
773
  story,
740
- text: formatStorySnapshotForLLM(snapshot, false),
741
- blocks: snapshot.blocks.map(resolvedStoryBlockProjection)
774
+ minted,
775
+ ...resolvedStoryProjection(snapshot, minted)
742
776
  });
743
777
  return snapshot;
744
778
  }
@@ -1356,13 +1390,14 @@ var FolioDocxReviewer = class FolioDocxReviewer {
1356
1390
  async assertResolvedStoriesSerialized(buffer, expectations) {
1357
1391
  if (expectations.length === 0) return;
1358
1392
  const reopened = await FolioDocxReviewer.fromBuffer(buffer);
1359
- for (const { story, text, blocks } of expectations) {
1393
+ for (const { story, text, blocks, minted } of expectations) {
1360
1394
  const serialized = reopened.readReviewedStory({
1361
1395
  story,
1362
1396
  view: "current-markup"
1363
1397
  });
1364
- const serializedText = serialized ? formatStorySnapshotForLLM(serialized.snapshot, false) : null;
1365
- const serializedBlocks = serialized ? serialized.snapshot.blocks.map(resolvedStoryBlockProjection) : null;
1398
+ const persisted = serialized ? resolvedStoryProjection(serialized.snapshot, minted) : null;
1399
+ const serializedText = persisted?.text ?? null;
1400
+ const serializedBlocks = persisted?.blocks ?? null;
1366
1401
  const mismatches = [];
1367
1402
  if (!serialized) mismatches.push(FOLIO_RESOLVED_STORY_SERIALIZATION_MISMATCHES.storyMissing);
1368
1403
  else {
@@ -1,9 +1,11 @@
1
1
  import { buildCleanBlockText } from "../ai-edits/clean-text.js";
2
2
  import { sourceDocumentOf } from "../ai-edits/snapshot.js";
3
3
  import { resolveAllChangesInHeadlessStateWithMapping } from "../prosemirror/commands/comments.js";
4
+ import { runFormattingInlineAtomResultText } from "../prosemirror/runFormattingInlineCarriers.js";
4
5
  import { canonicalJson } from "../utils/canonicalJson.js";
5
6
  import { prepareTargetInlineAtom } from "./inline-atom-resources.js";
6
7
  //#region src/compare/inline-atoms.ts
8
+ const actionRangeCount = (action) => action.kind === "replace-text" && action.textDisposition === "retained" || action.kind === "replace-atom" && action.atomDisposition === "retained" ? 2 : 1;
7
9
  const MAX_INLINE_ATOM_LCS_CELLS = 16384;
8
10
  const isSupportedAtom = (node) => node.isInline && node.isAtom && (node.type.name === "field" || node.type.name === "image" || node.type.name === "pageBreakRun");
9
11
  const hasSupportedAtom = (doc) => {
@@ -72,8 +74,8 @@ const atomKey = (node) => canonicalJson({
72
74
  attrs: documentFactAttrs(node),
73
75
  content: node.content.toJSON()
74
76
  });
75
- const atomBlockOf = ({ node, from }) => {
76
- const clean = buildCleanBlockText(node, from, { fieldResults: "omitted" });
77
+ const atomBlockOf = ({ node, from }, fieldResults) => {
78
+ const clean = buildCleanBlockText(node, from, { fieldResults });
77
79
  const supported = [];
78
80
  const unsupportedTopology = [];
79
81
  node.descendants((child, relativePosition) => {
@@ -86,12 +88,18 @@ const atomBlockOf = ({ node, from }) => {
86
88
  });
87
89
  if (!isSupportedAtom(child)) {
88
90
  if (child.type.name === "renderedPageBreak") return false;
89
- unsupportedTopology.push(`${offset}:${child.type.name}:${canonicalJson(documentFactAttrs(child))}`);
91
+ unsupportedTopology.push({
92
+ offset,
93
+ key: `${child.type.name}:${canonicalJson(documentFactAttrs(child))}`
94
+ });
90
95
  return false;
91
96
  }
92
97
  const prepared = prepareTargetInlineAtom(child);
93
98
  if (!prepared) {
94
- unsupportedTopology.push(`${offset}:${child.type.name}:${canonicalJson(documentFactAttrs(child))}`);
99
+ unsupportedTopology.push({
100
+ offset,
101
+ key: `${child.type.name}:${canonicalJson(documentFactAttrs(child))}`
102
+ });
95
103
  return false;
96
104
  }
97
105
  supported.push({
@@ -130,8 +138,33 @@ const targetBlockIdLookup = (snapshot) => {
130
138
  return owner?.id;
131
139
  };
132
140
  };
133
- const atomBlocksOf = (doc) => textBlocksOf(doc).map(atomBlockOf);
134
- const sameBlockTopology = (left, right) => left.node.type === right.node.type && left.cleanText === right.cleanText && canonicalJson(left.unsupportedTopology) === canonicalJson(right.unsupportedTopology);
141
+ const atomBlocksOf = (doc, fieldResults) => textBlocksOf(doc).map((block) => atomBlockOf(block, fieldResults));
142
+ const sameBlockTopology = (left, right) => {
143
+ if (left.node.type !== right.node.type || left.cleanText !== right.cleanText) return false;
144
+ const supportedOffsets = /* @__PURE__ */ new Set([...left.supported.map(({ offset }) => offset), ...right.supported.map(({ offset }) => offset)]);
145
+ const relevantTopology = ({ unsupportedTopology }) => unsupportedTopology.filter(({ offset }) => supportedOffsets.has(offset));
146
+ return canonicalJson(relevantTopology(left)) === canonicalJson(relevantTopology(right));
147
+ };
148
+ const supportedAtomProjection = (block) => canonicalJson(block.supported.map(({ offset, key }) => ({
149
+ offset,
150
+ key
151
+ })));
152
+ const sameSupportedAtoms = (left, right) => left.node.type === right.node.type && supportedAtomProjection(left) === supportedAtomProjection(right);
153
+ const fieldResultOwnershipOf = (atoms) => {
154
+ const ownership = /* @__PURE__ */ new Map();
155
+ for (const atom of atoms) {
156
+ const resultText = runFormattingInlineAtomResultText(atom.node);
157
+ if (!resultText) continue;
158
+ let resultsAtOffset = ownership.get(atom.offset);
159
+ if (!resultsAtOffset) {
160
+ resultsAtOffset = /* @__PURE__ */ new Set();
161
+ ownership.set(atom.offset, resultsAtOffset);
162
+ }
163
+ resultsAtOffset.add(resultText);
164
+ }
165
+ return ownership;
166
+ };
167
+ const ownsFieldResult = ({ ownership, offset, resultText }) => ownership.get(offset)?.has(resultText) ?? false;
135
168
  const matchingAtomGroup = ({ live, target, liveStart, targetStart }) => {
136
169
  if (live.length === target.length && live.every((atom, index) => atom.key === target[index]?.key)) return live.map((_, index) => [liveStart + index, targetStart + index]);
137
170
  if (live.length === 0 || target.length === 0) return [];
@@ -246,14 +279,58 @@ const sourceTextBlockIds = (source) => {
246
279
  }
247
280
  return blocks;
248
281
  };
249
- const sameParagraphSourcePosition = ({ sourceBlocks, reviewed, offset }) => {
282
+ const sameParagraphSourcePosition = ({ sourceBlocks, reviewed, offset, fieldResults }) => {
250
283
  const paraId = reviewed.node.attrs["paraId"];
251
284
  if (typeof paraId !== "string" || paraId.length === 0) return null;
252
285
  const source = sourceBlocks.get(paraId);
253
286
  if (!source || source.node.type !== reviewed.node.type) return null;
254
- const clean = buildCleanBlockText(source.node, source.from, { fieldResults: "omitted" });
287
+ const clean = buildCleanBlockText(source.node, source.from, { fieldResults });
255
288
  return clean.text === reviewed.cleanText ? clean.offsets[offset] ?? null : null;
256
289
  };
290
+ const textRangeDisposition = ({ doc, from, to, expectedText, originalRevisionIdSeed }) => {
291
+ if (from >= to || doc.textBetween(from, to) !== expectedText) return null;
292
+ let disposition;
293
+ let covered = 0;
294
+ let invalid = false;
295
+ doc.nodesBetween(from, to, (node, position) => {
296
+ if (!node.isText) {
297
+ if (position > from && position < to) invalid = true;
298
+ return !invalid;
299
+ }
300
+ const overlapFrom = Math.max(from, position);
301
+ const overlapTo = Math.min(to, position + node.nodeSize);
302
+ if (overlapFrom >= overlapTo) return false;
303
+ covered += overlapTo - overlapFrom;
304
+ if (node.marks.some(({ type }) => type.name === "deletion")) {
305
+ invalid = true;
306
+ return false;
307
+ }
308
+ const insertion = node.marks.find(({ type }) => type.name === "insertion");
309
+ let current = "retained";
310
+ if (insertion) {
311
+ const revisionId = insertion.attrs["revisionId"];
312
+ if (typeof revisionId !== "number" || revisionId < originalRevisionIdSeed) {
313
+ invalid = true;
314
+ return false;
315
+ }
316
+ current = "inserted";
317
+ }
318
+ if (disposition !== void 0 && disposition !== current) {
319
+ invalid = true;
320
+ return false;
321
+ }
322
+ disposition = current;
323
+ return false;
324
+ });
325
+ return !invalid && covered === to - from ? disposition ?? null : null;
326
+ };
327
+ const atomDisposition = ({ atom, originalRevisionIdSeed }) => {
328
+ if (atom.node.marks.some(({ type }) => type.name === "deletion")) return null;
329
+ const insertion = atom.node.marks.find(({ type }) => type.name === "insertion");
330
+ if (!insertion) return "retained";
331
+ const revisionId = insertion.attrs["revisionId"];
332
+ return typeof revisionId === "number" && revisionId >= originalRevisionIdSeed ? "inserted" : null;
333
+ };
257
334
  /**
258
335
  * Restore field, image, and page-break atoms omitted by text-only comparison operations.
259
336
  * Positions come from the legacy review resolver's mapping, never a guessed
@@ -272,15 +349,38 @@ const matchInlineAtoms = ({ state, targetSnapshot, revisionStamp, originalRevisi
272
349
  const reviewed = resolveAllChangesInHeadlessStateWithMapping(state, "accept");
273
350
  const targetBlockIdAt = targetBlockIdLookup(targetSnapshot);
274
351
  let sourceBlocks;
275
- const liveBlocks = atomBlocksOf(reviewed.state.doc);
276
- const targetBlocks = atomBlocksOf(targetDocument);
352
+ const liveBlocks = atomBlocksOf(reviewed.state.doc, "text");
353
+ const targetBlocks = atomBlocksOf(targetDocument, "text");
277
354
  if (liveBlocks.length !== targetBlocks.length) return { status: "unalignable" };
278
355
  const actions = [];
279
- for (const [index, live] of liveBlocks.entries()) {
280
- const target = targetBlocks[index];
281
- if (!target) return { status: "unalignable" };
282
- if (live.supported.length === 0 && target.supported.length === 0) continue;
283
- if (!sameBlockTopology(live, target)) return { status: "unalignable" };
356
+ let rangeCount = 0;
357
+ const planAction = (action) => {
358
+ const nextRangeCount = rangeCount + actionRangeCount(action);
359
+ if (nextRangeCount > maxRanges) return false;
360
+ rangeCount = nextRangeCount;
361
+ actions.push(action);
362
+ return true;
363
+ };
364
+ let omittedLiveBlocks;
365
+ let omittedTargetBlocks;
366
+ for (const [index, fullLive] of liveBlocks.entries()) {
367
+ const fullTarget = targetBlocks[index];
368
+ if (!fullTarget) return { status: "unalignable" };
369
+ if (fullLive.supported.length === 0 && fullTarget.supported.length === 0) continue;
370
+ if (sameSupportedAtoms(fullLive, fullTarget)) continue;
371
+ let live = fullLive;
372
+ let target = fullTarget;
373
+ let fieldResults = "text";
374
+ if (!sameBlockTopology(live, target)) {
375
+ omittedLiveBlocks ??= atomBlocksOf(reviewed.state.doc, "omitted");
376
+ omittedTargetBlocks ??= atomBlocksOf(targetDocument, "omitted");
377
+ const omittedLive = omittedLiveBlocks[index];
378
+ const omittedTarget = omittedTargetBlocks[index];
379
+ if (!omittedLive || !omittedTarget || !sameBlockTopology(omittedLive, omittedTarget)) return { status: "unalignable" };
380
+ live = omittedLive;
381
+ target = omittedTarget;
382
+ fieldResults = "omitted";
383
+ }
284
384
  const matches = matchingAtoms({
285
385
  live: live.supported,
286
386
  target: target.supported
@@ -292,6 +392,8 @@ const matchInlineAtoms = ({ state, targetSnapshot, revisionStamp, originalRevisi
292
392
  target: target.supported,
293
393
  matches
294
394
  });
395
+ const liveFieldResultOwnership = fieldResultOwnershipOf(live.supported);
396
+ const targetFieldResultOwnership = fieldResultOwnershipOf(target.supported);
295
397
  for (const [liveIndex, atom] of live.supported.entries()) {
296
398
  if (matchedLive.has(liveIndex)) continue;
297
399
  const from = mappedSourcePosition({
@@ -299,12 +401,34 @@ const matchInlineAtoms = ({ state, targetSnapshot, revisionStamp, originalRevisi
299
401
  position: atom.from
300
402
  });
301
403
  if (from === null) return { status: "unalignable" };
302
- actions.push({
404
+ const resultText = runFormattingInlineAtomResultText(atom.node);
405
+ if (fieldResults === "text" && resultText && !ownsFieldResult({
406
+ ownership: targetFieldResultOwnership,
407
+ offset: atom.offset,
408
+ resultText
409
+ })) {
410
+ const disposition = atomDisposition({
411
+ atom,
412
+ originalRevisionIdSeed
413
+ });
414
+ if (disposition === null) return { status: "unalignable" };
415
+ if (!planAction({
416
+ kind: "replace-atom",
417
+ from,
418
+ to: from + atom.node.nodeSize,
419
+ text: resultText,
420
+ atomDisposition: disposition,
421
+ marks: atom.node.marks,
422
+ targetBlockId: targetBlockIdAt(target.from)
423
+ })) return { status: "budget-exceeded" };
424
+ continue;
425
+ }
426
+ if (!planAction({
303
427
  kind: "delete",
304
428
  from,
305
429
  to: from + atom.node.nodeSize,
306
430
  targetBlockId: targetBlockIdAt(target.from)
307
- });
431
+ })) return { status: "budget-exceeded" };
308
432
  }
309
433
  for (const [targetIndex, atom] of target.supported.entries()) {
310
434
  if (matchedTarget.has(targetIndex)) continue;
@@ -316,18 +440,48 @@ const matchInlineAtoms = ({ state, targetSnapshot, revisionStamp, originalRevisi
316
440
  }) ?? sameParagraphSourcePosition({
317
441
  sourceBlocks: sourceBlocks ??= sourceTextBlockIds(state.doc),
318
442
  reviewed: live,
319
- offset: atom.offset
443
+ offset: atom.offset,
444
+ fieldResults
320
445
  });
321
446
  if (from === null) return { status: "unalignable" };
322
- actions.push({
447
+ const resultText = runFormattingInlineAtomResultText(atom.node);
448
+ if (fieldResults === "text" && resultText && !ownsFieldResult({
449
+ ownership: liveFieldResultOwnership,
450
+ offset: atom.offset,
451
+ resultText
452
+ })) {
453
+ const reviewedTo = live.offsets[atom.offset + resultText.length];
454
+ const to = reviewedTo === void 0 ? null : mappedSourcePosition({
455
+ mapping: reviewed.mapping,
456
+ position: reviewedTo
457
+ });
458
+ if (to === null) return { status: "unalignable" };
459
+ const textDisposition = textRangeDisposition({
460
+ doc: state.doc,
461
+ from,
462
+ to,
463
+ expectedText: resultText,
464
+ originalRevisionIdSeed
465
+ });
466
+ if (textDisposition === null) return { status: "unalignable" };
467
+ if (!planAction({
468
+ kind: "replace-text",
469
+ from,
470
+ to,
471
+ node: atom.node,
472
+ textDisposition,
473
+ targetBlockId: targetBlockIdAt(target.from)
474
+ })) return { status: "budget-exceeded" };
475
+ continue;
476
+ }
477
+ if (!planAction({
323
478
  kind: "insert",
324
479
  from,
325
480
  node: atom.node,
326
481
  targetBlockId: targetBlockIdAt(target.from)
327
- });
482
+ })) return { status: "budget-exceeded" };
328
483
  }
329
484
  }
330
- if (actions.length > maxRanges) return { status: "budget-exceeded" };
331
485
  const insertionType = state.schema.marks["insertion"];
332
486
  const deletionType = state.schema.marks["deletion"];
333
487
  if (!insertionType || !deletionType) return { status: "unalignable" };
@@ -347,6 +501,45 @@ const matchInlineAtoms = ({ state, targetSnapshot, revisionStamp, originalRevisi
347
501
  if (action.targetBlockId) changedTargetBlockIds.add(action.targetBlockId);
348
502
  continue;
349
503
  }
504
+ if (action.kind === "replace-text") {
505
+ const from = transaction.mapping.map(action.from, 1);
506
+ const to = transaction.mapping.map(action.to, -1);
507
+ if (from >= to) return { status: "unalignable" };
508
+ if (action.textDisposition === "inserted") transaction.delete(from, to);
509
+ else transaction.addMark(from, to, deletionType.create({
510
+ revisionId: nextRevisionId++,
511
+ author,
512
+ date: revisionStamp.date
513
+ }));
514
+ const at = transaction.mapping.map(action.from, -1);
515
+ transaction.insert(at, action.node.mark(insertionType.create({
516
+ revisionId: nextRevisionId++,
517
+ author,
518
+ date: revisionStamp.date
519
+ }).addToSet(action.node.marks)));
520
+ if (action.targetBlockId) changedTargetBlockIds.add(action.targetBlockId);
521
+ continue;
522
+ }
523
+ if (action.kind === "replace-atom") {
524
+ const from = transaction.mapping.map(action.from, 1);
525
+ const to = transaction.mapping.map(action.to, -1);
526
+ const node = transaction.doc.nodeAt(from);
527
+ if (!node || node.nodeSize !== to - from || node.type.name !== "field") return { status: "unalignable" };
528
+ if (action.atomDisposition === "inserted") transaction.delete(from, to);
529
+ else transaction.addMark(from, to, deletionType.create({
530
+ revisionId: nextRevisionId++,
531
+ author,
532
+ date: revisionStamp.date
533
+ }));
534
+ const at = transaction.mapping.map(action.from, -1);
535
+ transaction.insert(at, state.schema.text(action.text, insertionType.create({
536
+ revisionId: nextRevisionId++,
537
+ author,
538
+ date: revisionStamp.date
539
+ }).addToSet(action.marks)));
540
+ if (action.targetBlockId) changedTargetBlockIds.add(action.targetBlockId);
541
+ continue;
542
+ }
350
543
  const from = transaction.mapping.map(action.from, 1);
351
544
  const to = transaction.mapping.map(action.to, -1);
352
545
  const node = transaction.doc.nodeAt(from);
@@ -372,25 +565,19 @@ const matchInlineAtoms = ({ state, targetSnapshot, revisionStamp, originalRevisi
372
565
  transaction,
373
566
  nextRevisionId,
374
567
  changedTargetBlockIds: [...changedTargetBlockIds],
375
- rangeCount: actions.length
568
+ rangeCount
376
569
  };
377
570
  };
378
571
  /** Compare supported inline atom identity after accept or reject projection. */
379
572
  const sameInlineAtoms = (leftDocument, rightDocument) => {
380
573
  if (!hasSupportedAtom(leftDocument) && !hasSupportedAtom(rightDocument)) return true;
381
- const leftBlocks = atomBlocksOf(leftDocument);
382
- const rightBlocks = atomBlocksOf(rightDocument);
574
+ const leftBlocks = atomBlocksOf(leftDocument, "omitted");
575
+ const rightBlocks = atomBlocksOf(rightDocument, "omitted");
383
576
  if (leftBlocks.length !== rightBlocks.length) return false;
384
577
  return leftBlocks.every((left, index) => {
385
578
  const right = rightBlocks[index];
386
579
  if (left.supported.length === 0 && right?.supported.length === 0) return true;
387
- return right !== void 0 && sameBlockTopology(left, right) && canonicalJson(left.supported.map(({ offset, key }) => ({
388
- offset,
389
- key
390
- }))) === canonicalJson(right.supported.map(({ offset, key }) => ({
391
- offset,
392
- key
393
- })));
580
+ return right !== void 0 && left.cleanText === right.cleanText && sameSupportedAtoms(left, right);
394
581
  });
395
582
  };
396
583
  //#endregion
@@ -12,6 +12,7 @@ const boundaryChildrenOf = (document) => {
12
12
  let hasChangeHistory = false;
13
13
  document.forEach((node, position) => {
14
14
  if (node.type.name !== "paragraph") {
15
+ if (node.type.name === "preservedBlock") return;
15
16
  children.push({
16
17
  type: "other",
17
18
  nodeType: node.type.name
@@ -162,11 +162,9 @@ function mapBlock(block, match) {
162
162
  ...row,
163
163
  cells: row.cells.map((cell) => {
164
164
  const transformed = transformBlocks(cell.content, match);
165
- const cellContent = [];
166
- for (const child of transformed) if (child.type === "paragraph" || child.type === "table") cellContent.push(child);
167
165
  return {
168
166
  ...cell,
169
- content: cellContent
167
+ content: transformed
170
168
  };
171
169
  })
172
170
  }));
@@ -204,6 +204,13 @@ const withoutOrphanTableCellBlockMarkers = (blocks, validCommentIds) => {
204
204
  const withoutOrphanTableCellBlockMarker = (block, validCommentIds) => {
205
205
  if (block.type === "paragraph") return withoutOrphanParagraphMarkers(block, validCommentIds);
206
206
  if (block.type === "table") return withoutOrphanTableMarkers(block, validCommentIds);
207
+ if (block.type === "blockSdt") {
208
+ const content = withoutOrphanTableCellBlockMarkers(block.content, validCommentIds);
209
+ return content ? {
210
+ ...block,
211
+ content
212
+ } : block;
213
+ }
207
214
  return block;
208
215
  };
209
216
  const withoutOrphanParagraphMarkers = (paragraph, validCommentIds) => {
@@ -2,7 +2,9 @@ import { parseBlockContent } from "./blockContentParser.js";
2
2
  import { getParagraphText } from "./paragraphParser.js";
3
3
  import { getDefaultSectionProperties, parseSectionProperties } from "./sectionParser.js";
4
4
  import { parseStreamingXml } from "./streamingXmlParser.js";
5
- import { collectXmlnsDeclarations, findChild, getLocalName, parseXml } from "./xmlParser.js";
5
+ import { parseThemeColorAttribute } from "./themeColorAttribute.js";
6
+ import { captureVerbatimXml } from "./verbatimCapture.js";
7
+ import { WORDPROCESSINGML_NAMESPACE_URIS, collectXmlnsDeclarations, findChild, findChildByNamespaceUri, getAttributeByNamespaceUri, getLocalName, parseXml } from "./xmlParser.js";
6
8
  //#region src/docx/documentParser.ts
7
9
  /**
8
10
  * Regular expression to match template variables {{...}}
@@ -38,6 +40,8 @@ function extractAllTemplateVariables(content) {
38
40
  } else if (block.type === "table") {
39
41
  const tableVars = extractTableVariables(block);
40
42
  for (const v of tableVars) if (!variables.includes(v)) variables.push(v);
43
+ } else if (block.type === "blockSdt") {
44
+ for (const v of extractAllTemplateVariables(block.content)) if (!variables.includes(v)) variables.push(v);
41
45
  }
42
46
  return variables;
43
47
  }
@@ -52,6 +56,8 @@ function extractTableVariables(table) {
52
56
  } else if (cellContent.type === "table") {
53
57
  const nestedVars = extractTableVariables(cellContent);
54
58
  for (const v of nestedVars) if (!variables.includes(v)) variables.push(v);
59
+ } else if (cellContent.type === "blockSdt") {
60
+ for (const v of extractAllTemplateVariables(cellContent.content)) if (!variables.includes(v)) variables.push(v);
55
61
  }
56
62
  return variables;
57
63
  }
@@ -108,6 +114,28 @@ function canonicalizeLeadingBodySectionProperties(bodyElement, body) {
108
114
  }
109
115
  body.finalSectionProperties = nextProperties;
110
116
  }
117
+ /** Read the page background declared directly under `w:document`. */
118
+ function parseDocumentBackground(documentEl, context) {
119
+ const element = findChildByNamespaceUri(documentEl, WORDPROCESSINGML_NAMESPACE_URIS, "background");
120
+ if (!element) return;
121
+ const background = {};
122
+ const color = getAttributeByNamespaceUri(element, WORDPROCESSINGML_NAMESPACE_URIS, "color");
123
+ if (color === "auto") background.color = { auto: true };
124
+ else if (color !== null) background.color = { rgb: color };
125
+ const themeColor = parseThemeColorAttribute({
126
+ raw: getAttributeByNamespaceUri(element, WORDPROCESSINGML_NAMESPACE_URIS, "themeColor"),
127
+ element: element.name ?? "w:background",
128
+ context
129
+ });
130
+ if (themeColor !== void 0) background.themeColor = themeColor;
131
+ const themeTint = getAttributeByNamespaceUri(element, WORDPROCESSINGML_NAMESPACE_URIS, "themeTint");
132
+ if (themeTint !== null) background.themeTint = themeTint;
133
+ const themeShade = getAttributeByNamespaceUri(element, WORDPROCESSINGML_NAMESPACE_URIS, "themeShade");
134
+ if (themeShade !== null) background.themeShade = themeShade;
135
+ const drawing = findChildByNamespaceUri(element, WORDPROCESSINGML_NAMESPACE_URIS, "drawing");
136
+ if (drawing) background.drawing = { rawXml: captureVerbatimXml(drawing) };
137
+ return background;
138
+ }
111
139
  /**
112
140
  * Parse document.xml body content
113
141
  *
@@ -125,6 +153,8 @@ function parseDocumentBody(xml, styles = null, theme = null, numbering = null, r
125
153
  const streamed = parseStreamingXml(xml);
126
154
  const documentEl = ((streamed.status === "parsed" ? streamed.value : parseXml(xml)).elements ?? []).find((el) => el.type === "element" && (el.name === "w:document" || el.name?.endsWith(":document")));
127
155
  if (!documentEl) return result;
156
+ const background = parseDocumentBackground(documentEl, context);
157
+ if (background !== void 0) result.background = background;
128
158
  const bodyEl = findChild(documentEl, "w", "body");
129
159
  if (!bodyEl) return result;
130
160
  result.content = parseBlockContent(bodyEl, styles, theme, numbering, rels, media, {
@@ -144,8 +174,16 @@ function getAllParagraphs(body) {
144
174
  const paragraphs = [];
145
175
  for (const block of body.content) if (block.type === "paragraph") paragraphs.push(block);
146
176
  else if (block.type === "table") paragraphs.push(...getTableParagraphs(block));
177
+ else if (block.type === "blockSdt") paragraphs.push(...getParagraphsFromBlocks(block.content));
147
178
  return paragraphs;
148
179
  }
180
+ const getParagraphsFromBlocks = (blocks) => {
181
+ const paragraphs = [];
182
+ for (const block of blocks) if (block.type === "paragraph") paragraphs.push(block);
183
+ else if (block.type === "table") paragraphs.push(...getTableParagraphs(block));
184
+ else if (block.type === "blockSdt") paragraphs.push(...getParagraphsFromBlocks(block.content));
185
+ return paragraphs;
186
+ };
149
187
  /**
150
188
  * Get all paragraphs from a table (recursively)
151
189
  */
@@ -153,6 +191,7 @@ function getTableParagraphs(table) {
153
191
  const paragraphs = [];
154
192
  for (const row of table.rows) for (const cell of row.cells) for (const content of cell.content) if (content.type === "paragraph") paragraphs.push(content);
155
193
  else if (content.type === "table") paragraphs.push(...getTableParagraphs(content));
194
+ else if (content.type === "blockSdt") paragraphs.push(...getParagraphsFromBlocks(content.content));
156
195
  return paragraphs;
157
196
  }
158
197
  /**
@@ -163,9 +202,15 @@ function getAllTables(body) {
163
202
  for (const block of body.content) if (block.type === "table") {
164
203
  tables.push(block);
165
204
  tables.push(...getNestedTables(block));
166
- }
205
+ } else if (block.type === "blockSdt") tables.push(...getTablesFromBlocks(block.content));
167
206
  return tables;
168
207
  }
208
+ const getTablesFromBlocks = (blocks) => {
209
+ const tables = [];
210
+ for (const block of blocks) if (block.type === "table") tables.push(block, ...getNestedTables(block));
211
+ else if (block.type === "blockSdt") tables.push(...getTablesFromBlocks(block.content));
212
+ return tables;
213
+ };
169
214
  /**
170
215
  * Get nested tables from a table (recursively)
171
216
  */
@@ -174,7 +219,7 @@ function getNestedTables(table) {
174
219
  for (const row of table.rows) for (const cell of row.cells) for (const content of cell.content) if (content.type === "table") {
175
220
  tables.push(content);
176
221
  tables.push(...getNestedTables(content));
177
- }
222
+ } else if (content.type === "blockSdt") tables.push(...getTablesFromBlocks(content.content));
178
223
  return tables;
179
224
  }
180
225
  /**
@@ -184,8 +229,14 @@ function getDocumentText(body) {
184
229
  const lines = [];
185
230
  for (const block of body.content) if (block.type === "paragraph") lines.push(getParagraphText(block));
186
231
  else if (block.type === "table") lines.push(getTableText(block));
232
+ else if (block.type === "blockSdt") lines.push(getTextFromBlocks(block.content));
187
233
  return lines.join("\n");
188
234
  }
235
+ const getTextFromBlocks = (blocks) => blocks.flatMap((block) => {
236
+ if (block.type === "paragraph") return [getParagraphText(block)];
237
+ if (block.type === "table") return [getTableText(block)];
238
+ return block.type === "blockSdt" ? [getTextFromBlocks(block.content)] : [];
239
+ }).join("\n");
189
240
  /**
190
241
  * Get plain text from a table
191
242
  */
@@ -197,6 +248,7 @@ function getTableText(table) {
197
248
  const cellTexts = [];
198
249
  for (const content of cell.content) if (content.type === "paragraph") cellTexts.push(getParagraphText(content));
199
250
  else if (content.type === "table") cellTexts.push(getTableText(content));
251
+ else if (content.type === "blockSdt") cellTexts.push(getTextFromBlocks(content.content));
200
252
  rowTexts.push(cellTexts.join("\n"));
201
253
  }
202
254
  lines.push(rowTexts.join(" "));