@stll/folio-core 0.53.0 → 0.54.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 (148) hide show
  1. package/dist/ai-edits/apply.js +390 -166
  2. package/dist/ai-edits/batch-claims.d.ts +21 -6
  3. package/dist/ai-edits/batch-claims.js +47 -16
  4. package/dist/ai-edits/headless.d.ts +20 -0
  5. package/dist/ai-edits/headless.js +140 -11
  6. package/dist/ai-edits/pending-suggestions.js +1 -1
  7. package/dist/ai-edits/read.d.ts +5 -3
  8. package/dist/ai-edits/read.js +97 -47
  9. package/dist/ai-edits/revisionStretches.d.ts +17 -0
  10. package/dist/ai-edits/revisionStretches.js +80 -0
  11. package/dist/ai-edits/snapshot.js +3 -2
  12. package/dist/ai-edits/table-cell-mutations.d.ts +3 -1
  13. package/dist/ai-edits/table-cell-mutations.js +2 -1
  14. package/dist/ai-edits/table-row-column-mutations.d.ts +6 -1
  15. package/dist/ai-edits/table-row-column-mutations.js +27 -10
  16. package/dist/compare/content-alignment.d.ts +5 -1
  17. package/dist/compare/content-alignment.js +39 -32
  18. package/dist/compare/inline-provenance.js +1 -1
  19. package/dist/controller/layoutPipeline.js +3 -2
  20. package/dist/docx/archiveInflation.d.ts +127 -0
  21. package/dist/docx/archiveInflation.js +181 -0
  22. package/dist/docx/metadataPrivacy.js +24 -1
  23. package/dist/docx/noteReferenceMark.d.ts +15 -0
  24. package/dist/docx/noteReferenceMark.js +48 -0
  25. package/dist/docx/paragraphParser.js +5 -3
  26. package/dist/docx/paragraphPropertySource.d.ts +1 -1
  27. package/dist/docx/paragraphPropertySource.js +3 -1
  28. package/dist/docx/parser.js +2 -2
  29. package/dist/docx/rezip.js +4 -2
  30. package/dist/docx/selectiveXmlPatch.d.ts +3 -1
  31. package/dist/docx/selectiveXmlPatch.js +81 -3
  32. package/dist/docx/server/boundedArchive.d.ts +8 -13
  33. package/dist/docx/server/boundedArchive.js +70 -59
  34. package/dist/docx/server/extractDocxText.d.ts +8 -2
  35. package/dist/docx/server/extractDocxText.js +2 -2
  36. package/dist/docx/server/validateDocxConformance.d.ts +1 -1
  37. package/dist/docx/server/validateDocxConformance.js +1 -0
  38. package/dist/docx/unzip.d.ts +19 -2
  39. package/dist/docx/unzip.js +135 -24
  40. package/dist/fonts/embeddedFonts.js +1 -1
  41. package/dist/i18n/messages/catalogs.gen.d.ts +34 -34
  42. package/dist/i18n/messages/catalogs.gen.js +34 -34
  43. package/dist/i18n/messages/messages.gen.d.ts +2 -2
  44. package/dist/internal/wholeStoryRevisionResolution.js +95 -25
  45. package/dist/layout-engine/measure/measureParagraph.js +2 -2
  46. package/dist/layout-engine/measure/paragraphMeasureShared.d.ts +12 -1
  47. package/dist/layout-engine/measure/paragraphMeasureShared.js +14 -1
  48. package/dist/markdown/escape.d.ts +2 -2
  49. package/dist/markdown/escape.js +4 -5
  50. package/dist/markdown/renderBlock.js +91 -25
  51. package/dist/markdown/renderRuns.d.ts +10 -1
  52. package/dist/markdown/renderRuns.js +320 -59
  53. package/dist/prosemirror/clearRunColor.d.ts +6 -0
  54. package/dist/prosemirror/clearRunColor.js +46 -0
  55. package/dist/prosemirror/commands/comments.js +56 -21
  56. package/dist/prosemirror/commands/formatPainter.js +1 -1
  57. package/dist/prosemirror/commands/hyperlink.js +2 -1
  58. package/dist/prosemirror/commands/propertyChangeScope.d.ts +3 -2
  59. package/dist/prosemirror/commands/propertyChangeScope.js +24 -3
  60. package/dist/prosemirror/commands/resolveAllTableChanges.js +46 -41
  61. package/dist/prosemirror/commands/resolveParagraphProperties.js +20 -6
  62. package/dist/prosemirror/commands/tableCellMergeResolution.d.ts +8 -1
  63. package/dist/prosemirror/commands/tableCellMergeResolution.js +23 -7
  64. package/dist/prosemirror/containerFinalParagraph.d.ts +11 -5
  65. package/dist/prosemirror/containerFinalParagraph.js +7 -6
  66. package/dist/prosemirror/conversion/fromProseDoc.d.ts +2 -13
  67. package/dist/prosemirror/conversion/fromProseDoc.js +12 -590
  68. package/dist/prosemirror/conversion/toProseDoc.d.ts +4 -11
  69. package/dist/prosemirror/conversion/toProseDoc.js +31 -150
  70. package/dist/prosemirror/documentSchema.d.ts +8 -0
  71. package/dist/prosemirror/documentSchema.js +18 -0
  72. package/dist/prosemirror/extensions/StarterKit.js +2 -0
  73. package/dist/prosemirror/extensions/core/ParagraphExtension.js +104 -63
  74. package/dist/prosemirror/extensions/features/BaseKeymapExtension.js +1 -1
  75. package/dist/prosemirror/extensions/features/EmptyParagraphFormatExtension.js +24 -3
  76. package/dist/prosemirror/extensions/features/JoinedRunStyleExtension.d.ts +20 -0
  77. package/dist/prosemirror/extensions/features/JoinedRunStyleExtension.js +43 -0
  78. package/dist/prosemirror/extensions/features/ListExtension.js +10 -9
  79. package/dist/prosemirror/extensions/features/pastedHeadingStyles.js +1 -1
  80. package/dist/prosemirror/extensions/features/pastedHtmlLists.js +2 -4
  81. package/dist/prosemirror/extensions/marks/FootnoteRefExtension.js +14 -8
  82. package/dist/prosemirror/extensions/marks/HyperlinkExtension.js +8 -4
  83. package/dist/prosemirror/extensions/marks/StrikeExtension.js +2 -2
  84. package/dist/prosemirror/extensions/marks/SubscriptExtension.js +2 -2
  85. package/dist/prosemirror/extensions/marks/SuperscriptExtension.js +2 -2
  86. package/dist/prosemirror/extensions/marks/TextColorExtension.js +4 -3
  87. package/dist/prosemirror/extensions/marks/markUtils.d.ts +8 -6
  88. package/dist/prosemirror/extensions/marks/markUtils.js +17 -8
  89. package/dist/prosemirror/extensions/marks/noteReferenceDeletion.d.ts +3 -1
  90. package/dist/prosemirror/extensions/marks/noteReferenceDeletion.js +12 -1
  91. package/dist/prosemirror/extensions/nodes/CommentReferenceExtension.js +2 -2
  92. package/dist/prosemirror/extensions/nodes/HardBreakExtension.js +5 -3
  93. package/dist/prosemirror/extensions/nodes/TableExtension.js +104 -210
  94. package/dist/prosemirror/extensions/types.d.ts +7 -0
  95. package/dist/prosemirror/hyperlinkRemoval.d.ts +9 -0
  96. package/dist/prosemirror/hyperlinkRemoval.js +129 -0
  97. package/dist/prosemirror/index.d.ts +2 -1
  98. package/dist/prosemirror/index.js +2 -1
  99. package/dist/prosemirror/listNumbering.d.ts +3 -21
  100. package/dist/prosemirror/listNumbering.js +10 -25
  101. package/dist/prosemirror/listRendering.d.ts +27 -0
  102. package/dist/prosemirror/listRendering.js +27 -0
  103. package/dist/prosemirror/markupViewNotes.d.ts +8 -0
  104. package/dist/prosemirror/markupViewNotes.js +49 -0
  105. package/dist/prosemirror/markupViewProjection.d.ts +3 -1
  106. package/dist/prosemirror/markupViewProjection.js +7 -2
  107. package/dist/prosemirror/noteReferenceReview.d.ts +77 -0
  108. package/dist/prosemirror/noteReferenceReview.js +332 -0
  109. package/dist/prosemirror/paragraphIndentation.js +5 -2
  110. package/dist/prosemirror/paragraphMarkJoin.d.ts +6 -4
  111. package/dist/prosemirror/paragraphMarkJoin.js +21 -19
  112. package/dist/prosemirror/paragraphPropertyCarry.d.ts +64 -0
  113. package/dist/prosemirror/paragraphPropertyCarry.js +158 -0
  114. package/dist/prosemirror/plugins/documentStyleState.d.ts +26 -0
  115. package/dist/prosemirror/plugins/documentStyleState.js +32 -0
  116. package/dist/prosemirror/plugins/documentStyles.d.ts +2 -21
  117. package/dist/prosemirror/plugins/documentStyles.js +66 -29
  118. package/dist/prosemirror/plugins/index.d.ts +2 -1
  119. package/dist/prosemirror/plugins/index.js +2 -1
  120. package/dist/prosemirror/plugins/paragraphStyleResolution.d.ts +15 -0
  121. package/dist/prosemirror/plugins/paragraphStyleResolution.js +230 -0
  122. package/dist/prosemirror/plugins/suggestionMode.d.ts +12 -2
  123. package/dist/prosemirror/plugins/suggestionMode.js +391 -28
  124. package/dist/prosemirror/rebaseParagraphRunFormatting.d.ts +22 -2
  125. package/dist/prosemirror/rebaseParagraphRunFormatting.js +39 -13
  126. package/dist/prosemirror/rebaseParagraphRuns.d.ts +36 -0
  127. package/dist/prosemirror/rebaseParagraphRuns.js +99 -0
  128. package/dist/prosemirror/runFormattingFromMarks.d.ts +18 -0
  129. package/dist/prosemirror/runFormattingFromMarks.js +567 -0
  130. package/dist/prosemirror/runFormattingReconciliation.js +10 -9
  131. package/dist/prosemirror/schema/nodes.d.ts +2 -0
  132. package/dist/prosemirror/styles/paragraphStyleCascade.d.ts +59 -0
  133. package/dist/prosemirror/styles/paragraphStyleCascade.js +145 -0
  134. package/dist/prosemirror/styles/resolvedStyleAttrs.d.ts +21 -2
  135. package/dist/prosemirror/styles/resolvedStyleAttrs.js +38 -1
  136. package/dist/prosemirror/styles/tableStyleRegions.d.ts +31 -0
  137. package/dist/prosemirror/styles/tableStyleRegions.js +55 -0
  138. package/dist/prosemirror/tableCellPaste.d.ts +74 -0
  139. package/dist/prosemirror/tableCellPaste.js +525 -0
  140. package/dist/prosemirror/tableGridMutation.d.ts +53 -14
  141. package/dist/prosemirror/tableGridMutation.js +166 -18
  142. package/dist/prosemirror/tableRunIn.d.ts +51 -0
  143. package/dist/prosemirror/tableRunIn.js +165 -0
  144. package/dist/prosemirror/textInput.js +11 -2
  145. package/dist/prosemirror/trackedRevisionPath.d.ts +17 -0
  146. package/dist/prosemirror/trackedRevisionPath.js +32 -0
  147. package/dist/server.d.ts +2 -2
  148. package/package.json +5 -5
