@usejunior/docx-core 0.19.1 → 0.21.2

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 (180) hide show
  1. package/README.md +12 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cli/conformance-adapter.d.ts.map +1 -1
  4. package/dist/cli/conformance-adapter.js +0 -27
  5. package/dist/cli/conformance-adapter.js.map +1 -1
  6. package/dist/core-types.d.ts +1 -139
  7. package/dist/core-types.d.ts.map +1 -1
  8. package/dist/core-types.js +1 -3
  9. package/dist/core-types.js.map +1 -1
  10. package/dist/generation/compile.d.ts.map +1 -1
  11. package/dist/generation/compile.js +1 -3
  12. package/dist/generation/compile.js.map +1 -1
  13. package/dist/index.d.ts +5 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +13 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/integration/generation-probes.d.ts +36 -2
  18. package/dist/integration/generation-probes.d.ts.map +1 -1
  19. package/dist/integration/generation-probes.js +137 -15
  20. package/dist/integration/generation-probes.js.map +1 -1
  21. package/dist/integration/libreoffice-oracle.d.ts +3 -1
  22. package/dist/integration/libreoffice-oracle.d.ts.map +1 -1
  23. package/dist/integration/libreoffice-oracle.js +10 -4
  24. package/dist/integration/libreoffice-oracle.js.map +1 -1
  25. package/dist/integration/synthetic-docx-fixture.d.ts.map +1 -1
  26. package/dist/integration/synthetic-docx-fixture.js +8 -0
  27. package/dist/integration/synthetic-docx-fixture.js.map +1 -1
  28. package/dist/primitives/accept_ai_edits.js +14 -2
  29. package/dist/primitives/accept_ai_edits.js.map +1 -1
  30. package/dist/primitives/accept_changes.d.ts +6 -0
  31. package/dist/primitives/accept_changes.d.ts.map +1 -1
  32. package/dist/primitives/accept_changes.js +236 -62
  33. package/dist/primitives/accept_changes.js.map +1 -1
  34. package/dist/primitives/bookmarks.d.ts +8 -2
  35. package/dist/primitives/bookmarks.d.ts.map +1 -1
  36. package/dist/primitives/bookmarks.js +31 -7
  37. package/dist/primitives/bookmarks.js.map +1 -1
  38. package/dist/primitives/comments.d.ts +101 -5
  39. package/dist/primitives/comments.d.ts.map +1 -1
  40. package/dist/primitives/comments.js +356 -156
  41. package/dist/primitives/comments.js.map +1 -1
  42. package/dist/primitives/conformance.d.ts +42 -0
  43. package/dist/primitives/conformance.d.ts.map +1 -0
  44. package/dist/primitives/conformance.js +61 -0
  45. package/dist/primitives/conformance.js.map +1 -0
  46. package/dist/primitives/document.d.ts +145 -2
  47. package/dist/primitives/document.d.ts.map +1 -1
  48. package/dist/primitives/document.js +505 -27
  49. package/dist/primitives/document.js.map +1 -1
  50. package/dist/primitives/document_view-headings.d.ts +3 -1
  51. package/dist/primitives/document_view-headings.d.ts.map +1 -1
  52. package/dist/primitives/document_view-headings.js +5 -5
  53. package/dist/primitives/document_view-headings.js.map +1 -1
  54. package/dist/primitives/document_view.d.ts +2 -0
  55. package/dist/primitives/document_view.d.ts.map +1 -1
  56. package/dist/primitives/document_view.js +18 -4
  57. package/dist/primitives/document_view.js.map +1 -1
  58. package/dist/primitives/errors.d.ts +3 -2
  59. package/dist/primitives/errors.d.ts.map +1 -1
  60. package/dist/primitives/errors.js +3 -1
  61. package/dist/primitives/errors.js.map +1 -1
  62. package/dist/primitives/extract_revisions.d.ts +23 -6
  63. package/dist/primitives/extract_revisions.d.ts.map +1 -1
  64. package/dist/primitives/extract_revisions.js +208 -38
  65. package/dist/primitives/extract_revisions.js.map +1 -1
  66. package/dist/primitives/field_evaluation.d.ts +70 -0
  67. package/dist/primitives/field_evaluation.d.ts.map +1 -0
  68. package/dist/primitives/field_evaluation.js +462 -0
  69. package/dist/primitives/field_evaluation.js.map +1 -0
  70. package/dist/primitives/footnotes.d.ts +53 -3
  71. package/dist/primitives/footnotes.d.ts.map +1 -1
  72. package/dist/primitives/footnotes.js +228 -83
  73. package/dist/primitives/footnotes.js.map +1 -1
  74. package/dist/primitives/index.d.ts +12 -1
  75. package/dist/primitives/index.d.ts.map +1 -1
  76. package/dist/primitives/index.js +12 -1
  77. package/dist/primitives/index.js.map +1 -1
  78. package/dist/primitives/merge_runs.d.ts +3 -1
  79. package/dist/primitives/merge_runs.d.ts.map +1 -1
  80. package/dist/primitives/merge_runs.js +12 -2
  81. package/dist/primitives/merge_runs.js.map +1 -1
  82. package/dist/primitives/namespaces.d.ts +10 -2
  83. package/dist/primitives/namespaces.d.ts.map +1 -1
  84. package/dist/primitives/namespaces.js +11 -2
  85. package/dist/primitives/namespaces.js.map +1 -1
  86. package/dist/primitives/note_conversion.d.ts +11 -0
  87. package/dist/primitives/note_conversion.d.ts.map +1 -0
  88. package/dist/primitives/note_conversion.js +12 -0
  89. package/dist/primitives/note_conversion.js.map +1 -0
  90. package/dist/primitives/paragraph-index.d.ts +31 -0
  91. package/dist/primitives/paragraph-index.d.ts.map +1 -0
  92. package/dist/primitives/paragraph-index.js +140 -0
  93. package/dist/primitives/paragraph-index.js.map +1 -0
  94. package/dist/primitives/paragraph_merge_formatting.d.ts +24 -0
  95. package/dist/primitives/paragraph_merge_formatting.d.ts.map +1 -0
  96. package/dist/primitives/paragraph_merge_formatting.js +86 -0
  97. package/dist/primitives/paragraph_merge_formatting.js.map +1 -0
  98. package/dist/primitives/paragraph_numbering.d.ts +40 -0
  99. package/dist/primitives/paragraph_numbering.d.ts.map +1 -0
  100. package/dist/primitives/paragraph_numbering.js +198 -0
  101. package/dist/primitives/paragraph_numbering.js.map +1 -0
  102. package/dist/primitives/paragraph_structure.d.ts +19 -0
  103. package/dist/primitives/paragraph_structure.d.ts.map +1 -0
  104. package/dist/primitives/paragraph_structure.js +66 -0
  105. package/dist/primitives/paragraph_structure.js.map +1 -0
  106. package/dist/primitives/reject_changes.d.ts +6 -0
  107. package/dist/primitives/reject_changes.d.ts.map +1 -1
  108. package/dist/primitives/reject_changes.js +278 -68
  109. package/dist/primitives/reject_changes.js.map +1 -1
  110. package/dist/primitives/relationships.d.ts +22 -0
  111. package/dist/primitives/relationships.d.ts.map +1 -1
  112. package/dist/primitives/relationships.js +87 -0
  113. package/dist/primitives/relationships.js.map +1 -1
  114. package/dist/primitives/revision-parts.d.ts +15 -0
  115. package/dist/primitives/revision-parts.d.ts.map +1 -1
  116. package/dist/primitives/revision-parts.js +33 -1
  117. package/dist/primitives/revision-parts.js.map +1 -1
  118. package/dist/primitives/sectPrAudit.d.ts.map +1 -1
  119. package/dist/primitives/sectPrAudit.js +10 -1
  120. package/dist/primitives/sectPrAudit.js.map +1 -1
  121. package/dist/primitives/sections.d.ts +135 -0
  122. package/dist/primitives/sections.d.ts.map +1 -0
  123. package/dist/primitives/sections.js +644 -0
  124. package/dist/primitives/sections.js.map +1 -0
  125. package/dist/primitives/styles.d.ts +61 -0
  126. package/dist/primitives/styles.d.ts.map +1 -1
  127. package/dist/primitives/styles.js +207 -25
  128. package/dist/primitives/styles.js.map +1 -1
  129. package/dist/primitives/symbol_run_content.d.ts +42 -0
  130. package/dist/primitives/symbol_run_content.d.ts.map +1 -0
  131. package/dist/primitives/symbol_run_content.js +79 -0
  132. package/dist/primitives/symbol_run_content.js.map +1 -0
  133. package/dist/primitives/table_cells.d.ts +49 -0
  134. package/dist/primitives/table_cells.d.ts.map +1 -0
  135. package/dist/primitives/table_cells.js +149 -0
  136. package/dist/primitives/table_cells.js.map +1 -0
  137. package/dist/primitives/table_columns.d.ts +50 -0
  138. package/dist/primitives/table_columns.d.ts.map +1 -0
  139. package/dist/primitives/table_columns.js +138 -0
  140. package/dist/primitives/table_columns.js.map +1 -0
  141. package/dist/primitives/table_edit_common.d.ts +39 -0
  142. package/dist/primitives/table_edit_common.d.ts.map +1 -0
  143. package/dist/primitives/table_edit_common.js +163 -0
  144. package/dist/primitives/table_edit_common.js.map +1 -0
  145. package/dist/primitives/table_occupancy.d.ts +39 -0
  146. package/dist/primitives/table_occupancy.d.ts.map +1 -0
  147. package/dist/primitives/table_occupancy.js +145 -0
  148. package/dist/primitives/table_occupancy.js.map +1 -0
  149. package/dist/primitives/table_rows.d.ts +56 -0
  150. package/dist/primitives/table_rows.d.ts.map +1 -0
  151. package/dist/primitives/table_rows.js +390 -0
  152. package/dist/primitives/table_rows.js.map +1 -0
  153. package/dist/primitives/text.d.ts +69 -1
  154. package/dist/primitives/text.d.ts.map +1 -1
  155. package/dist/primitives/text.js +592 -102
  156. package/dist/primitives/text.js.map +1 -1
  157. package/dist/primitives/track-changes-emitter.d.ts +23 -0
  158. package/dist/primitives/track-changes-emitter.d.ts.map +1 -1
  159. package/dist/primitives/track-changes-emitter.js +64 -1
  160. package/dist/primitives/track-changes-emitter.js.map +1 -1
  161. package/dist/primitives/validate_document.d.ts.map +1 -1
  162. package/dist/primitives/validate_document.js +22 -1
  163. package/dist/primitives/validate_document.js.map +1 -1
  164. package/dist/primitives/zip.d.ts +22 -1
  165. package/dist/primitives/zip.d.ts.map +1 -1
  166. package/dist/primitives/zip.js +47 -8
  167. package/dist/primitives/zip.js.map +1 -1
  168. package/dist/shared/docx/DocxArchive.d.ts +6 -2
  169. package/dist/shared/docx/DocxArchive.d.ts.map +1 -1
  170. package/dist/shared/docx/DocxArchive.js +17 -4
  171. package/dist/shared/docx/DocxArchive.js.map +1 -1
  172. package/dist/shared/field-semantics.d.ts +26 -0
  173. package/dist/shared/field-semantics.d.ts.map +1 -0
  174. package/dist/shared/field-semantics.js +232 -0
  175. package/dist/shared/field-semantics.js.map +1 -0
  176. package/dist/shared/field-structure.d.ts +10 -1
  177. package/dist/shared/field-structure.d.ts.map +1 -1
  178. package/dist/shared/field-structure.js +31 -12
  179. package/dist/shared/field-structure.js.map +1 -1
  180. package/package.json +4 -5
