@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
@@ -868,6 +868,11 @@ const taggedLayerValidator = (label) => (layer, path, issues) => {
868
868
  path: `${path}.${key}`,
869
869
  message: "Expected a string."
870
870
  });
871
+ const propertiesXml = layer["propertiesXml"];
872
+ if (typeof propertiesXml === "string" && !isSafePreservedChildXml(propertiesXml)) issues.push({
873
+ path: `${path}.propertiesXml`,
874
+ message: "Expected one bounded, well-formed XML element."
875
+ });
871
876
  for (const key of Object.keys(layer)) if (!TAGGED_LAYER_KEYS.has(key)) issues.push({
872
877
  path: `${path}.${key}`,
873
878
  message: `Unexpected ${label} wrapper property.`
@@ -894,6 +899,19 @@ const INLINE_WRAPPER_LAYER_VALIDATORS = {
894
899
  const inlineWrapperLayerValidator = (kind) => {
895
900
  return typeof kind === "string" ? INLINE_WRAPPER_LAYER_VALIDATORS[kind] : void 0;
896
901
  };
902
+ const inlineWrapperProperties = function* (layers) {
903
+ for (let index = 0; index < layers.length; index += 1) {
904
+ const layer = layers[index];
905
+ yield { xml: isRecord(layer) && typeof layer["propertiesXml"] === "string" ? layer["propertiesXml"] : "" };
906
+ }
907
+ };
908
+ const emptyHyperlinkWrapperProperties = function* (stacks) {
909
+ for (let stackIndex = 0; stackIndex < stacks.length; stackIndex += 1) {
910
+ const stack = stacks[stackIndex];
911
+ yield {};
912
+ if (Array.isArray(stack)) yield* inlineWrapperProperties(stack);
913
+ }
914
+ };
897
915
  const validateInlineWrapperStack = (value, path, issues) => {
898
916
  if (!Array.isArray(value)) {
899
917
  issues.push({
@@ -909,6 +927,13 @@ const validateInlineWrapperStack = (value, path, issues) => {
909
927
  });
910
928
  return;
911
929
  }
930
+ if (!isWithinPreservedMarkupBudget(inlineWrapperProperties(value))) {
931
+ issues.push({
932
+ path,
933
+ message: "Inline wrapper stack exceeds its resource budget."
934
+ });
935
+ return;
936
+ }
912
937
  for (const [index, layer] of value.entries()) {
913
938
  const layerPath = `${path}[${index}]`;
914
939
  if (!isRecord(layer)) {
@@ -950,6 +975,22 @@ const readInlineWrapperMarkAttrs = (mark) => {
950
975
  const issues = [];
951
976
  expectMarkType(mark, "inlineWrapper", issues);
952
977
  validateInlineWrapperStack(attrs["stack"], "inlineWrapper.attrs.stack", issues);
978
+ const hyperlinkIndex = attrs["_docxHyperlinkIndex"];
979
+ const stackStart = attrs["_docxInsideHyperlinkStackStart"];
980
+ const hasHyperlinkIndex = hyperlinkIndex !== void 0 && hyperlinkIndex !== null;
981
+ const hasStackStart = stackStart !== void 0 && stackStart !== null;
982
+ if (hasHyperlinkIndex !== hasStackStart) issues.push({
983
+ path: "inlineWrapper.attrs",
984
+ message: "Hyperlink wrapper provenance requires both an index and stack start."
985
+ });
986
+ else if (hasHyperlinkIndex && hasStackStart) {
987
+ validateNonNegativeInteger(hyperlinkIndex, "inlineWrapper.attrs._docxHyperlinkIndex", issues);
988
+ validateNonNegativeInteger(stackStart, "inlineWrapper.attrs._docxInsideHyperlinkStackStart", issues);
989
+ if (typeof stackStart === "number" && Array.isArray(attrs["stack"]) && stackStart >= attrs["stack"].length) issues.push({
990
+ path: "inlineWrapper.attrs._docxInsideHyperlinkStackStart",
991
+ message: "Expected a stack position inside the wrapper stack."
992
+ });
993
+ }
953
994
  return attrsResult(attrs, issues);
954
995
  };
955
996
  const expectInlineWrapperMarkAttrs = (mark) => expectCachedMarkAttrs(mark, inlineWrapperAttrsCache, readInlineWrapperMarkAttrs, "inline wrapper attrs");
@@ -1745,7 +1786,7 @@ const optionalPositionedBookmarks = (attrs, path, issues) => {
1745
1786
  });
1746
1787
  continue;
1747
1788
  }
1748
- requiredNumber(entry, "index", `${entryPath}.index`, issues);
1789
+ validateNonNegativeSafeInteger(entry["index"], `${entryPath}.index`, issues);
1749
1790
  const marker = entry["marker"];
1750
1791
  if (!isRecord(marker)) {
1751
1792
  issues.push({
@@ -1762,7 +1803,7 @@ const optionalPositionedBookmarks = (attrs, path, issues) => {
1762
1803
  });
1763
1804
  continue;
1764
1805
  }
1765
- requiredNumber(marker, "id", `${entryPath}.marker.id`, issues);
1806
+ validateNonNegativeSafeInteger(marker["id"], `${entryPath}.marker.id`, issues);
1766
1807
  if (type === "bookmarkStart") requiredString(marker, "name", `${entryPath}.marker.name`, issues);
1767
1808
  optionalContentControls(entry, `${entryPath}.contentControls`, issues);
1768
1809
  }
@@ -2574,11 +2615,17 @@ const validateNonNegativeInteger = (value, path, issues) => {
2574
2615
  });
2575
2616
  return;
2576
2617
  }
2577
- if (!Number.isInteger(value) || value < 0) issues.push({
2618
+ if (!Number.isSafeInteger(value) || value < 0) issues.push({
2578
2619
  path,
2579
2620
  message: "Expected a non-negative integer."
2580
2621
  });
2581
2622
  };
2623
+ const validateNonNegativeSafeInteger = (value, path, issues) => {
2624
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) issues.push({
2625
+ path,
2626
+ message: "Expected a non-negative safe integer."
2627
+ });
2628
+ };
2582
2629
  const optionalBookmarkArray = (value, issues) => {
2583
2630
  if (value === void 0 || value === null) return;
2584
2631
  if (!Array.isArray(value)) {
@@ -2633,6 +2680,23 @@ const optionalEmptyHyperlinkArray = (value, issues) => {
2633
2680
  optionalString(item, "target", `${itemPath}.target`, issues);
2634
2681
  optionalBoolean(item, "history", `${itemPath}.history`, issues);
2635
2682
  optionalString(item, "docLocation", `${itemPath}.docLocation`, issues);
2683
+ const wrapperStacks = item["_docxEmptyWrapperStacks"];
2684
+ if (wrapperStacks === void 0 || wrapperStacks === null) continue;
2685
+ if (!Array.isArray(wrapperStacks)) {
2686
+ issues.push({
2687
+ path: `${itemPath}._docxEmptyWrapperStacks`,
2688
+ message: "Expected an array of inline wrapper stacks."
2689
+ });
2690
+ continue;
2691
+ }
2692
+ if (!isWithinPreservedMarkupBudget(emptyHyperlinkWrapperProperties(wrapperStacks))) {
2693
+ issues.push({
2694
+ path: `${itemPath}._docxEmptyWrapperStacks`,
2695
+ message: "Empty inline wrapper stacks exceed their aggregate resource budget."
2696
+ });
2697
+ continue;
2698
+ }
2699
+ for (const [stackIndex, stack] of wrapperStacks.entries()) validateInlineWrapperStack(stack, `${itemPath}._docxEmptyWrapperStacks[${stackIndex}]`, issues);
2636
2700
  }
2637
2701
  };
2638
2702
  //#endregion
@@ -8,9 +8,9 @@ const readCommentReferenceAttrs = (node) => {
8
8
  message: `Expected commentReference, got ${node.type.name}.`
9
9
  });
10
10
  const commentId = node.attrs["commentId"];
11
- if (typeof commentId !== "number" || !Number.isInteger(commentId) || commentId < 0) issues.push({
11
+ if (typeof commentId !== "number" || !Number.isSafeInteger(commentId)) issues.push({
12
12
  path: "commentReference.attrs.commentId",
13
- message: "Expected a non-negative integer."
13
+ message: "Expected a safe integer."
14
14
  });
15
15
  if (issues.length > 0 || typeof commentId !== "number") return {
16
16
  ok: false,
@@ -10,13 +10,17 @@
10
10
  * a comment mark creates none — so the repair is computed here and applied by
11
11
  * the extension's `appendTransaction`, rather than left to each call site.
12
12
  */
13
- type CommentReferenceOccurrence = {
14
- commentId: number;
13
+ type PositionedReference = {
15
14
  position: number;
16
15
  /** Node size, so a caller can delete the node without re-reading it. */
17
16
  size: number;
18
- malformed: boolean;
19
17
  };
18
+ type CommentReferenceOccurrence = (PositionedReference & {
19
+ status: "valid";
20
+ commentId: number;
21
+ }) | (PositionedReference & {
22
+ status: "malformed";
23
+ });
20
24
  /** Where a comment's mark last covers content, and how far that content runs. */
21
25
  type CommentMarkExtent = {
22
26
  commentId: number;
@@ -10,7 +10,15 @@ const planCommentReferenceRepairs = ({ references, markExtents }) => {
10
10
  const deletions = [];
11
11
  const keptIds = /* @__PURE__ */ new Set();
12
12
  for (const reference of references) {
13
- if (reference.malformed || !markedIds.has(reference.commentId)) {
13
+ if (reference.status === "malformed") {
14
+ deletions.push({
15
+ type: "delete",
16
+ position: reference.position,
17
+ size: reference.size
18
+ });
19
+ continue;
20
+ }
21
+ if (!markedIds.has(reference.commentId)) {
14
22
  deletions.push({
15
23
  type: "delete",
16
24
  position: reference.position,
@@ -272,6 +272,7 @@ function fromProseDoc(pmDoc, baseDocument, { reuse = "none" } = {}) {
272
272
  if (tokenSources) restoreParagraphPropertySourcesByToken(blocks, tokenSources);
273
273
  else if (baseDocument) restoreParagraphPropertySources(blocks, baseDocument.package.document.content, linkedSources.targets, linkedSources.sources);
274
274
  const documentBody = { content: blocks };
275
+ if (baseDocument?.package.document.background) documentBody.background = baseDocument.package.document.background;
275
276
  if (baseDocument?.package.document.finalSectionProperties) documentBody.finalSectionProperties = baseDocument.package.document.finalSectionProperties;
276
277
  if (baseDocument?.package.document.sections) documentBody.sections = baseDocument.package.document.sections;
277
278
  if (baseDocument?.package.document.comments) documentBody.comments = baseDocument.package.document.comments;
@@ -1137,6 +1138,17 @@ const inlineWrapperStackOf = (node) => {
1137
1138
  const mark = node.marks.find((candidate) => candidate.type.name === INLINE_WRAPPER_MARK_NAME);
1138
1139
  return mark ? expectInlineWrapperMarkAttrs(mark).stack : [];
1139
1140
  };
1141
+ /** The authored hyperlink that owns a suffix of the node's wrapper stack. */
1142
+ const inlineWrapperHyperlinkOriginOf = (node) => {
1143
+ const mark = node.marks.find((candidate) => candidate.type.name === INLINE_WRAPPER_MARK_NAME);
1144
+ if (!mark) return;
1145
+ const attrs = expectInlineWrapperMarkAttrs(mark);
1146
+ if (typeof attrs._docxHyperlinkIndex !== "number" || typeof attrs._docxInsideHyperlinkStackStart !== "number") return;
1147
+ return {
1148
+ hyperlinkIndex: attrs._docxHyperlinkIndex,
1149
+ stackStart: attrs._docxInsideHyperlinkStackStart
1150
+ };
1151
+ };
1140
1152
  const isRevisionWrapper = (item) => item.type === "insertion" || item.type === "deletion" || item.type === "moveFrom" || item.type === "moveTo";
1141
1153
  /** `stack` closed around `content`, outermost layer first. */
1142
1154
  const nestInlineWrappers = (stack, content) => {
@@ -1160,7 +1172,20 @@ const nestInlineWrappers = (stack, content) => {
1160
1172
  * A group with nothing in it writes no wrapper: an authored wrapper whose
1161
1173
  * content was deleted or rejected is gone, and an empty one says nothing.
1162
1174
  */
1163
- const nestInlineWrapperGroups = (items, groups) => {
1175
+ const nestInsideHyperlink = (item, stack, hyperlinkIndex, indices) => {
1176
+ if (item.type === "hyperlink") {
1177
+ if (indices.get(item) !== hyperlinkIndex) return false;
1178
+ item.children = [nestInlineWrappers(stack, item.children)];
1179
+ return true;
1180
+ }
1181
+ if (isRevisionWrapper(item)) {
1182
+ let nested = false;
1183
+ for (const child of item.content) if (nestInsideHyperlink(child, stack, hyperlinkIndex, indices)) nested = true;
1184
+ return nested;
1185
+ }
1186
+ return false;
1187
+ };
1188
+ const nestInlineWrapperGroups = (items, groups, hyperlinkIndices) => {
1164
1189
  const nested = [];
1165
1190
  for (const [index, group] of groups.entries()) {
1166
1191
  const slice = items.slice(group.start, groups[index + 1]?.start ?? items.length);
@@ -1168,10 +1193,22 @@ const nestInlineWrapperGroups = (items, groups) => {
1168
1193
  nested.push(...slice);
1169
1194
  continue;
1170
1195
  }
1196
+ const origin = group.origin;
1197
+ let outerStack = group.stack;
1198
+ if (origin !== void 0) {
1199
+ const innerStack = group.stack.slice(origin.stackStart);
1200
+ let nestedInsideOrigin = false;
1201
+ for (const item of slice) if (nestInsideHyperlink(item, innerStack, origin.hyperlinkIndex, hyperlinkIndices)) nestedInsideOrigin = true;
1202
+ if (nestedInsideOrigin) outerStack = group.stack.slice(0, origin.stackStart);
1203
+ }
1204
+ if (outerStack.length === 0) {
1205
+ nested.push(...slice);
1206
+ continue;
1207
+ }
1171
1208
  let pending = [];
1172
1209
  const closeNest = () => {
1173
1210
  if (pending.length === 0) return;
1174
- nested.push(nestInlineWrappers(group.stack, pending));
1211
+ nested.push(nestInlineWrappers(outerStack, pending));
1175
1212
  pending = [];
1176
1213
  };
1177
1214
  for (const item of slice) {
@@ -1180,7 +1217,7 @@ const nestInlineWrapperGroups = (items, groups) => {
1180
1217
  continue;
1181
1218
  }
1182
1219
  closeNest();
1183
- if (item.content.length > 0) item.content = [nestInlineWrappers(group.stack, item.content)];
1220
+ if (item.content.length > 0) item.content = [nestInlineWrappers(outerStack, item.content)];
1184
1221
  nested.push(item);
1185
1222
  }
1186
1223
  closeNest();
@@ -1214,7 +1251,14 @@ function extractParagraphContent(paragraph, _documentCounts, emptyHyperlinks, te
1214
1251
  let currentHyperlinkKey = null;
1215
1252
  let currentTrackedChange;
1216
1253
  const sourceRunOwners = /* @__PURE__ */ new WeakMap();
1254
+ const sourceHyperlinkIndices = /* @__PURE__ */ new WeakMap();
1217
1255
  const openedComments = /* @__PURE__ */ new Set();
1256
+ const createIndexedHyperlink = (mark) => {
1257
+ const hyperlink = createHyperlink(mark);
1258
+ const index = expectHyperlinkMarkAttrs(mark)._docxHyperlinkIndex;
1259
+ if (typeof index === "number") sourceHyperlinkIndices.set(hyperlink, index);
1260
+ return hyperlink;
1261
+ };
1218
1262
  const commentLastOffset = /* @__PURE__ */ new Map();
1219
1263
  paragraph.forEach((node, offset) => {
1220
1264
  for (const commentId of getCommentMarkIds(node.marks)) commentLastOffset.set(commentId, offset);
@@ -1298,14 +1342,20 @@ function extractParagraphContent(paragraph, _documentCounts, emptyHyperlinks, te
1298
1342
  */
1299
1343
  const enterWrapperGroup = (node) => {
1300
1344
  const stack = inlineWrapperStackOf(node);
1301
- const key = inlineWrapperStackKey(stack);
1345
+ const origin = inlineWrapperHyperlinkOriginOf(node);
1346
+ const key = [
1347
+ inlineWrapperStackKey(stack),
1348
+ origin?.hyperlinkIndex ?? "",
1349
+ origin?.stackStart ?? ""
1350
+ ].join("\0");
1302
1351
  if (key === wrapperGroupKey) return;
1303
1352
  flushCurrentInline();
1304
1353
  currentTrackedChange = void 0;
1305
1354
  wrapperGroupKey = key;
1306
1355
  wrapperGroups.push({
1307
1356
  start: content.length,
1308
- stack
1357
+ stack,
1358
+ ...origin ? { origin } : {}
1309
1359
  });
1310
1360
  };
1311
1361
  const processInlineNode = (node, offset) => {
@@ -1332,12 +1382,6 @@ function extractParagraphContent(paragraph, _documentCounts, emptyHyperlinks, te
1332
1382
  content.push({ ...start }, { ...end });
1333
1383
  return;
1334
1384
  }
1335
- if (node.type.name === "moveRangeBoundary") {
1336
- flushCurrentInline();
1337
- currentTrackedChange = void 0;
1338
- content.push({ ...expectMoveRangeBoundaryAttrs(node) });
1339
- return;
1340
- }
1341
1385
  const linkMark = node.marks.find((m) => m.type.name === "hyperlink");
1342
1386
  const noteRefMark = node.marks.find((m) => m.type.name === "footnoteRef");
1343
1387
  const insertionMark = node.marks.find((m) => m.type.name === "insertion");
@@ -1354,10 +1398,8 @@ function extractParagraphContent(paragraph, _documentCounts, emptyHyperlinks, te
1354
1398
  };
1355
1399
  if (textBoxAnchorMarkers.has(anchorId)) return;
1356
1400
  textBoxAnchorMarkers.set(anchorId, marker);
1357
- const anchoredContent = linkMark ? {
1358
- ...createHyperlink(linkMark),
1359
- children: [marker]
1360
- } : marker;
1401
+ const anchoredContent = linkMark ? createIndexedHyperlink(linkMark) : marker;
1402
+ if (anchoredContent.type === "hyperlink") anchoredContent.children.push(marker);
1361
1403
  const changeMark = insertionMark ?? deletionMark;
1362
1404
  if (!changeMark) {
1363
1405
  content.push(anchoredContent);
@@ -1409,7 +1451,7 @@ function extractParagraphContent(paragraph, _documentCounts, emptyHyperlinks, te
1409
1451
  if (linkMark) {
1410
1452
  const linkKey = getLinkKey(linkMark);
1411
1453
  if (!currentTrackedChange || currentTrackedChange.type !== "hyperlink" || currentTrackedChange.key !== trackedChangeKey || currentTrackedChange.hyperlinkKey !== linkKey) {
1412
- const hyperlink = createHyperlink(linkMark);
1454
+ const hyperlink = createIndexedHyperlink(linkMark);
1413
1455
  const wrapper = createTrackedRunWrapper(type, info, hyperlink);
1414
1456
  content.push(wrapper);
1415
1457
  currentTrackedChange = {
@@ -1455,6 +1497,10 @@ function extractParagraphContent(paragraph, _documentCounts, emptyHyperlinks, te
1455
1497
  currentTrackedChange.wrapper.content.push(bookmarkMarkerFromAttrs(expectBookmarkBoundaryAttrs(node)));
1456
1498
  return;
1457
1499
  }
1500
+ if (node.type.name === "moveRangeBoundary") {
1501
+ currentTrackedChange.wrapper.content.push({ ...expectMoveRangeBoundaryAttrs(node) });
1502
+ return;
1503
+ }
1458
1504
  if (node.type.name === "field" || node.type.name === "structuredField") {
1459
1505
  currentTrackedChange.wrapper.content.push(createFieldFromNode(node, {
1460
1506
  ...formattingContext,
@@ -1488,6 +1534,12 @@ function extractParagraphContent(paragraph, _documentCounts, emptyHyperlinks, te
1488
1534
  appendDirectRun(node, ownedRun, node.isText);
1489
1535
  return;
1490
1536
  }
1537
+ if (node.type.name === "moveRangeBoundary") {
1538
+ flushCurrentInline();
1539
+ currentTrackedChange = void 0;
1540
+ content.push({ ...expectMoveRangeBoundaryAttrs(node) });
1541
+ return;
1542
+ }
1491
1543
  if (noteRefMark && !linkMark) {
1492
1544
  flushCurrentInline();
1493
1545
  content.push(createNoteReferenceRun(noteRefMark, node.marks, formattingContext));
@@ -1497,7 +1549,7 @@ function extractParagraphContent(paragraph, _documentCounts, emptyHyperlinks, te
1497
1549
  const linkKey = getLinkKey(linkMark);
1498
1550
  if (currentHyperlink && currentHyperlinkKey === linkKey) {} else {
1499
1551
  flushCurrentInline();
1500
- currentHyperlink = createHyperlink(linkMark);
1552
+ currentHyperlink = createIndexedHyperlink(linkMark);
1501
1553
  currentHyperlinkKey = linkKey;
1502
1554
  }
1503
1555
  addNodeToHyperlink({
@@ -1588,7 +1640,7 @@ function extractParagraphContent(paragraph, _documentCounts, emptyHyperlinks, te
1588
1640
  type: "commentRangeEnd",
1589
1641
  id: commentId
1590
1642
  });
1591
- return nestInlineWrapperGroups(content, wrapperGroups);
1643
+ return nestInlineWrapperGroups(content, wrapperGroups, sourceHyperlinkIndices);
1592
1644
  }
1593
1645
  function createTrackedChangeRun({ baseParagraphFormatting, inheritedFormatting, marks, node, paragraphMarkFormatting, paragraphMarkPrecedesStyle, styleResolver }) {
1594
1646
  const formattingContext = {
@@ -1646,6 +1698,7 @@ function createEmptyHyperlink(attrs) {
1646
1698
  if (attrs.target !== void 0) hyperlink.target = attrs.target;
1647
1699
  if (attrs.history !== void 0) hyperlink.history = attrs.history;
1648
1700
  if (attrs.docLocation !== void 0) hyperlink.docLocation = attrs.docLocation;
1701
+ for (const stack of attrs._docxEmptyWrapperStacks ?? []) hyperlink.children.push(nestInlineWrappers(stack, []));
1649
1702
  return hyperlink;
1650
1703
  }
1651
1704
  function getCommentMarkIds(marks) {
@@ -3298,6 +3351,9 @@ function convertPMTableCell(node, documentCounts, styleResolver = null) {
3298
3351
  } else if (contentNode.type.name === "table") {
3299
3352
  content.push(convertPMTable(contentNode, documentCounts, styleResolver));
3300
3353
  previousStandaloneTextBox = null;
3354
+ } else if (contentNode.type.name === "blockSdt") {
3355
+ content.push(convertPMBlockSdt(contentNode, styleResolver));
3356
+ previousStandaloneTextBox = null;
3301
3357
  } else if (contentNode.type.name === "textBox") previousStandaloneTextBox = appendTextBoxBlock(content, contentNode, {
3302
3358
  pendingPageBreaks: 0,
3303
3359
  previousStandaloneTextBox,
@@ -486,6 +486,7 @@ function convertParagraph(paragraph, styleResolver, context, extraRunFormatting,
486
486
  nextRunIdentityId
487
487
  });
488
488
  if (linkNodes.length === 0) {
489
+ const emptyWrapperStacks = emptyInlineWrapperStacks(content.children);
489
490
  emptyHyperlinks ??= [];
490
491
  emptyHyperlinks.push({
491
492
  offset: inlineOffset,
@@ -495,7 +496,8 @@ function convertParagraph(paragraph, styleResolver, context, extraRunFormatting,
495
496
  ...content.rId !== void 0 ? { rId: content.rId } : {},
496
497
  ...content.target !== void 0 ? { target: content.target } : {},
497
498
  ...content.history !== void 0 ? { history: content.history } : {},
498
- ...content.docLocation !== void 0 ? { docLocation: content.docLocation } : {}
499
+ ...content.docLocation !== void 0 ? { docLocation: content.docLocation } : {},
500
+ ...emptyWrapperStacks.length === 0 ? {} : { _docxEmptyWrapperStacks: emptyWrapperStacks }
499
501
  });
500
502
  break;
501
503
  }
@@ -667,6 +669,7 @@ function convertTrackedChange(change, markType, nextHyperlinkInstanceIndex, next
667
669
  id: item.id,
668
670
  displacedByCustomXml: item.displacedByCustomXml
669
671
  }));
672
+ else if (item.type === "moveFromRangeStart" || item.type === "moveFromRangeEnd" || item.type === "moveToRangeStart" || item.type === "moveToRangeEnd") itemNodes.push(schema.node(MOVE_RANGE_BOUNDARY_NODE_NAME, { marker: item }));
670
673
  else if (item.type === "preservedInline") itemNodes.push(preservedInlineNode(item));
671
674
  else panic(`Unsupported tracked-run content: ${JSON.stringify(item)}`);
672
675
  nodes.push(...withInlineWrapperMark(itemNodes, stack));
@@ -1035,6 +1038,7 @@ function blockHasMeaningfulContent(block) {
1035
1038
  if (block.type === "preservedBlock") return true;
1036
1039
  if (block.type === "bookmarkStart" || block.type === "bookmarkEnd") return true;
1037
1040
  if (block.type === "table") return block.rows.some((row) => row.cells.some((cell) => tableCellHasMeaningfulContent(cell)));
1041
+ if (block.type === "blockSdt") return true;
1038
1042
  return block.content.some(paragraphContentHasMeaningfulContent);
1039
1043
  }
1040
1044
  function paragraphContentHasMeaningfulContent(content) {
@@ -1394,28 +1398,35 @@ function convertTableCell({ cell, styleResolver, context, isHeader, gridWidthPer
1394
1398
  }
1395
1399
  if (preserveVMergeRestart) attrs._preserveVMergeRestart = true;
1396
1400
  if (vMergeContinuationCells && vMergeContinuationCells.length > 0) attrs._docxVMergeContinuationCells = transportTableCellsWithParagraphPropertySources(vMergeContinuationCells);
1397
- const contentNodes = [];
1398
- for (const content of cell.content) switch (content.type) {
1399
- case "paragraph":
1400
- contentNodes.push(...convertParagraphWithTextBoxes(content, styleResolver, {
1401
- textBoxGroupId: context.nextTextBoxGroupId(),
1402
- context,
1403
- ...conditionalStyle?.rPr !== void 0 ? { extraRunFormatting: conditionalStyle.rPr } : {},
1404
- ...conditionalStyle?.pPr !== void 0 ? { tableParagraphOverlay: conditionalStyle.pPr } : {}
1405
- }));
1406
- break;
1407
- case "table":
1408
- contentNodes.push(convertTable(content, styleResolver, context));
1409
- break;
1410
- case "preservedBlock":
1411
- contentNodes.push(convertPreservedBlock(content));
1412
- break;
1413
- case "bookmarkStart":
1414
- case "bookmarkEnd":
1415
- contentNodes.push(convertBlockBookmarkBoundary(content));
1416
- break;
1417
- default: panic(`Unsupported table cell content: ${JSON.stringify(content)}`);
1418
- }
1401
+ const convertCellBlocks = (blocks) => {
1402
+ const nodes = [];
1403
+ for (const block of blocks) switch (block.type) {
1404
+ case "paragraph":
1405
+ nodes.push(...convertParagraphWithTextBoxes(block, styleResolver, {
1406
+ textBoxGroupId: context.nextTextBoxGroupId(),
1407
+ context,
1408
+ ...conditionalStyle?.rPr !== void 0 ? { extraRunFormatting: conditionalStyle.rPr } : {},
1409
+ ...conditionalStyle?.pPr !== void 0 ? { tableParagraphOverlay: conditionalStyle.pPr } : {}
1410
+ }));
1411
+ break;
1412
+ case "table":
1413
+ nodes.push(convertTable(block, styleResolver, context));
1414
+ break;
1415
+ case "blockSdt":
1416
+ nodes.push(convertBlockSdt(block, convertCellBlocks));
1417
+ break;
1418
+ case "preservedBlock":
1419
+ nodes.push(convertPreservedBlock(block));
1420
+ break;
1421
+ case "bookmarkStart":
1422
+ case "bookmarkEnd":
1423
+ nodes.push(convertBlockBookmarkBoundary(block));
1424
+ break;
1425
+ default: panic(`Unsupported table cell content: ${JSON.stringify(block)}`);
1426
+ }
1427
+ return nodes;
1428
+ };
1429
+ const contentNodes = convertCellBlocks(cell.content);
1419
1430
  if (contentNodes.length === 0) contentNodes.push(schema.node("paragraph", {}, []));
1420
1431
  const nodeType = isHeader ? "tableHeader" : "tableCell";
1421
1432
  return schema.node(nodeType, attrs, contentNodes);
@@ -1527,27 +1538,32 @@ const withInlineWrapperStacks = (content, stack = []) => content.flatMap((item)
1527
1538
  content: item,
1528
1539
  stack
1529
1540
  }]);
1530
- /**
1531
- * `nodes` with `stack` recorded outside whatever wrapper they already carry.
1532
- *
1533
- * A revision converts its own content first, so a node that arrives here
1534
- * already marked sat inside the item this stack wraps: the two stacks
1535
- * concatenate, outermost first. Concatenating is what the schema forces — the
1536
- * mark excludes itself, so a second one replaces the first, and the inner
1537
- * wrapper would be the one lost.
1538
- *
1539
- * A node that holds its own leaves, a content control, is marked here as the
1540
- * one node it is; its leaves were marked with the whole enclosing stack when
1541
- * it was built, because the painter reads the wrapper off the leaf.
1542
- */
1543
- const withInlineWrapperMark = (nodes, stack) => {
1541
+ /** Wrapper nests with no leaf to carry their stack as a ProseMirror mark. */
1542
+ const emptyInlineWrapperStacks = (content, stack = []) => content.flatMap((item) => {
1543
+ if (item.type !== "inlineWrapper") return [];
1544
+ const nested = [...stack, inlineWrapperLayer(item)];
1545
+ return item.content.length === 0 ? [nested] : emptyInlineWrapperStacks(item.content, nested);
1546
+ });
1547
+ const withInlineWrapperMark = (nodes, stack, origin) => {
1544
1548
  const markType = schema.marks["inlineWrapper"];
1545
1549
  if (stack.length === 0 || !markType) return nodes;
1546
1550
  return nodes.map((node) => {
1547
- if (!node.isText && (!node.isInline || !node.type.allowsMarkType(markType))) return node;
1551
+ if (!node.isInline) return node;
1548
1552
  const inner = node.marks.find((mark) => mark.type === markType);
1549
- const layers = inner === void 0 ? stack : [...stack, ...expectInlineWrapperMarkAttrs(inner).stack];
1550
- return node.mark(markType.create({ stack: layers }).addToSet(node.marks));
1553
+ const innerAttrs = inner === void 0 ? void 0 : expectInlineWrapperMarkAttrs(inner);
1554
+ const layers = innerAttrs === void 0 ? stack : [...stack, ...innerAttrs.stack];
1555
+ const inheritedOrigin = typeof innerAttrs?._docxHyperlinkIndex !== "number" || typeof innerAttrs._docxInsideHyperlinkStackStart !== "number" ? void 0 : {
1556
+ hyperlinkIndex: innerAttrs._docxHyperlinkIndex,
1557
+ stackStart: stack.length + innerAttrs._docxInsideHyperlinkStackStart
1558
+ };
1559
+ const provenance = origin ?? inheritedOrigin;
1560
+ return node.mark(markType.create({
1561
+ stack: layers,
1562
+ ...provenance === void 0 ? {} : {
1563
+ _docxHyperlinkIndex: provenance.hyperlinkIndex,
1564
+ _docxInsideHyperlinkStackStart: provenance.stackStart
1565
+ }
1566
+ }).addToSet(node.marks));
1551
1567
  });
1552
1568
  };
1553
1569
  /**
@@ -1570,9 +1586,17 @@ function convertMathEquation(math) {
1570
1586
  function convertInlineSdt(sdt, nextHyperlinkInstanceIndex, nextRunIdentityId, getInheritedRunFormatting, trackedRunFormattingResolvers, styleResolver, textBoxAnchors, wrappedBy = []) {
1571
1587
  const props = sdt.properties;
1572
1588
  const inlineNodes = [];
1573
- for (const { content, stack } of withInlineWrapperStacks(sdt.content, wrappedBy)) {
1589
+ const stacked = withInlineWrapperStacks(sdt.content, wrappedBy);
1590
+ const emptyRanges = planEmptyRanges(stacked.map(({ content }) => content));
1591
+ for (const [index, { content, stack }] of stacked.entries()) {
1574
1592
  if (!isInlineSdtContent(content)) continue;
1575
1593
  const itemNodes = [];
1594
+ const anchored = emptyRanges.anchorAt.get(index);
1595
+ if (anchored) {
1596
+ inlineNodes.push(...withInlineWrapperMark([schema.node(RANGE_ANCHOR_NODE_NAME, anchored)], stack));
1597
+ continue;
1598
+ }
1599
+ if (emptyRanges.closedAt.has(index)) continue;
1576
1600
  switch (content.type) {
1577
1601
  case "run":
1578
1602
  itemNodes.push(...convertRun(content, getInheritedRunFormatting(content.formatting), nextRunIdentityId, styleResolver, textBoxAnchors));
@@ -1633,6 +1657,12 @@ function convertInlineSdt(sdt, nextHyperlinkInstanceIndex, nextRunIdentityId, ge
1633
1657
  displacedByCustomXml: content.displacedByCustomXml
1634
1658
  }));
1635
1659
  break;
1660
+ case "moveFromRangeStart":
1661
+ case "moveFromRangeEnd":
1662
+ case "moveToRangeStart":
1663
+ case "moveToRangeEnd":
1664
+ itemNodes.push(schema.node(MOVE_RANGE_BOUNDARY_NODE_NAME, { marker: content }));
1665
+ break;
1636
1666
  case "preservedInline":
1637
1667
  itemNodes.push(preservedInlineNode(content));
1638
1668
  break;
@@ -2331,7 +2361,10 @@ function convertHyperlink(hyperlink, { getInheritedRunFormatting, styleResolver,
2331
2361
  }
2332
2362
  default: break;
2333
2363
  }
2334
- nodes.push(...withInlineWrapperMark(childNodes, stack));
2364
+ nodes.push(...withInlineWrapperMark(childNodes, stack, {
2365
+ hyperlinkIndex,
2366
+ stackStart: 0
2367
+ }));
2335
2368
  }
2336
2369
  return nodes;
2337
2370
  }
@@ -7,6 +7,7 @@ import { panic } from "better-result";
7
7
  /**
8
8
  * Hyperlink Mark Extension
9
9
  */
10
+ const DOCX_HYPERLINK_INDEX_ATTRIBUTE = "data-docx-hyperlink-index";
10
11
  /**
11
12
  * Sanitize an href arriving from (or being emitted to) the DOM: pasted HTML,
12
13
  * clipboard content, or a mark attr already stored on the document. Only
@@ -90,19 +91,25 @@ const HyperlinkExtension = createMarkExtension({
90
91
  inclusive: false,
91
92
  parseDOM: [{
92
93
  tag: "a[href]",
93
- getAttrs: (dom) => ({
94
- href: sanitizeStoredHref(dom.getAttribute("href") ?? void 0),
95
- tooltip: dom.getAttribute("title") ?? void 0,
96
- target: dom.getAttribute("target") ?? void 0
97
- })
94
+ getAttrs: (dom) => {
95
+ const index = dom.getAttribute(DOCX_HYPERLINK_INDEX_ATTRIBUTE);
96
+ const docxHyperlinkIndex = index !== null && /^\d+$/u.test(index) && Number.isSafeInteger(Number(index)) ? Number(index) : void 0;
97
+ return {
98
+ href: sanitizeStoredHref(dom.getAttribute("href") ?? void 0),
99
+ tooltip: dom.getAttribute("title") ?? void 0,
100
+ target: dom.getAttribute("target") ?? void 0,
101
+ ...docxHyperlinkIndex === void 0 ? {} : { _docxHyperlinkIndex: docxHyperlinkIndex }
102
+ };
103
+ }
98
104
  }],
99
105
  toDOM(mark) {
100
- const { href, target, tooltip } = expectHyperlinkMarkAttrs(mark);
106
+ const { href, target, tooltip, _docxHyperlinkIndex } = expectHyperlinkMarkAttrs(mark);
101
107
  const domAttrs = {
102
108
  href: sanitizeStoredHref(href),
103
109
  ...anchorTargetAttrs(target)
104
110
  };
105
111
  if (tooltip) domAttrs["title"] = tooltip;
112
+ if (typeof _docxHyperlinkIndex === "number") domAttrs[DOCX_HYPERLINK_INDEX_ATTRIBUTE] = String(_docxHyperlinkIndex);
106
113
  return [
107
114
  "a",
108
115
  domAttrs,