@@ -4,6 +4,19 @@ import { DEFAULT_FONT_FAMILY, buildRunFontStyle } from "./measureHelpers.js";
4
4
  const DEFAULT_FONT_SIZE = 11;
5
5
  const DEFAULT_LINE_HEIGHT_MULTIPLIER = 1;
6
6
  const WIDTH_TOLERANCE = .5;
7
+ /** Grid a finished line's width snaps to: 1/1024 px, exact in binary floating point. */
8
+ const LINE_WIDTH_GRID = 1024;
9
+ /**
10
+ * The width a finished line reports. A line's width is a floating-point sum
11
+ * of per-token measurements, and the same text can reach it through different
12
+ * token groupings, e.g. a run split where a hidden revision sat measures
13
+ * "confirm" + "ation" where the resolved text measures "confirmation". Those
14
+ * sums agree only up to the last few bits, so the result snaps to a
15
+ * binary-exact 1/1024 px grid (under 0.0005 px, far below a device pixel):
16
+ * the same text yields the same width whichever grouping summed it, and the
17
+ * alignment offsets derived from it agree too.
18
+ */
19
+ const canonicalLineWidth = (width) => Math.round(Math.max(0, width) * LINE_WIDTH_GRID) / LINE_WIDTH_GRID;
7
20
  /**
8
21
  * Extract FontStyle from a run that carries RunFormatting (text, tab, or
9
22
  * field). All three share the same formatting shape, so they measure the
@@ -79,4 +92,4 @@ function isSpaceOrTab(char) {
79
92
  return char === " " || char === " ";
80
93
  }
81
94
  //#endregion
82
- export { DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT_MULTIPLIER, WIDTH_TOLERANCE, fieldMeasureText, isBlockLayoutImageRun, isEmptyTextRun, isFieldRun, isImageRun, isLineBreakRun, isMathRun, isSpaceOrTab, isTabRun, isTextRun, runToFontStyle };
95
+ export { DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT_MULTIPLIER, WIDTH_TOLERANCE, canonicalLineWidth, fieldMeasureText, isBlockLayoutImageRun, isEmptyTextRun, isFieldRun, isImageRun, isLineBreakRun, isMathRun, isSpaceOrTab, isTabRun, isTextRun, runToFontStyle };
@@ -7,8 +7,8 @@
7
7
  */