@@ -6,17 +6,19 @@
6
6
  */
7
7
  import { OOXML, W } from './namespaces.js';
8
8
  import { parseXml, serializeXml } from './xml.js';
9
- import { getParagraphRuns } from './text.js';
9
+ import { buildParagraphIndex } from './paragraph-index.js';
10
10
  import { getParagraphBookmarkId } from './bookmarks.js';
11
11
  import { findUniqueSubstringMatch } from './matching.js';
12
12
  import { childElements, isW } from './dom-helpers.js';
13
13
  import { getFirstChild } from './xml-helpers.js';
14
- import { extractEffectiveRunFormatting, parseStylesXml } from './styles.js';
14
+ import { extractEffectiveRunFormatting, parseStylesXml, parseThemeXml, } from './styles.js';
15
15
  import { emitFormattingTags, mergeAdjacentTags } from './formatting_tags.js';
16
+ import { ensureExternalHyperlinkRelationships } from './relationships.js';
16
17
  import { createRevisionContainer, prepareElementForDeletion, } from './track-changes-emitter.js';
17
18
  // ── Relationship & content types ────────────────────────────────────────
18
19
  const REL_TYPE_FOOTNOTES = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes';
19
20
  const CT_FOOTNOTES = 'application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml';
21
+ const XMLNS_NS = 'http://www.w3.org/2000/xmlns/';
20
22
  // ── Minimal XML template ────────────────────────────────────────────────
21
23
  const FOOTNOTES_XML_TEMPLATE = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
22
24
  `<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"` +
@@ -178,8 +180,10 @@ function buildDisplayNumberMap(documentXml, footnotesDoc) {
178
180
  * When omitted, formatting is read from direct `w:rPr` only — the flattened
179
181
  * `text` and the plural anchor map are unaffected either way, so existing
180
182
  * callers that pass `(zip, documentXml)` keep their exact behavior.
183
+ *
184
+ * @conformance ECMA-376 edition 5, Part 1 § 17.11.14
181
185
  */
182
- export async function getFootnotes(zip, documentXml, styles) {
186
+ export async function getFootnotes(zip, documentXml, styles, theme) {
183
187
  const footnotesText = await zip.readTextOrNull('word/footnotes.xml');
184
188
  if (!footnotesText)
185
189
  return [];
@@ -189,6 +193,8 @@ export async function getFootnotes(zip, documentXml, styles) {
189
193
  return [];
190
194
  const displayMap = buildDisplayNumberMap(documentXml, footnotesDoc);
191
195
  const stylesModel = styles ?? parseStylesXml(null);
196
+ const themeText = theme ? null : await zip.readTextOrNull('word/theme/theme1.xml');
197
+ const themeModel = theme ?? parseThemeXml(themeText ? parseXml(themeText) : null);
192
198
  // Build map of footnoteReference id → every anchored paragraph bookmark id, in
193
199
  // document order (deduplicated). The FIRST entry feeds the legacy
194
200
  // `anchoredParagraphId`; the whole ordered list feeds `refParagraphIds`.
@@ -196,6 +202,7 @@ export async function getFootnotes(zip, documentXml, styles) {
196
202
  // malformed one has been observed reusing an id across several — so we keep
197
203
  // them all rather than silently dropping the extras.
198
204
  const anchorMap = new Map();
205
+ const referencePointMap = new Map();
199
206
  const refs = documentXml.getElementsByTagNameNS(OOXML.W_NS, W.footnoteReference);
200
207
  for (let i = 0; i < refs.length; i++) {
201
208
  const ref = refs.item(i);
@@ -214,6 +221,14 @@ export async function getFootnotes(zip, documentXml, styles) {
214
221
  if (!existing.includes(bookmarkId))
215
222
  existing.push(bookmarkId);
216
223
  anchorMap.set(id, existing);
224
+ const index = buildParagraphIndex(pel);
225
+ const indexedReference = index.nodes.find((node) => node.element === ref);
226
+ if (indexedReference) {
227
+ referencePointMap.set(id, [
228
+ ...(referencePointMap.get(id) ?? []),
229
+ { paragraphId: bookmarkId, textOffset: indexedReference.visibleStart },
230
+ ]);
231
+ }
217
232
  }
218
233
  break;
219
234
  }
@@ -229,19 +244,20 @@ export async function getFootnotes(zip, documentXml, styles) {
229
244
  if (!idStr)
230
245
  continue;
231
246
  const id = parseInt(idStr, 10);
232
- const paragraphs = extractFootnoteParagraphs(el, stylesModel);
247
+ const paragraphs = extractFootnoteParagraphs(el, stylesModel, themeModel);
233
248
  const text = paragraphs.map((p) => p.text).join('\n');
234
249
  const displayNumber = displayMap.get(id) ?? 0;
235
250
  const refParagraphIds = anchorMap.get(id) ?? [];
251
+ const referencePoints = referencePointMap.get(id) ?? [];
236
252
  const anchoredParagraphId = refParagraphIds[0] ?? null;
237
- footnotes.push({ id, displayNumber, text, anchoredParagraphId, refParagraphIds, paragraphs });
253
+ footnotes.push({ id, displayNumber, text, anchoredParagraphId, refParagraphIds, referencePoints, paragraphs });
238
254
  }
239
255
  // Sort by display number (document order)
240
256
  footnotes.sort((a, b) => a.displayNumber - b.displayNumber);
241
257
  return footnotes;
242
258
  }
243
- export async function getFootnote(zip, documentXml, noteId, styles) {
244
- const all = await getFootnotes(zip, documentXml, styles);
259
+ export async function getFootnote(zip, documentXml, noteId, styles, theme) {
260
+ const all = await getFootnotes(zip, documentXml, styles, theme);
245
261
  return all.find((f) => f.id === noteId) ?? null;
246
262
  }
247
263
  function extractFootnoteText(footnoteEl) {
@@ -259,7 +275,7 @@ function extractFootnoteText(footnoteEl) {
259
275
  * skipped so the footnote number never leaks into the body text. The reserved
260
276
  * separator paragraphs are filtered out by the caller (`isReservedFootnote`).
261
277
  */
262
- function extractFootnoteParagraphs(footnoteEl, styles) {
278
+ function extractFootnoteParagraphs(footnoteEl, styles, theme) {
263
279
  const paragraphs = footnoteEl.getElementsByTagNameNS(OOXML.W_NS, W.p);
264
280
  const out = [];
265
281
  for (let pi = 0; pi < paragraphs.length; pi++) {
@@ -288,12 +304,18 @@ function extractFootnoteParagraphs(footnoteEl, styles) {
288
304
  paragraphPPr: paraPPr ?? null,
289
305
  paragraphStyleId: style,
290
306
  styles,
307
+ theme,
291
308
  });
292
309
  annotated.push({ text: runText, formatting, hyperlinkUrl: null, charCount: runText.length, isHeaderRun: false });
293
310
  }
294
311
  // `full` mode: no baseline suppression, so every run's bold/italic/etc.
295
312
  // survives into tagged_text at node-level fidelity.
296
- const tagged = mergeAdjacentTags(emitFormattingTags({ runs: annotated, baseline: FOOTNOTE_TAG_BASELINE, formattingMode: 'full' }));
313
+ const tagged = mergeAdjacentTags(emitFormattingTags({
314
+ runs: annotated,
315
+ baseline: FOOTNOTE_TAG_BASELINE,
316
+ fontBaseline: { modalColor: null, colorSuppressed: false, modalFontSizePt: 0, fontSizeSuppressed: true, modalFontName: '', fontNameSuppressed: true },
317
+ formattingMode: 'full',
318
+ }));
297
319
  out.push({ text: textParts.join(''), tagged_text: tagged, style });
298
320
  }
299
321
  return out;
@@ -311,30 +333,50 @@ function getFootnoteParagraphStyle(p) {
311
333
  return getWAttr(pStyle, 'val');
312
334
  }
313
335
  // ── Insertion ───────────────────────────────────────────────────────────
336
+ /**
337
+ * Add one footnote definition and its exact point reference in the main story.
338
+ *
339
+ * @conformance ECMA-376 edition 5, Part 1 § 17.11.14
340
+ */
314
341
  export async function addFootnote(documentXml, zip, params, ctx) {
315
- const { paragraphEl, afterText, text } = params;
342
+ const { paragraphEl, afterText, visibleOffset, text, presentation } = params;
343
+ if (afterText !== undefined && visibleOffset !== undefined) {
344
+ throw new Error('afterText and visibleOffset are mutually exclusive footnote anchors');
345
+ }
316
346
  // Load or bootstrap footnotes.xml
317
347
  const footnotesXml = await zip.readText('word/footnotes.xml');
318
348
  const footnotesDoc = parseXml(footnotesXml);
319
349
  // Allocate next ID
320
350
  const noteId = allocateNextFootnoteId(footnotesDoc);
321
351
  // Insert footnoteReference run in document body
322
- insertFootnoteReference(documentXml, paragraphEl, noteId, afterText, ctx);
352
+ insertFootnoteReference(documentXml, paragraphEl, noteId, afterText, visibleOffset, ctx);
323
353
  // Add footnote body to footnotes.xml
324
- const footnoteEl = addFootnoteElement(footnotesDoc, noteId, text);
354
+ const destinations = [
355
+ ...(presentation?.prefixRuns ?? []),
356
+ ...(presentation?.separatorRuns ?? []),
357
+ ...(presentation?.body?.flatMap((paragraph) => paragraph.runs) ?? []),
358
+ ].flatMap((run) => run.hyperlink ? [run.hyperlink.destination] : []);
359
+ const hyperlinkRelationshipIds = await ensureExternalHyperlinkRelationships(zip, 'word/footnotes.xml', destinations);
360
+ const footnoteEl = addFootnoteElement(footnotesDoc, noteId, text, presentation, hyperlinkRelationshipIds);
325
361
  if (ctx) {
326
362
  wrapFootnoteParagraphTextRuns(getFirstFootnoteParagraph(footnoteEl), 'ins', ctx);
327
363
  }
328
364
  zip.writeText('word/footnotes.xml', serializeXml(footnotesDoc));
329
365
  return { noteId };
330
366
  }
331
- function insertFootnoteReference(documentXml, paragraphEl, noteId, afterText, ctx) {
367
+ function insertFootnoteReference(documentXml, paragraphEl, noteId, afterText, requestedVisibleOffset, ctx) {
332
368
  // Create the reference run
333
369
  const refRun = documentXml.createElementNS(OOXML.W_NS, 'w:r');
334
370
  const rPr = documentXml.createElementNS(OOXML.W_NS, 'w:rPr');
335
371
  const rStyle = documentXml.createElementNS(OOXML.W_NS, 'w:rStyle');
336
372
  rStyle.setAttributeNS(OOXML.W_NS, 'w:val', 'FootnoteReference');
337
373
  rPr.appendChild(rStyle);
374
+ // Some source documents omit or redefine the FootnoteReference character
375
+ // style. Keep the semantic style and make the required visual elevation
376
+ // explicit so the marker remains superscript across those documents.
377
+ const vertAlign = documentXml.createElementNS(OOXML.W_NS, 'w:vertAlign');
378
+ vertAlign.setAttributeNS(OOXML.W_NS, 'w:val', 'superscript');
379
+ rPr.appendChild(vertAlign);
338
380
  refRun.appendChild(rPr);
339
381
  const fnRef = documentXml.createElementNS(OOXML.W_NS, 'w:footnoteReference');
340
382
  fnRef.setAttributeNS(OOXML.W_NS, 'w:id', String(noteId));
@@ -343,44 +385,49 @@ function insertFootnoteReference(documentXml, paragraphEl, noteId, afterText, ct
343
385
  if (ctx) {
344
386
  refAnchor.appendChild(refRun);
345
387
  }
346
- if (!afterText) {
388
+ if (afterText === undefined && requestedVisibleOffset === undefined) {
347
389
  // Default: append at end of paragraph
348
390
  paragraphEl.appendChild(refAnchor);
349
391
  return;
350
392
  }
351
- // Find the text boundary using unique substring matching
352
- const runs = getParagraphRuns(paragraphEl);
353
- const fullText = runs.map((r) => r.text).join('');
354
- const match = findUniqueSubstringMatch(fullText, afterText);
355
- if (match.status === 'not_found') {
356
- throw new Error(`after_text '${afterText}' not found in paragraph`);
393
+ const index = buildParagraphIndex(paragraphEl);
394
+ const runs = index.runs.filter((run) => run.visibleText.length > 0);
395
+ let insertOffset;
396
+ if (requestedVisibleOffset !== undefined) {
397
+ if (!Number.isInteger(requestedVisibleOffset) || requestedVisibleOffset < 0 || requestedVisibleOffset > index.text.length) {
398
+ throw new Error(`visibleOffset ${requestedVisibleOffset} is outside paragraph visible text [0, ${index.text.length}]`);
399
+ }
400
+ insertOffset = requestedVisibleOffset;
357
401
  }
358
- if (match.status === 'multiple') {
359
- throw new Error(`after_text '${afterText}' found ${match.matchCount} times in paragraph`);
402
+ else {
403
+ const match = findUniqueSubstringMatch(index.text, afterText);
404
+ if (match.status === 'not_found')
405
+ throw new Error(`after_text '${afterText}' not found in paragraph`);
406
+ if (match.status === 'multiple')
407
+ throw new Error(`after_text '${afterText}' found ${match.matchCount} times in paragraph`);
408
+ insertOffset = match.end;
360
409
  }
361
- // We need to insert after the end of the matched text
362
- const insertOffset = match.end;
363
410
  // Map offset to run position
364
411
  let pos = 0;
365
412
  for (let i = 0; i < runs.length; i++) {
366
413
  const run = runs[i];
367
- const runEnd = pos + run.text.length;
414
+ const runEnd = pos + run.visibleText.length;
368
415
  if (insertOffset <= pos) {
369
416
  // Insert before this run
370
- const parent = run.r.parentNode;
371
- parent.insertBefore(refAnchor, run.r);
417
+ const parent = run.element.parentNode;
418
+ parent.insertBefore(refAnchor, run.element);
372
419
  return;
373
420
  }
374
421
  if (insertOffset > pos && insertOffset < runEnd) {
375
422
  // Need to split this run at the offset
376
423
  const splitOffset = insertOffset - pos;
377
- splitRunAndInsertReference(run.r, splitOffset, refAnchor);
424
+ splitRunAndInsertReference(run.element, splitOffset, refAnchor);
378
425
  return;
379
426
  }
380
427
  if (insertOffset === runEnd) {
381
428
  // Insert after this run
382
- const parent = run.r.parentNode;
383
- parent.insertBefore(refAnchor, run.r.nextSibling);
429
+ const parent = run.element.parentNode;
430
+ parent.insertBefore(refAnchor, run.element.nextSibling);
384
431
  return;
385
432
  }
386
433
  pos = runEnd;
@@ -493,8 +540,11 @@ function setXmlSpacePreserve(t, text) {
493
540
  t.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve');
494
541
  }
495
542
  }
496
- function addFootnoteElement(footnotesDoc, noteId, text) {
543
+ function addFootnoteElement(footnotesDoc, noteId, text, presentation, hyperlinkRelationshipIds) {
497
544
  const root = footnotesDoc.documentElement;
545
+ if (hyperlinkRelationshipIds?.size && root.lookupNamespaceURI('r') !== OOXML.R_NS) {
546
+ root.setAttributeNS(XMLNS_NS, 'xmlns:r', OOXML.R_NS);
547
+ }
498
548
  const footnoteEl = footnotesDoc.createElementNS(OOXML.W_NS, 'w:footnote');
499
549
  footnoteEl.setAttributeNS(OOXML.W_NS, 'w:id', String(noteId));
500
550
  // Word-compatible body skeleton
@@ -511,6 +561,9 @@ function addFootnoteElement(footnotesDoc, noteId, text) {
511
561
  const refRStyle = footnotesDoc.createElementNS(OOXML.W_NS, 'w:rStyle');
512
562
  refRStyle.setAttributeNS(OOXML.W_NS, 'w:val', 'FootnoteReference');
513
563
  refRPr.appendChild(refRStyle);
564
+ const refVertAlign = footnotesDoc.createElementNS(OOXML.W_NS, 'w:vertAlign');
565
+ refVertAlign.setAttributeNS(OOXML.W_NS, 'w:val', 'superscript');
566
+ refRPr.appendChild(refVertAlign);
514
567
  refRun.appendChild(refRPr);
515
568
  const fnRefEl = footnotesDoc.createElementNS(OOXML.W_NS, 'w:footnoteRef');
516
569
  refRun.appendChild(fnRefEl);
@@ -522,19 +575,99 @@ function addFootnoteElement(footnotesDoc, noteId, text) {
522
575
  spaceT.appendChild(footnotesDoc.createTextNode(' '));
523
576
  spaceRun.appendChild(spaceT);
524
577
  p.appendChild(spaceRun);
525
- // User text run
526
- const textRun = footnotesDoc.createElementNS(OOXML.W_NS, 'w:r');
527
- const t = footnotesDoc.createElementNS(OOXML.W_NS, 'w:t');
528
- if (text.startsWith(' ') || text.endsWith(' ')) {
529
- t.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve');
530
- }
531
- t.appendChild(footnotesDoc.createTextNode(text));
532
- textRun.appendChild(t);
533
- p.appendChild(textRun);
578
+ const prefixRuns = presentation?.prefixRuns
579
+ ?? (presentation?.prefix ? [{ text: presentation.prefix, style: presentation.prefixStyle }] : []);
580
+ const separatorRuns = presentation?.separatorRuns
581
+ ?? (presentation?.prefixSeparator ? [{ text: presentation.prefixSeparator }] : []);
582
+ appendFootnoteRuns(p, prefixRuns, footnotesDoc, hyperlinkRelationshipIds);
583
+ appendFootnoteRuns(p, separatorRuns, footnotesDoc, hyperlinkRelationshipIds);
584
+ const body = presentation?.body ?? [{ runs: [{ text, style: presentation?.bodyStyle }] }];
585
+ appendFootnoteRuns(p, body[0]?.runs ?? [], footnotesDoc, hyperlinkRelationshipIds);
534
586
  footnoteEl.appendChild(p);
587
+ for (const paragraph of body.slice(1)) {
588
+ const bodyParagraph = footnotesDoc.createElementNS(OOXML.W_NS, 'w:p');
589
+ appendFootnoteRuns(bodyParagraph, paragraph.runs, footnotesDoc, hyperlinkRelationshipIds);
590
+ footnoteEl.appendChild(bodyParagraph);
591
+ }
535
592
  root.appendChild(footnoteEl);
536
593
  return footnoteEl;
537
594
  }
595
+ /**
596
+ * Emit footnote runs under destination-grouped external hyperlink wrappers.
597
+ *
598
+ * @conformance ECMA-376 edition 5, Part 1 § 17.16.22
599
+ * @see #956
600
+ */
601
+ function appendFootnoteRuns(paragraph, runs, doc, relationshipIds) {
602
+ let activeDestination;
603
+ let activeHyperlink;
604
+ for (const styledRun of runs) {
605
+ const destination = styledRun.hyperlink?.destination;
606
+ if (!destination) {
607
+ activeDestination = undefined;
608
+ activeHyperlink = undefined;
609
+ paragraph.appendChild(buildStyledTextRun(doc, styledRun.text, styledRun.style));
610
+ continue;
611
+ }
612
+ const relationshipId = relationshipIds?.get(destination);
613
+ if (!relationshipId)
614
+ throw new Error(`Missing footnote hyperlink relationship for ${destination}`);
615
+ if (activeDestination !== destination || !activeHyperlink) {
616
+ activeDestination = destination;
617
+ activeHyperlink = doc.createElementNS(OOXML.W_NS, 'w:hyperlink');
618
+ activeHyperlink.setAttributeNS(OOXML.R_NS, 'r:id', relationshipId);
619
+ paragraph.appendChild(activeHyperlink);
620
+ }
621
+ activeHyperlink.appendChild(buildStyledTextRun(doc, styledRun.text, styledRun.style));
622
+ }
623
+ }
624
+ function buildStyledTextRun(doc, text, style) {
625
+ const run = doc.createElementNS(OOXML.W_NS, 'w:r');
626
+ if (style && Object.values(style).some((value) => value !== undefined && value !== false)) {
627
+ const rPr = doc.createElementNS(OOXML.W_NS, 'w:rPr');
628
+ const onOff = (name) => {
629
+ const el = doc.createElementNS(OOXML.W_NS, `w:${name}`);
630
+ rPr.appendChild(el);
631
+ };
632
+ if (style.styleId) {
633
+ const rStyle = doc.createElementNS(OOXML.W_NS, 'w:rStyle');
634
+ rStyle.setAttributeNS(OOXML.W_NS, 'w:val', style.styleId);
635
+ rPr.appendChild(rStyle);
636
+ }
637
+ if (style.fontSizeHalfPoints !== undefined) {
638
+ const size = doc.createElementNS(OOXML.W_NS, 'w:sz');
639
+ size.setAttributeNS(OOXML.W_NS, 'w:val', String(style.fontSizeHalfPoints));
640
+ rPr.appendChild(size);
641
+ }
642
+ if (style.bold)
643
+ onOff('b');
644
+ if (style.italic)
645
+ onOff('i');
646
+ if (style.underline) {
647
+ const u = doc.createElementNS(OOXML.W_NS, 'w:u');
648
+ u.setAttributeNS(OOXML.W_NS, 'w:val', 'single');
649
+ rPr.appendChild(u);
650
+ }
651
+ if (style.color) {
652
+ const color = doc.createElementNS(OOXML.W_NS, 'w:color');
653
+ color.setAttributeNS(OOXML.W_NS, 'w:val', style.color);
654
+ rPr.appendChild(color);
655
+ }
656
+ if (style.highlight && style.highlight !== 'none') {
657
+ const highlight = doc.createElementNS(OOXML.W_NS, 'w:highlight');
658
+ highlight.setAttributeNS(OOXML.W_NS, 'w:val', style.highlight);
659
+ rPr.appendChild(highlight);
660
+ }
661
+ run.appendChild(rPr);
662
+ }
663
+ const t = doc.createElementNS(OOXML.W_NS, 'w:t');
664
+ if (text.startsWith(' ') || text.endsWith(' ')) {
665
+ t.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve');
666
+ }
667
+ t.appendChild(doc.createTextNode(text));
668
+ run.appendChild(t);
669
+ return run;
670
+ }
538
671
  // ── Update ──────────────────────────────────────────────────────────────
539
672
  export async function updateFootnoteText(zip, params, ctx) {
540
673
  const { noteId, newText } = params;
@@ -661,78 +794,90 @@ function buildFootnoteTextRuns(doc, text) {
661
794
  return [spaceRun, textRun];
662
795
  }
663
796
  function removeFootnoteParagraphTextRuns(paragraph) {
664
- const collected = collectFootnoteTextRuns(paragraph);
665
- if (!collected)
666
- return;
667
- for (const run of collected.runs) {
668
- run.parentNode?.removeChild(run);
797
+ for (const group of collectFootnoteTextRunGroups(paragraph)) {
798
+ for (const run of group.runs)
799
+ group.parent.removeChild(run);
669
800
  }
670
- cleanupEmptyRevisionWrappers(paragraph);
801
+ cleanupEmptyRunContainers(paragraph);
671
802
  }
803
+ /**
804
+ * Wrap every text run of a footnote paragraph in a `w:ins` / `w:del`
805
+ * revision container. Runs are wrapped per parent container, so text that
806
+ * already sits inside a revision wrapper or inside a `w:hyperlink` stays
807
+ * where it is: the new container is created inside that parent, which keeps
808
+ * document order and link identity intact instead of hoisting linked runs
809
+ * out of their hyperlink.
810
+ *
811
+ * @conformance ECMA-376 edition 5, Part 1 § 17.16.22
812
+ * @see #956
813
+ */
672
814
  function wrapFootnoteParagraphTextRuns(paragraph, kind, ctx) {
673
- const collected = collectFootnoteTextRuns(paragraph);
674
- if (!collected)
815
+ const groups = collectFootnoteTextRunGroups(paragraph);
816
+ if (groups.length === 0)
675
817
  return null;
676
818
  const doc = paragraph.ownerDocument;
677
819
  if (!doc)
678
820
  throw new Error('Paragraph has no ownerDocument');
679
- const { parent: anchorParent, before: anchorBefore } = collected.insertionAnchor;
680
- const wrapper = createRevisionContainer(doc, kind, ctx);
681
- anchorParent.insertBefore(wrapper, anchorBefore);
682
- for (const run of collected.runs) {
683
- run.parentNode?.removeChild(run);
684
- wrapper.appendChild(kind === 'del' ? prepareElementForDeletion(run) : run);
821
+ let first = null;
822
+ for (const group of groups) {
823
+ const wrapper = createRevisionContainer(doc, kind, ctx);
824
+ group.parent.insertBefore(wrapper, group.runs[0]);
825
+ for (const run of group.runs) {
826
+ group.parent.removeChild(run);
827
+ wrapper.appendChild(kind === 'del' ? prepareElementForDeletion(run) : run);
828
+ }
829
+ first ??= wrapper;
685
830
  }
686
- cleanupEmptyRevisionWrappers(paragraph);
687
- return wrapper;
831
+ cleanupEmptyRunContainers(paragraph);
832
+ return first;
688
833
  }
689
834
  /**
690
835
  * Collect every `<w:r>` descendant of the paragraph that does NOT contain a
691
- * `footnoteRef` marker. This intentionally crosses into `<w:ins>` / `<w:del>`
692
- * wrappers so that footnote text already carrying revision history (e.g.,
693
- * third-party documents or prior tracked edits in the same session) is still
694
- * captured by `updateFootnoteText` and `deleteFootnote`. The first-found
695
- * insertion-anchor (the parent of the first match) is returned so callers
696
- * can place a new wrapper at the same structural position.
836
+ * `footnoteRef` marker, grouped by immediate parent in document order. The
837
+ * traversal intentionally crosses `<w:ins>` / `<w:del>` wrappers so footnote
838
+ * text already carrying revision history (third-party documents or prior
839
+ * tracked edits in the same session) is still captured by
840
+ * `updateFootnoteText` and `deleteFootnote`, and crosses `<w:hyperlink>`
841
+ * containers so linked text is captured without being detached from its link.
697
842
  */
698
- function collectFootnoteTextRuns(paragraph) {
699
- const collected = [];
843
+ function collectFootnoteTextRunGroups(paragraph) {
844
+ const groups = [];
700
845
  function visit(parent) {
701
846
  for (const child of childElements(parent)) {
702
847
  if (isW(child, W.r)) {
703
- if (!runContainsFootnoteRef(child)) {
704
- collected.push({ parent, run: child });
705
- }
848
+ if (runContainsFootnoteRef(child))
849
+ continue;
850
+ const last = groups[groups.length - 1];
851
+ if (last && last.parent === parent)
852
+ last.runs.push(child);
853
+ else
854
+ groups.push({ parent, runs: [child] });
706
855
  continue;
707
856
  }
708
- if (isW(child, 'ins') || isW(child, 'del')) {
857
+ if (isW(child, 'ins') || isW(child, 'del') || isW(child, W.hyperlink)) {
709
858
  visit(child);
710
859
  }
711
860
  }
712
861
  }
713
862
  visit(paragraph);
714
- if (collected.length === 0)
715
- return null;
716
- const first = collected[0];
717
- return {
718
- insertionAnchor: { parent: first.parent, before: first.run },
719
- runs: collected.map((entry) => entry.run),
720
- };
863
+ return groups;
721
864
  }
722
865
  function runContainsFootnoteRef(run) {
723
866
  return run.getElementsByTagNameNS(OOXML.W_NS, W.footnoteRef).length > 0;
724
867
  }
725
868
  /**
726
869
  * After detaching runs from their parents, sweep up any now-empty
727
- * `<w:ins>`/`<w:del>` siblings of the paragraph so we do not leave orphan
728
- * revision wrappers behind. This pairs with `collectFootnoteTextRuns`'s
729
- * cross-wrapper traversal.
870
+ * `<w:ins>` / `<w:del>` wrappers and `<w:hyperlink>` containers at any depth
871
+ * so we do not leave orphan containers behind. This pairs with
872
+ * `collectFootnoteTextRunGroups`'s cross-container traversal.
730
873
  */
731
- function cleanupEmptyRevisionWrappers(paragraph) {
732
- for (const child of Array.from(childElements(paragraph))) {
733
- if ((isW(child, 'ins') || isW(child, 'del')) && childElements(child).length === 0) {
734
- paragraph.removeChild(child);
735
- }
874
+ function cleanupEmptyRunContainers(element) {
875
+ for (const child of Array.from(childElements(element))) {
876
+ if (!(isW(child, 'ins') || isW(child, 'del') || isW(child, W.hyperlink)))
877
+ continue;
878
+ cleanupEmptyRunContainers(child);
879
+ if (childElements(child).length === 0)
880
+ element.removeChild(child);
736
881
  }
737
882
  }
738
883
  function isolateReferenceRun(run, ref) {