8
8
  /**
9
9
  * Escape only characters that would change markdown structure mid-text:
10
- * - backslash, backtick, asterisk, brackets (always)
11
- * - underscore: only at word boundaries (emphasis trigger)
10
+ * - backslash, backtick, asterisk, brackets, tilde (always)
11
+ * - underscore: unless between two letters or digits (emphasis trigger)
12
12
  * - angle brackets: only when they look like a tag/autolink
13
13
  *
14
14
  * Characters like `.` `-` `+` `#` `(` `)` `!` are only meaningful at line
@@ -7,8 +7,8 @@
7
7
  */
8
8
  /**
9
9
  * Escape only characters that would change markdown structure mid-text:
10
- * - backslash, backtick, asterisk, brackets (always)
11
- * - underscore: only at word boundaries (emphasis trigger)
10
+ * - backslash, backtick, asterisk, brackets, tilde (always)
11
+ * - underscore: unless between two letters or digits (emphasis trigger)
12
12
  * - angle brackets: only when they look like a tag/autolink
13
13
  *
14
14
  * Characters like `.` `-` `+` `#` `(` `)` `!` are only meaningful at line
@@ -16,9 +16,8 @@
16
16
  * when to escape those.
17
17
  */
18
18
  function escapeInline(text) {
19
- let out = text.replace(/(?<ch>[\\`*[\]])/gu, "\\$<ch>");
20
- out = out.replace(/(?<pre>^|\s)_/gu, "$<pre>\\_");
21
- out = out.replace(/_(?<post>\s|$)/gu, "\\_$<post>");
19
+ let out = text.replace(/(?<ch>[\\`*[\]~])/gu, "\\$<ch>");
20
+ out = out.replace(/(?<![\p{L}\p{N}])_|_(?![\p{L}\p{N}])/gu, "\\_");
22
21
  out = out.replace(/<(?<tag>\/?[A-Za-z][\w-]*)(?=[\s/>])/gu, "\\<$<tag>");
23
22
  return out;
24
23
  }
@@ -11,37 +11,103 @@ import { panic } from "better-result";
11
11
  * of the same list. Ported from eigenpal/docx-editor PR #595.
12
12
  */
13
13
  /**
14
- * In `trackedChanges: "clean"` mode every change is accepted. A paragraph whose
15
- * end-of-paragraph mark is a pending deletion (`pPrMark.kind === "del"`) loses
16
- * its break on accept and merges with the following paragraph. Word's join
17
- * keeps the FIRST paragraph's properties (style, list) and drops the resolved
18
- * mark; the surviving break is the next paragraph's, so a run of consecutive
19
- * deletions collapses into one paragraph. A first paragraph with nothing left
20
- * once its deletions are accepted (a whole deleted paragraph) contributes only
21
- * the join, so the NEXT paragraph keeps its own properties, as the editor's
22
- * accept does. A non-paragraph next block (table, SDT) is structurally
23
- * incompatible and stays unmerged, matching the editor's accept-change join
24
- * guard (`commands/comments.ts`).
14
+ * In `trackedChanges: "clean"` mode every change is accepted, as the editor's
15
+ * accept-all resolves the story (`internal/wholeStoryRevisionResolution.ts`).
16
+ *
17
+ * A paragraph whose mark is pending deletion loses its break on accept and
18
+ * runs on into the paragraph after it, which is the paragraph left: its mark
19
+ * ends the joined text, and a paragraph's properties (style, list) live on its
20
+ * mark. A run of consecutive deletions collapses into that last paragraph.
21
+ *
22
+ * With a table right after it, its words run on into the table's first cell
23
+ * and lead that cell's first paragraph (`prosemirror/tableRunIn.ts`), unless
24
+ * it ends a section, keeps nothing once accepted, or that cell is pending.
25
+ * Otherwise a paragraph that cannot join keeps its place, or goes when it
26
+ * keeps nothing and something follows it.
25
27
  */
26
28
  function mergeAcceptedParagraphBreaks(blocks) {
27
- const merged = [];
28
- for (const block of blocks) {
29
- const prev = merged.at(-1);
30
- if (prev?.type === "paragraph" && prev.pPrMark?.kind === "del" && block.type === "paragraph") {
31
- const next = block.pPrMark;
32
- const content = [...prev.content, ...block.content];
33
- const joined = cloneParagraphWithoutPropertySource(holdsNothingOnAccept(prev) ? block : prev, {
34
- content,
35
- ...next ? { pPrMark: next } : {}
36
- });
37
- if (!next) Reflect.deleteProperty(joined, "pPrMark");
38
- merged[merged.length - 1] = joined;
29
+ const reversed = [];
30
+ let survivor = null;
31
+ let tableFollows = false;
32
+ let followed = false;
33
+ const flush = () => {
34
+ if (survivor) reversed.push(survivor);
35
+ survivor = null;
36
+ };
37
+ for (let index = blocks.length - 1; index >= 0; index--) {
38
+ const block = blocks[index];
39
+ if (!block) continue;
40
+ if (block.type !== "paragraph") {
41
+ flush();
42
+ reversed.push(block);
43
+ tableFollows = block.type === "table";
44
+ followed ||= tableFollows;
39
45
  continue;
40
46
  }
41
- merged.push(block);
47
+ const goes = block.pPrMark?.kind === "del" || block.pPrMark?.kind === "moveFrom";
48
+ if (goes && survivor) {
49
+ survivor = cloneParagraphWithoutPropertySource(survivor, { content: [...block.content, ...survivor.content] });
50
+ continue;
51
+ }
52
+ const table = reversed.at(-1);
53
+ if (goes && tableFollows && table?.type === "table") {
54
+ const ranIn = tableWithParagraphRunIn(table, block);
55
+ if (ranIn) {
56
+ reversed[reversed.length - 1] = ranIn;
57
+ continue;
58
+ }
59
+ }
60
+ flush();
61
+ tableFollows = false;
62
+ if (goes && holdsNothingOnAccept(block) && followed && index + reversed.length > 0) continue;
63
+ survivor = block;
64
+ followed = true;
42
65
  }
43
- return merged;
66
+ flush();
67
+ return reversed.reverse();
44
68
  }
69
+ /**
70
+ * Whether the cell a paragraph's words would run into is itself pending (its
71
+ * row or the cell, through nested tables' first cells), as
72
+ * `prosemirror/tableRunIn.ts` reads it.
73
+ */
74
+ const runInCellPending = (table) => {
75
+ const row = table.rows[0];
76
+ const cell = row?.cells[0];
77
+ if (!row || !cell) return false;
78
+ if (row.structuralChange !== void 0 || cell.structuralChange !== void 0) return true;
79
+ const first = cell.content[0];
80
+ return first?.type === "table" && runInCellPending(first);
81
+ };
82
+ /**
83
+ * `table` with `paragraph`'s words leading its first cell's first paragraph,
84
+ * descending into nested tables, or null when the paragraph keeps its place.
85
+ */
86
+ const tableWithParagraphRunIn = (table, paragraph) => {
87
+ if (paragraph.sectionProperties !== void 0 || holdsNothingOnAccept(paragraph)) return null;
88
+ if (runInCellPending(table)) return null;
89
+ const runIn = (current) => {
90
+ const [row, ...rows] = current.rows;
91
+ const [cell, ...cells] = row?.cells ?? [];
92
+ const [first, ...rest] = cell?.content ?? [];
93
+ if (!row || !cell || !first) return null;
94
+ let led = null;
95
+ if (first.type === "paragraph") led = cloneParagraphWithoutPropertySource(first, { content: [...paragraph.content, ...first.content] });
96
+ else if (first.type === "table") led = runIn(first);
97
+ if (!led) return null;
98
+ return {
99
+ ...current,
100
+ rows: [{
101
+ ...row,
102
+ cells: [{
103
+ ...cell,
104
+ content: [led, ...rest]
105
+ }, ...cells]
106
+ }, ...rows]
107
+ };
108
+ };
109
+ return runIn(table);
110
+ };
45
111
  /** Paragraph content that shows nothing: range boundaries and anchors. */
46
112
  const ZERO_WIDTH_CONTENT = /* @__PURE__ */ new Set([
47
113
  "bookmarkStart",
@@ -1,10 +1,19 @@
1
1
  import { document_d_exports } from "../types/document.js";
2
2
  import { RenderContext } from "./types.js";
3
3
  //#region src/markdown/renderRuns.d.ts
4
+ /**
5
+ * The character beside a run's delimiters: `""` at the paragraph's edge,
6
+ * `undefined` when it cannot be told (read as a letter, the strictest case).
7
+ */
8
+ type Neighbor = string | undefined;
9
+ type RunNeighbors = {
10
+ before: Neighbor;
11
+ after: Neighbor;
12
+ };
4
13
  /**
5
14
  * Render the full inline content of a paragraph, tracking comment-range
6
15
  * boundaries to apply the configured wrapper.
7
16
  */
8
- declare function renderParagraphInline(ctx: RenderContext, pkg: document_d_exports.DocxPackage | undefined, content: readonly document_d_exports.ParagraphContent[], paraId: string | undefined): string;
17
+ declare function renderParagraphInline(ctx: RenderContext, pkg: document_d_exports.DocxPackage | undefined, content: readonly document_d_exports.ParagraphContent[], paraId: string | undefined, edges?: RunNeighbors): string;
9
18
  //#endregion
10
19
  export { renderParagraphInline };
@@ -5,6 +5,7 @@ import { wrapComment, wrapDeletion, wrapInsertion, wrapMoveFrom, wrapMoveTo } fr
5
5
  import { escapeAltText, escapeInline, escapeLinkUrl } from "./escape.js";
6
6
  import { registerImage } from "./images.js";
7
7
  import { numberNoteReference, pushWarning } from "./internals.js";
8
+ import { compileMarkdownToContent } from "@stll/docx-core";
8
9
  //#region src/markdown/renderRuns.ts
9
10
  const MARK_DELIMS = {
10
11
  bold: "**",
@@ -32,9 +33,9 @@ function marksFor(run) {
32
33
  const f = run.formatting;
33
34
  if (!f) return [];
34
35
  const out = [];
36
+ if (f.strike) out.push("strike");
35
37
  if (f.bold) out.push("bold");
36
38
  if (f.italic) out.push("italic");
37
- if (f.strike) out.push("strike");
38
39
  const ascii = f.fontFamily?.ascii?.toLowerCase();
39
40
  if (ascii && MONOSPACE_FONTS.has(ascii)) out.push("code");
40
41
  return out;
@@ -42,7 +43,7 @@ function marksFor(run) {
42
43
  function applyMarks(text, marks) {
43
44
  if (!text) return text;
44
45
  if (marks.includes("code")) {
45
- const literal = text.replace(/\\(?<char>[\\`*[\]_<])/gu, "$<char>");
46
+ const literal = text.replace(/\\(?<char>[\\`*[\]_<~])/gu, "$<char>");
46
47
  let longestRun = 0;
47
48
  for (const m of literal.matchAll(/`+/gu)) longestRun = Math.max(longestRun, m[0].length);
48
49
  const fence = "`".repeat(longestRun + 1);
@@ -56,6 +57,133 @@ function applyMarks(text, marks) {
56
57
  }
57
58
  return out;
58
59
  }
60
+ const UNKNOWN_NEIGHBORS = {
61
+ before: void 0,
62
+ after: void 0
63
+ };
64
+ const isBlank = (ch) => ch === "" || ch !== void 0 && /\s/u.test(ch);
65
+ /**
66
+ * Which mark wraps a stretch of runs first, outermost first. Strikethrough
67
+ * goes inside emphasis: an emphasis delimiter beside a tilde opens and closes
68
+ * wherever it stands, where the other way round (`~~*`) needs a blank or
69
+ * punctuation outside.
70
+ */
71
+ const GROUPING_ORDER = [
72
+ "italic",
73
+ "bold",
74
+ "strike"
75
+ ];
76
+ /** The stackings tried, in turn, where the reader misreads one. */
77
+ const GROUPING_ORDERS = [GROUPING_ORDER, [
78
+ "bold",
79
+ "italic",
80
+ "strike"
81
+ ]];
82
+ /**
83
+ * CommonMark punctuation (Unicode P and S categories) as a delimiter of
84
+ * `mark` meets it. Beside an emphasis delimiter, an asterisk is another
85
+ * delimiter that runs together with it, and a tilde does not let it close
86
+ * (the GFM reader's rule): both count as letters. (Words carry them escaped,
87
+ * as a two-character unit.)
88
+ */
89
+ const punctuationFor = (mark) => (ch) => {
90
+ if (ch === void 0 || ch === "" || ch === "~") return false;
91
+ if (mark !== "strike" && ch === "*") return false;
92
+ return /[\p{P}\p{S}]/u.test(ch);
93
+ };
94
+ /** One escape pair (`\*`) or one code point of escaped inline text. */
95
+ const INLINE_UNIT = /\\[\s\S]|[\s\S]/gu;
96
+ /**
97
+ * Wrap `inner` in `mark` so a CommonMark reader reads it back. A delimiter
98
+ * opens only before a character that is not blank, and before punctuation
99
+ * only where blank or punctuation precedes it; closing mirrors that. Where
100
+ * the words at an edge would stop a delimiter from opening or closing (a
101
+ * space at the edge, emphasis ending in a full stop right before a letter),
102
+ * those characters are left outside the emphasis rather than written as
103
+ * literal asterisks.
104
+ */
105
+ function wrapFlanking(inner, mark, { before, after }) {
106
+ const isPunctuation = punctuationFor(mark);
107
+ const opensAfter = (ch) => isBlank(ch) || isPunctuation(ch);
108
+ const units = Array.from(inner.matchAll(INLINE_UNIT), ([unit]) => unit);
109
+ const isPunctuationUnit = (unit) => unit.length === 2 && unit.startsWith("\\") || isPunctuation(unit);
110
+ let start = 0;
111
+ let end = units.length;
112
+ let outside = before;
113
+ for (;;) {
114
+ const unit = units[start];
115
+ if (unit === void 0 || start >= end) break;
116
+ if (!isBlank(unit) && (!isPunctuationUnit(unit) || opensAfter(outside))) break;
117
+ outside = unit.at(-1);
118
+ start++;
119
+ }
120
+ outside = after;
121
+ for (;;) {
122
+ const unit = units[end - 1];
123
+ if (unit === void 0 || end <= start) break;
124
+ if (!isBlank(unit) && (!isPunctuationUnit(unit) || opensAfter(outside))) break;
125
+ outside = unit.at(0);
126
+ end--;
127
+ }
128
+ return units.slice(0, start).join("") + applyMarks(units.slice(start, end).join(""), start < end ? [mark] : []) + units.slice(end).join("");
129
+ }
130
+ /** Paragraph content that writes nothing to the line. */
131
+ const SILENT_MARKERS = /* @__PURE__ */ new Set([
132
+ "bookmarkStart",
133
+ "bookmarkEnd",
134
+ "moveFromRangeStart",
135
+ "moveFromRangeEnd",
136
+ "moveToRangeStart",
137
+ "moveToRangeEnd"
138
+ ]);
139
+ /** Comment range markers, which write nothing when comments are stripped. */
140
+ const COMMENT_MARKERS = /* @__PURE__ */ new Set(["commentRangeStart", "commentRangeEnd"]);
141
+ /** The first character `piece` puts on the line, inside the marks `outer` already opened. */
142
+ const pieceStart = (piece, outer, order) => {
143
+ const first = piece.text.match(INLINE_UNIT)?.[0];
144
+ if (first === void 0 || isBlank(first)) return first;
145
+ const opens = order.find((mark) => piece.marks.has(mark) && !outer.has(mark));
146
+ if (opens === void 0) return first;
147
+ return opens === "strike" ? "~" : "*";
148
+ };
149
+ /**
150
+ * Wrap consecutive pieces in their emphasis, one mark at a time: the pieces
151
+ * sharing the outermost mark are wrapped once, around the rest of their
152
+ * emphasis. Written run by run, the closing delimiter of one would meet the
153
+ * opening one of the next (`*a**b*`), which no reader takes for two
154
+ * emphases.
155
+ */
156
+ function renderPieces(pieces, outer, edges, order = GROUPING_ORDER) {
157
+ let out = "";
158
+ let index = 0;
159
+ while (index < pieces.length) {
160
+ const piece = pieces[index];
161
+ if (!piece) break;
162
+ const mark = order.find((key) => piece.marks.has(key) && !outer.has(key));
163
+ if (mark === void 0) {
164
+ out += piece.text;
165
+ index++;
166
+ continue;
167
+ }
168
+ let last = index;
169
+ while (pieces[last + 1]?.marks.has(mark)) last++;
170
+ const next = pieces[last + 1];
171
+ const around = {
172
+ before: out.length > 0 ? out.at(-1) : edges.before,
173
+ after: next === void 0 ? edges.after : pieceStart(next, outer, order)
174
+ };
175
+ const inner = renderPieces(pieces.slice(index, last + 1), /* @__PURE__ */ new Set([...outer, mark]), around, order);
176
+ out += wrapFlanking(inner, mark, around);
177
+ index = last + 1;
178
+ }
179
+ return out;
180
+ }
181
+ /** The first character an item that is not a run puts on the line, where it is fixed. */
182
+ function renderedStart(item) {
183
+ if (item?.type !== "hyperlink") return void 0;
184
+ const link = item;
185
+ return link.href || link.anchor ? "[" : void 0;
186
+ }
59
187
  /** Render a single run's RunContent array into the inline text fragment. */
60
188
  function renderRunContent(ctx, pkg, content, paraId) {
61
189
  let out = "";
@@ -111,21 +239,111 @@ function renderRunContent(ctx, pkg, content, paraId) {
111
239
  }
112
240
  return out;
113
241
  }
114
- /** Render a single Run with its formatting applied. */
115
- function renderRun(ctx, pkg, run, paraId) {
116
- if (run.formatting?.hidden) return "";
242
+ /** A run's rendered text and the emphasis it still takes; `null` when it writes nothing. */
243
+ function pieceOf(ctx, pkg, run, paraId) {
244
+ if (run.formatting?.hidden) return null;
117
245
  const inner = renderRunContent(ctx, pkg, run.content, paraId);
118
- if (!inner) return "";
246
+ if (!inner) return null;
247
+ const marks = marksFor(run);
248
+ if (!marks.includes("code")) return {
249
+ text: inner,
250
+ marks: new Set(marks)
251
+ };
119
252
  const leadLen = inner.length - inner.trimStart().length;
120
253
  const trailLen = inner.length - inner.trimEnd().length;
121
254
  const core = inner.slice(leadLen, inner.length - trailLen);
122
- if (!core) return inner;
123
- const lead = inner.slice(0, leadLen);
124
- const trail = inner.slice(inner.length - trailLen);
125
- return `${lead}${applyMarks(core, marksFor(run))}${trail}`;
255
+ return {
256
+ text: core ? `${inner.slice(0, leadLen)}${applyMarks(core, marks)}${inner.slice(inner.length - trailLen)}` : inner,
257
+ marks: /* @__PURE__ */ new Set()
258
+ };
126
259
  }
260
+ /** What a markdown reader reads from one line: each character with its emphasis. */
261
+ const readLine = (line) => compileMarkdownToContent(line).content.flatMap((block) => block.type === "paragraph" ? block.content : []).flatMap((item) => item.type === "hyperlink" ? item.children : [item]).flatMap((item) => {
262
+ if (item.type !== "run") return [{
263
+ char: "\0",
264
+ marks: /* @__PURE__ */ new Set()
265
+ }];
266
+ const marks = new Set(GROUPING_ORDER.filter((key) => item.formatting?.[key] === true));
267
+ return item.content.flatMap((content) => content.type === "text" ? Array.from(content.text, (char) => ({
268
+ char,
269
+ marks
270
+ })) : [{
271
+ char: "\0",
272
+ marks
273
+ }]);
274
+ });
275
+ const sameMarks = (left, right) => left.size === right.size && [...left].every((key) => right.has(key));
276
+ /**
277
+ * How `rendered`, between `edges`, reads back: `"exact"` with the pieces'
278
+ * text and each letter's emphasis, `"text"` with their text alone (emphasis
279
+ * lost somewhere, none of it read as literal asterisks), or `null`.
280
+ */
281
+ const readBack = (rendered, pieces, edges) => {
282
+ const side = (ch) => {
283
+ if (ch === void 0) return "x";
284
+ return ch === "" ? " " : ch;
285
+ };
286
+ const line = (inner) => `x ${side(edges.before)}${inner}${side(edges.after)} x`;
287
+ const read = readLine(line(rendered));
288
+ const plain = readLine(line(pieces.map(({ text }) => text).join("")));
289
+ if (read.map(({ char }) => char).join("") !== plain.map(({ char }) => char).join("")) return null;
290
+ const expected = pieces.flatMap(({ text, marks }) => readLine(`x${text}x`).slice(1, -1).map(({ char }) => ({
291
+ char,
292
+ marks
293
+ })));
294
+ const start = Array.from(`x ${side(edges.before)}`).length;
295
+ const end = Array.from(`${side(edges.after)} x`).length;
296
+ if (read.length !== start + expected.length + end) return read.every(({ marks }) => marks.size === 0) ? "text" : null;
297
+ let exact = true;
298
+ for (const [index, { char, marks }] of expected.entries()) {
299
+ const at = read[start + index];
300
+ if (at?.char !== char) return null;
301
+ if ([...at.marks].some((mark) => !marks.has(mark))) return null;
302
+ if (/[\p{L}\p{N}]/u.test(char) && !sameMarks(at.marks, marks)) exact = false;
303
+ }
304
+ return exact ? "exact" : "text";
305
+ };
306
+ /**
307
+ * Render consecutive runs with their formatting applied. The delimiters are
308
+ * placed by the flanking rules the reader applies, and the result is checked
309
+ * against the reader: a stacking it misreads is tried the other way round,
310
+ * or with more punctuation left outside, and failing all of those, the runs
311
+ * go out without emphasis rather than with stray asterisks.
312
+ */
313
+ function renderRuns(ctx, pkg, runs, paraId, edges = UNKNOWN_NEIGHBORS) {
314
+ const pieces = runs.flatMap((run) => pieceOf(ctx, pkg, run, paraId) ?? []);
315
+ const plain = pieces.map(({ text }) => text).join("");
316
+ if (!pieces.some(({ marks }) => marks.size > 0)) return plain;
317
+ let fallback;
318
+ for (const split of [pieces, splitAtBlanks(pieces)]) for (const order of GROUPING_ORDERS) for (const neighbors of [edges, UNKNOWN_NEIGHBORS]) {
319
+ const rendered = renderPieces(split, /* @__PURE__ */ new Set(), neighbors, order);
320
+ const read = readBack(rendered, pieces, edges);
321
+ if (read === "exact") return rendered;
322
+ if (read === "text") fallback ??= rendered;
323
+ }
324
+ return fallback ?? plain;
325
+ }
326
+ /** The pieces with every blank a piece of its own, without emphasis. */
327
+ const splitAtBlanks = (pieces) => pieces.flatMap((piece) => {
328
+ if (piece.marks.size === 0) return [piece];
329
+ const parts = [];
330
+ for (const [unit] of piece.text.matchAll(INLINE_UNIT)) {
331
+ const marks = isBlank(unit) ? /* @__PURE__ */ new Set() : piece.marks;
332
+ const last = parts.at(-1);
333
+ if (last && last.marks === marks) last.text += unit;
334
+ else parts.push({
335
+ text: unit,
336
+ marks
337
+ });
338
+ }
339
+ return parts;
340
+ });
127
341
  function renderHyperlink(ctx, pkg, link, paraId) {
128
- const inner = getHyperlinkRuns(link).map((run) => renderRun(ctx, pkg, run, paraId)).join("");
342
+ const bracketed = Boolean(link.href ?? link.anchor);
343
+ const inner = renderRuns(ctx, pkg, getHyperlinkRuns(link), paraId, bracketed ? {
344
+ before: "[",
345
+ after: "]"
346
+ } : UNKNOWN_NEIGHBORS);
129
347
  if (!inner) return "";
130
348
  const href = link.href ?? (link.anchor ? `#${link.anchor}` : "");
131
349
  if (!href) {
@@ -145,17 +363,17 @@ function renderHyperlink(ctx, pkg, link, paraId) {
145
363
  function renderTrackedWrapper(ctx, pkg, wrapper, paraId) {
146
364
  const renderChild = (child) => {
147
365
  switch (child.type) {
148
- case "run": return renderRun(ctx, pkg, child, paraId);
366
+ case "run": return renderRuns(ctx, pkg, [child], paraId);
149
367
  case "hyperlink": return renderHyperlink(ctx, pkg, child, paraId);
150
368
  case "mathEquation": return child.plainText ? escapeInline(child.plainText) : "";
151
369
  case "inlineWrapper":
152
- case "inlineSdt": return renderParagraphInline(ctx, pkg, child.content, paraId);
370
+ case "inlineSdt": return renderParagraphInline(ctx, pkg, child.content, paraId, UNKNOWN_NEIGHBORS);
153
371
  case "insertion":
154
372
  case "deletion":
155
373
  case "moveFrom":
156
374
  case "moveTo": return renderTrackedWrapper(ctx, pkg, child, paraId);
157
375
  case "simpleField":
158
- case "complexField": return renderParagraphInline(ctx, pkg, [child], paraId);
376
+ case "complexField": return renderParagraphInline(ctx, pkg, [child], paraId, UNKNOWN_NEIGHBORS);
159
377
  case "bookmarkStart":
160
378
  case "bookmarkEnd":
161
379
  case "moveFromRangeStart":
@@ -166,11 +384,33 @@ function renderTrackedWrapper(ctx, pkg, wrapper, paraId) {
166
384
  default: return child;
167
385
  }
168
386
  };
387
+ const renderChildren = () => {
388
+ let rendered = "";
389
+ let runs = [];
390
+ const flush = (after) => {
391
+ if (runs.length === 0) return;
392
+ rendered += renderRuns(ctx, pkg, runs, paraId, {
393
+ before: rendered.length > 0 ? rendered.at(-1) : void 0,
394
+ after
395
+ });
396
+ runs = [];
397
+ };
398
+ for (const child of wrapper.content) {
399
+ if (child.type === "run") {
400
+ runs.push(child);
401
+ continue;
402
+ }
403
+ flush(renderedStart(child));
404
+ rendered += renderChild(child);
405
+ }
406
+ flush(void 0);
407
+ return rendered;
408
+ };
169
409
  if (ctx.opts.trackedChanges === "clean") {
170
- if (wrapper.type === "insertion" || wrapper.type === "moveTo") return wrapper.content.map(renderChild).join("");
410
+ if (wrapper.type === "insertion" || wrapper.type === "moveTo") return renderChildren();
171
411
  return "";
172
412
  }
173
- const inner = wrapper.content.map(renderChild).join("");
413
+ const inner = renderChildren();
174
414
  switch (wrapper.type) {
175
415
  case "insertion": return wrapInsertion(ctx, wrapper.info, inner);
176
416
  case "deletion": return wrapDeletion(ctx, wrapper.info, inner);
@@ -182,55 +422,76 @@ function renderTrackedWrapper(ctx, pkg, wrapper, paraId) {
182
422
  * Render the full inline content of a paragraph, tracking comment-range
183
423
  * boundaries to apply the configured wrapper.
184
424
  */
185
- function renderParagraphInline(ctx, pkg, content, paraId) {
425
+ function renderParagraphInline(ctx, pkg, content, paraId, edges = {
426
+ before: "",
427
+ after: ""
428
+ }) {
186
429
  let out = "";
187
430
  const openComments = [];
188
- for (const item of content) switch (item.type) {
189
- case "run":
190
- out += renderRun(ctx, pkg, item, paraId);
191
- break;
192
- case "hyperlink":
193
- out += renderHyperlink(ctx, pkg, item, paraId);
194
- break;
195
- case "insertion":
196
- case "deletion":
197
- case "moveFrom":
198
- case "moveTo":
199
- out += renderTrackedWrapper(ctx, pkg, item, paraId);
200
- break;
201
- case "commentRangeStart":
202
- out += handleCommentStart(ctx, pkg, item, openComments, out.length);
203
- break;
204
- case "commentRangeEnd":
205
- out = handleCommentEnd(ctx, item, openComments, out);
206
- break;
207
- case "commentReference":
208
- out += renderPointComment(ctx, pkg, item.id);
209
- break;
210
- case "simpleField":
211
- case "complexField": {
212
- const runs = item.type === "simpleField" ? item.content : item.fieldResult;
213
- for (const child of runs) {
214
- if (child.type === "run") {
215
- out += renderRun(ctx, pkg, child, paraId);
216
- continue;
431
+ const items = content.filter((item) => !SILENT_MARKERS.has(item.type) && !(ctx.opts.comments === "strip" && COMMENT_MARKERS.has(item.type)));
432
+ const around = (index) => {
433
+ const next = items[index + 1];
434
+ return {
435
+ before: out.length > 0 ? out.at(-1) : edges.before,
436
+ after: next === void 0 ? edges.after : renderedStart(next)
437
+ };
438
+ };
439
+ for (let index = 0; index < items.length; index++) {
440
+ const item = items[index];
441
+ if (item === void 0) break;
442
+ switch (item.type) {
443
+ case "run": {
444
+ const runs = [item];
445
+ while (items[index + 1]?.type === "run") {
446
+ runs.push(items[index + 1]);
447
+ index++;
217
448
  }
218
- if (child.type === "hyperlink") {
219
- out += renderHyperlink(ctx, pkg, child, paraId);
220
- continue;
449
+ out += renderRuns(ctx, pkg, runs, paraId, around(index));
450
+ break;
451
+ }
452
+ case "hyperlink":
453
+ out += renderHyperlink(ctx, pkg, item, paraId);
454
+ break;
455
+ case "insertion":
456
+ case "deletion":
457
+ case "moveFrom":
458
+ case "moveTo":
459
+ out += renderTrackedWrapper(ctx, pkg, item, paraId);
460
+ break;
461
+ case "commentRangeStart":
462
+ out += handleCommentStart(ctx, pkg, item, openComments, out.length);
463
+ break;
464
+ case "commentRangeEnd":
465
+ out = handleCommentEnd(ctx, item, openComments, out);
466
+ break;
467
+ case "commentReference":
468
+ out += renderPointComment(ctx, pkg, item.id);
469
+ break;
470
+ case "simpleField":
471
+ case "complexField": {
472
+ const runs = item.type === "simpleField" ? item.content : item.fieldResult;
473
+ for (const child of runs) {
474
+ if (child.type === "run") {
475
+ out += renderRuns(ctx, pkg, [child], paraId);
476
+ continue;
477
+ }
478
+ if (child.type === "hyperlink") {
479
+ out += renderHyperlink(ctx, pkg, child, paraId);
480
+ continue;
481
+ }
482
+ out += child.type === "inlineWrapper" ? renderParagraphInline(ctx, pkg, child.content, paraId, UNKNOWN_NEIGHBORS) : child.text;
221
483
  }
222
- out += child.type === "inlineWrapper" ? renderParagraphInline(ctx, pkg, child.content, paraId) : child.text;
484
+ break;
223
485
  }
224
- break;
486
+ case "inlineSdt":
487
+ case "inlineWrapper":
488
+ out += renderParagraphInline(ctx, pkg, item.content, paraId, around(index));
489
+ break;
490
+ case "mathEquation":
491
+ if (item.plainText) out += escapeInline(item.plainText);
492
+ break;
493
+ default: break;
225
494
  }
226
- case "inlineSdt":
227
- case "inlineWrapper":
228
- out += renderParagraphInline(ctx, pkg, item.content, paraId);
229
- break;
230
- case "mathEquation":
231
- if (item.plainText) out += escapeInline(item.plainText);
232
- break;
233
- default: break;
234
495
  }
235
496
  while (openComments.length) {
236
497
  const slot = openComments.pop();