@usejunior/docx-core 0.20.1 → 0.21.3

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 (125) 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/generation/compile.d.ts.map +1 -1
  7. package/dist/generation/compile.js +1 -3
  8. package/dist/generation/compile.js.map +1 -1
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/integration/libreoffice-oracle.d.ts +3 -1
  14. package/dist/integration/libreoffice-oracle.d.ts.map +1 -1
  15. package/dist/integration/libreoffice-oracle.js +9 -3
  16. package/dist/integration/libreoffice-oracle.js.map +1 -1
  17. package/dist/primitives/accept_changes.d.ts +3 -4
  18. package/dist/primitives/accept_changes.d.ts.map +1 -1
  19. package/dist/primitives/accept_changes.js +165 -79
  20. package/dist/primitives/accept_changes.js.map +1 -1
  21. package/dist/primitives/bookmarks.d.ts +7 -2
  22. package/dist/primitives/bookmarks.d.ts.map +1 -1
  23. package/dist/primitives/bookmarks.js +30 -6
  24. package/dist/primitives/bookmarks.js.map +1 -1
  25. package/dist/primitives/comments.d.ts +73 -4
  26. package/dist/primitives/comments.d.ts.map +1 -1
  27. package/dist/primitives/comments.js +266 -165
  28. package/dist/primitives/comments.js.map +1 -1
  29. package/dist/primitives/conformance.d.ts +42 -0
  30. package/dist/primitives/conformance.d.ts.map +1 -0
  31. package/dist/primitives/conformance.js +61 -0
  32. package/dist/primitives/conformance.js.map +1 -0
  33. package/dist/primitives/document.d.ts +98 -1
  34. package/dist/primitives/document.d.ts.map +1 -1
  35. package/dist/primitives/document.js +315 -22
  36. package/dist/primitives/document.js.map +1 -1
  37. package/dist/primitives/errors.d.ts +3 -2
  38. package/dist/primitives/errors.d.ts.map +1 -1
  39. package/dist/primitives/errors.js +3 -1
  40. package/dist/primitives/errors.js.map +1 -1
  41. package/dist/primitives/extract_revisions.d.ts +23 -6
  42. package/dist/primitives/extract_revisions.d.ts.map +1 -1
  43. package/dist/primitives/extract_revisions.js +208 -38
  44. package/dist/primitives/extract_revisions.js.map +1 -1
  45. package/dist/primitives/footnotes.d.ts +36 -0
  46. package/dist/primitives/footnotes.d.ts.map +1 -1
  47. package/dist/primitives/footnotes.js +173 -75
  48. package/dist/primitives/footnotes.js.map +1 -1
  49. package/dist/primitives/index.d.ts +7 -1
  50. package/dist/primitives/index.d.ts.map +1 -1
  51. package/dist/primitives/index.js +7 -1
  52. package/dist/primitives/index.js.map +1 -1
  53. package/dist/primitives/paragraph-index.d.ts +31 -0
  54. package/dist/primitives/paragraph-index.d.ts.map +1 -0
  55. package/dist/primitives/paragraph-index.js +140 -0
  56. package/dist/primitives/paragraph-index.js.map +1 -0
  57. package/dist/primitives/paragraph_merge_formatting.d.ts +24 -0
  58. package/dist/primitives/paragraph_merge_formatting.d.ts.map +1 -0
  59. package/dist/primitives/paragraph_merge_formatting.js +86 -0
  60. package/dist/primitives/paragraph_merge_formatting.js.map +1 -0
  61. package/dist/primitives/paragraph_structure.d.ts +19 -0
  62. package/dist/primitives/paragraph_structure.d.ts.map +1 -0
  63. package/dist/primitives/paragraph_structure.js +66 -0
  64. package/dist/primitives/paragraph_structure.js.map +1 -0
  65. package/dist/primitives/reject_changes.d.ts +3 -4
  66. package/dist/primitives/reject_changes.d.ts.map +1 -1
  67. package/dist/primitives/reject_changes.js +166 -79
  68. package/dist/primitives/reject_changes.js.map +1 -1
  69. package/dist/primitives/relationships.d.ts +22 -0
  70. package/dist/primitives/relationships.d.ts.map +1 -1
  71. package/dist/primitives/relationships.js +87 -0
  72. package/dist/primitives/relationships.js.map +1 -1
  73. package/dist/primitives/revision-parts.d.ts +15 -0
  74. package/dist/primitives/revision-parts.d.ts.map +1 -1
  75. package/dist/primitives/revision-parts.js +33 -1
  76. package/dist/primitives/revision-parts.js.map +1 -1
  77. package/dist/primitives/sections.d.ts +2 -1
  78. package/dist/primitives/sections.d.ts.map +1 -1
  79. package/dist/primitives/sections.js +2 -2
  80. package/dist/primitives/sections.js.map +1 -1
  81. package/dist/primitives/styles.d.ts +1 -0
  82. package/dist/primitives/styles.d.ts.map +1 -1
  83. package/dist/primitives/styles.js +1 -0
  84. package/dist/primitives/styles.js.map +1 -1
  85. package/dist/primitives/table_cells.d.ts +49 -0
  86. package/dist/primitives/table_cells.d.ts.map +1 -0
  87. package/dist/primitives/table_cells.js +149 -0
  88. package/dist/primitives/table_cells.js.map +1 -0
  89. package/dist/primitives/table_columns.d.ts +50 -0
  90. package/dist/primitives/table_columns.d.ts.map +1 -0
  91. package/dist/primitives/table_columns.js +138 -0
  92. package/dist/primitives/table_columns.js.map +1 -0
  93. package/dist/primitives/table_edit_common.d.ts +39 -0
  94. package/dist/primitives/table_edit_common.d.ts.map +1 -0
  95. package/dist/primitives/table_edit_common.js +163 -0
  96. package/dist/primitives/table_edit_common.js.map +1 -0
  97. package/dist/primitives/table_occupancy.d.ts +39 -0
  98. package/dist/primitives/table_occupancy.d.ts.map +1 -0
  99. package/dist/primitives/table_occupancy.js +145 -0
  100. package/dist/primitives/table_occupancy.js.map +1 -0
  101. package/dist/primitives/table_rows.d.ts +56 -0
  102. package/dist/primitives/table_rows.d.ts.map +1 -0
  103. package/dist/primitives/table_rows.js +390 -0
  104. package/dist/primitives/table_rows.js.map +1 -0
  105. package/dist/primitives/text.d.ts +40 -1
  106. package/dist/primitives/text.d.ts.map +1 -1
  107. package/dist/primitives/text.js +309 -129
  108. package/dist/primitives/text.js.map +1 -1
  109. package/dist/primitives/track-changes-emitter.d.ts +16 -2
  110. package/dist/primitives/track-changes-emitter.d.ts.map +1 -1
  111. package/dist/primitives/track-changes-emitter.js +38 -13
  112. package/dist/primitives/track-changes-emitter.js.map +1 -1
  113. package/dist/primitives/zip.d.ts +22 -1
  114. package/dist/primitives/zip.d.ts.map +1 -1
  115. package/dist/primitives/zip.js +47 -8
  116. package/dist/primitives/zip.js.map +1 -1
  117. package/dist/shared/docx/DocxArchive.d.ts +6 -2
  118. package/dist/shared/docx/DocxArchive.d.ts.map +1 -1
  119. package/dist/shared/docx/DocxArchive.js +10 -4
  120. package/dist/shared/docx/DocxArchive.js.map +1 -1
  121. package/dist/shared/field-structure.d.ts +3 -0
  122. package/dist/shared/field-structure.d.ts.map +1 -1
  123. package/dist/shared/field-structure.js +7 -5
  124. package/dist/shared/field-structure.js.map +1 -1
  125. package/package.json +3 -4
@@ -10,8 +10,14 @@ import { parseXml, serializeXml } from './xml.js';
10
10
  import { DocxZip } from './zip.js';
11
11
  import { getParagraphRuns, getParagraphText, splitRunAtVisibleOffset } from './text.js';
12
12
  import { getParagraphBookmarkId } from './bookmarks.js';
13
- import { childElements, getLeafText, isW } from './dom-helpers.js';
13
+ import { isW } from './dom-helpers.js';
14
+ import { buildParagraphIndex } from './paragraph-index.js';
14
15
  import { getAttributeSafe } from './xml-helpers.js';
16
+ import { getFirstChild } from './xml-helpers.js';
17
+ import { extractEffectiveRunFormatting, parseStylesXml, parseThemeXml } from './styles.js';
18
+ import { emitFormattingTags, mergeAdjacentTags } from './formatting_tags.js';
19
+ import { ensureExternalHyperlinkRelationships } from './relationships.js';
20
+ import { SafeDocxError } from './errors.js';
15
21
  import { createRevisionContainer, prepareElementForDeletion, } from './track-changes-emitter.js';
16
22
  // ── Relationship types ──────────────────────────────────────────────────
17
23
  const REL_TYPE_COMMENTS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments';
@@ -148,6 +154,9 @@ async function ensureRelationships(zip, newParts) {
148
154
  }
149
155
  zip.writeText(relsPath, serializeXml(relsDoc));
150
156
  }
157
+ function commentBodyDestinations(body) {
158
+ return body?.flatMap((paragraph) => paragraph.runs.flatMap((run) => run.hyperlink ? [run.hyperlink.destination] : [])) ?? [];
159
+ }
151
160
  function deterministicParaId(params, commentId) {
152
161
  return createHash('sha256')
153
162
  .update(`${commentId}\0${params.author}\0${params.initials}\0${params.date}\0${params.text}`)
@@ -198,6 +207,11 @@ export async function addTrackedRangeComments(buffer, comments) {
198
207
  const endParent = endRevision.parentNode;
199
208
  if (!startParent || !endParent)
200
209
  throw new Error('Attributed revision container has no parent.');
210
+ for (const parent of [startParent, endParent]) {
211
+ if (parent.nodeType === 1 && ['trPr', 'rPr'].includes(parent.localName)) {
212
+ throw new Error('Tracked range comments cannot be anchored inside a property container.');
213
+ }
214
+ }
201
215
  const commentId = allocateNextCommentId(commentsDoc);
202
216
  const rangeStart = documentXml.createElementNS(OOXML.W_NS, 'w:commentRangeStart');
203
217
  rangeStart.setAttribute('w:id', String(commentId));
@@ -228,6 +242,10 @@ export async function addTrackedRangeComments(buffer, comments) {
228
242
  * - Inserts commentReference run after range end
229
243
  * - Adds comment entry to comments.xml
230
244
  * - Adds author to people.xml if not present
245
+ *
246
+ * @conformance ECMA-376 edition 5, Part 1 § 17.13.4.4
247
+ * @conformance ECMA-376 edition 5, Part 1 § 17.13.4.3
248
+ * @conformance ECMA-376 edition 5, Part 1 § 17.13.4.5
231
249
  */
232
250
  export async function addComment(documentXml, zip, params, ctx) {
233
251
  const { paragraphEl, author, text, initials } = params;
@@ -249,19 +267,31 @@ export async function addComment(documentXml, zip, params, ctx) {
249
267
  insertCommentMarkers(documentXml, paragraphEl, commentId, start, end, ctx);
250
268
  // Add comment element to comments.xml
251
269
  const paraId = generateParaId();
270
+ const hyperlinkRelationshipIds = await ensureExternalHyperlinkRelationships(zip, 'word/comments.xml', commentBodyDestinations(params.body));
252
271
  addCommentElement(commentsDoc, {
253
272
  id: commentId,
254
273
  author,
255
274
  initials: initials ?? author.charAt(0).toUpperCase(),
256
275
  text,
257
276
  paraId,
258
- date: ctx?.date,
277
+ date: resolveDefinitionDate(params.date, ctx),
278
+ body: params.body,
279
+ hyperlinkRelationshipIds,
259
280
  });
260
281
  zip.writeText('word/comments.xml', serializeXml(commentsDoc));
261
282
  // Add author to people.xml
262
283
  await ensureAuthorInPeople(zip, author);
263
284
  return { commentId };
264
285
  }
286
+ /**
287
+ * Resolve the `w:date` a new comment definition should carry. An explicit
288
+ * `null` from the caller means "no `w:date`" and wins over `ctx.date`, exactly
289
+ * as an explicit string does; `undefined` defers to the revision context and
290
+ * then to the process clock inside `addCommentElement` (#961, #1103).
291
+ */
292
+ function resolveDefinitionDate(date, ctx) {
293
+ return date === null ? null : (date ?? ctx?.date);
294
+ }
265
295
  /**
266
296
  * Add a threaded reply to an existing comment.
267
297
  *
@@ -269,9 +299,10 @@ export async function addComment(documentXml, zip, params, ctx) {
269
299
  * Thread linkage is stored in commentsExtended.xml via paraIdParent.
270
300
  * Replies emit no body revision markup (there is nothing to anchor), but the
271
301
  * reply's comment definition still claims creation metadata — so a
272
- * caller-supplied `ctx.date` stamps `w:date` exactly as it does for root
273
- * comments, keeping reply timestamps deterministic alongside the rest of the
274
- * operation. Author and initials intentionally stay sourced from `params`.
302
+ * caller-supplied `params.date` (or, failing that, `ctx.date`) stamps
303
+ * `w:date` exactly as it does for root comments, keeping reply timestamps
304
+ * deterministic alongside the rest of the operation. Author and initials
305
+ * intentionally stay sourced from `params`.
275
306
  */
276
307
  export async function addCommentReply(_documentXml, zip, params, ctx) {
277
308
  const { parentCommentId, author, text, initials } = params;
@@ -286,13 +317,16 @@ export async function addCommentReply(_documentXml, zip, params, ctx) {
286
317
  // Allocate ID and add reply comment
287
318
  const commentId = allocateNextCommentId(commentsDoc);
288
319
  const replyParaId = generateParaId();
320
+ const hyperlinkRelationshipIds = await ensureExternalHyperlinkRelationships(zip, 'word/comments.xml', commentBodyDestinations(params.body));
289
321
  addCommentElement(commentsDoc, {
290
322
  id: commentId,
291
323
  author,
292
324
  initials: initials ?? author.charAt(0).toUpperCase(),
293
325
  text,
294
326
  paraId: replyParaId,
295
- date: ctx?.date,
327
+ date: resolveDefinitionDate(params.date, ctx),
328
+ body: params.body,
329
+ hyperlinkRelationshipIds,
296
330
  });
297
331
  zip.writeText('word/comments.xml', serializeXml(commentsDoc));
298
332
  // Link reply in commentsExtended.xml
@@ -303,6 +337,66 @@ export async function addCommentReply(_documentXml, zip, params, ctx) {
303
337
  await ensureAuthorInPeople(zip, author);
304
338
  return { commentId, parentCommentId };
305
339
  }
340
+ /**
341
+ * Replace only the editable body payload of an existing comment definition.
342
+ * Comment identity, authorship metadata, threading metadata, and every anchor
343
+ * in document.xml remain untouched. The admitted topology intentionally
344
+ * matches the comment-body subset exposed by getComments(); unfamiliar direct
345
+ * children fail closed before comments.xml is mutated.
346
+ *
347
+ * @conformance ECMA-376 edition 5, Part 1 § 17.13.4.2
348
+ * @see https://github.com/UseJunior/safe-docx/issues/960
349
+ */
350
+ export async function updateCommentBody(zip, params) {
351
+ const commentsText = await zip.readTextOrNull('word/comments.xml');
352
+ if (!commentsText)
353
+ throw new Error(`Comment ID ${params.commentId} not found`);
354
+ const commentsDoc = parseXml(commentsText);
355
+ const commentEl = findCommentElementById(commentsDoc, params.commentId);
356
+ if (!commentEl)
357
+ throw new Error(`Comment ID ${params.commentId} not found`);
358
+ const paragraphs = Array.from(commentEl.childNodes)
359
+ .filter((node) => node.nodeType === 1);
360
+ if (paragraphs.length === 0 || paragraphs.some((element) => !isW(element, W.p))) {
361
+ throw new SafeDocxError('UNSUPPORTED_EDIT', `Comment ID ${params.commentId} has unsupported body topology`, 'Only direct w:p comment-body children can be updated in place.');
362
+ }
363
+ const first = paragraphs[0];
364
+ const markerRuns = paragraphs.flatMap((paragraph) => Array.from(paragraph.childNodes)
365
+ .filter((node) => node.nodeType === 1 && isW(node, W.r))
366
+ .filter((run) => run.getElementsByTagNameNS(OOXML.W_NS, W.annotationRef).length > 0));
367
+ const annotationReferenceCount = paragraphs.reduce((count, paragraph) => count + paragraph.getElementsByTagNameNS(OOXML.W_NS, W.annotationRef).length, 0);
368
+ if (annotationReferenceCount > 1 || (markerRuns.length === 1 && markerRuns[0]?.parentNode !== first)) {
369
+ throw new SafeDocxError('UNSUPPORTED_EDIT', `Comment ID ${params.commentId} has unsupported annotation-reference topology`, 'At most one annotation-reference run is admitted, and it must be in the first comment paragraph.');
370
+ }
371
+ for (const paragraph of paragraphs) {
372
+ const children = Array.from(paragraph.childNodes).filter((node) => node.nodeType === 1);
373
+ const unsupported = children.find((child) => !isW(child, W.pPr)
374
+ && !isW(child, W.r)
375
+ && !isW(child, W.hyperlink));
376
+ if (unsupported) {
377
+ throw new SafeDocxError('UNSUPPORTED_EDIT', `Comment ID ${params.commentId} contains unsupported ${unsupported.nodeName}`, 'The existing comment body was left unchanged.');
378
+ }
379
+ }
380
+ const body = params.body ?? [{ runs: [{ text: params.text }] }];
381
+ const hyperlinkRelationshipIds = await ensureExternalHyperlinkRelationships(zip, 'word/comments.xml', commentBodyDestinations(body));
382
+ for (let index = 0; index < body.length; index++) {
383
+ const paragraph = paragraphs[index] ?? commentsDoc.createElementNS(OOXML.W_NS, 'w:p');
384
+ if (!paragraph.parentNode)
385
+ commentEl.appendChild(paragraph);
386
+ for (const child of Array.from(paragraph.childNodes)) {
387
+ if (child.nodeType !== 1)
388
+ continue;
389
+ const element = child;
390
+ if (isW(element, W.pPr) || (index === 0 && element === markerRuns[0]))
391
+ continue;
392
+ paragraph.removeChild(element);
393
+ }
394
+ appendCommentBodyRuns(paragraph, body[index].runs, commentsDoc, hyperlinkRelationshipIds);
395
+ }
396
+ for (const paragraph of paragraphs.slice(body.length))
397
+ commentEl.removeChild(paragraph);
398
+ zip.writeText('word/comments.xml', serializeXml(commentsDoc));
399
+ }
306
400
  // ── Internal helpers ────────────────────────────────────────────────────
307
401
  function allocateNextCommentId(commentsDoc) {
308
402
  const commentEls = commentsDoc.getElementsByTagNameNS(OOXML.W_NS, W.comment);
@@ -360,23 +454,35 @@ function ensureCommentPartNamespaceAliases(commentsDoc) {
360
454
  * Append a `w:comment` definition to the comments part.
361
455
  *
362
456
  * @conformance ECMA-376 edition 5, Part 1 § 17.13.4.2
363
- * The `w:date` creation stamp uses the caller-supplied revision date when one
364
- * is provided, so the comment definition and the body revision markup emitted
365
- * for the same operation agree on the calendar date Word displays — even
366
- * across a UTC/local day boundary. Only when the caller supplies no date does
367
- * the process clock remain the default. Author and initials always come from
368
- * `AddCommentParams` / `AddCommentReplyParams`, never from `RevisionContext`:
369
- * the comment's attribution is the commenting author, which is allowed to
370
- * differ from the tracked-change author wrapping the reference run.
457
+ * The `w:date` creation stamp uses the caller-supplied date when one is
458
+ * provided — an explicit `AddCommentParams.date`, or else the revision
459
+ * context's date — so the comment definition and any body revision markup
460
+ * emitted for the same operation agree on the calendar date Word displays,
461
+ * even across a UTC/local day boundary. Only when the caller supplies no date
462
+ * does the process clock remain the default. An explicit `null` writes no
463
+ * `w:date` attribute at all, which is how a source comment that never carried
464
+ * one is re-emitted without gaining the compile time (#1103); `w:date` is
465
+ * optional on `w:comment`. Author and initials always come
466
+ * from `AddCommentParams` / `AddCommentReplyParams`, never from
467
+ * `RevisionContext`: the comment's attribution is the commenting author, which
468
+ * is allowed to differ from the tracked-change author wrapping the reference
469
+ * run. A date alone never produces revision markup; only a `RevisionContext`
470
+ * does.
371
471
  * @see #859
472
+ * @see #961
473
+ * @see #1103
372
474
  */
373
475
  function addCommentElement(commentsDoc, params) {
374
476
  ensureCommentPartNamespaceAliases(commentsDoc);
375
477
  const root = commentsDoc.documentElement;
478
+ if (params.hyperlinkRelationshipIds?.size && root.lookupNamespaceURI('r') !== OOXML.R_NS) {
479
+ root.setAttributeNS(XMLNS_NS, 'xmlns:r', OOXML.R_NS);
480
+ }
376
481
  const commentEl = commentsDoc.createElementNS(OOXML.W_NS, 'w:comment');
377
482
  commentEl.setAttribute('w:id', String(params.id));
378
483
  commentEl.setAttribute('w:author', params.author);
379
- commentEl.setAttribute('w:date', params.date ?? isoNow());
484
+ if (params.date !== null)
485
+ commentEl.setAttribute('w:date', params.date ?? isoNow());
380
486
  commentEl.setAttribute('w:initials', params.initials);
381
487
  // Comment body: <w:p w14:paraId="..."><w:pPr><w:pStyle w:val="CommentText"/></w:pPr><w:r><w:annotationRef/></w:r><w:r><w:t>text</w:t></w:r></w:p>
382
488
  const p = commentsDoc.createElementNS(OOXML.W_NS, 'w:p');
@@ -388,18 +494,88 @@ function addCommentElement(commentsDoc, params) {
388
494
  const annotRef = commentsDoc.createElementNS(OOXML.W_NS, 'w:annotationRef');
389
495
  refRun.appendChild(annotRef);
390
496
  p.appendChild(refRun);
391
- // Text run
392
- const textRun = commentsDoc.createElementNS(OOXML.W_NS, 'w:r');
393
- const t = commentsDoc.createElementNS(OOXML.W_NS, 'w:t');
394
- if (params.text.startsWith(' ') || params.text.endsWith(' ')) {
395
- t.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve');
396
- }
397
- t.appendChild(commentsDoc.createTextNode(params.text));
398
- textRun.appendChild(t);
399
- p.appendChild(textRun);
497
+ const body = params.body ?? [{ runs: [{ text: params.text }] }];
498
+ appendCommentBodyRuns(p, body[0]?.runs ?? [], commentsDoc, params.hyperlinkRelationshipIds);
400
499
  commentEl.appendChild(p);
500
+ for (const paragraph of body.slice(1)) {
501
+ const bodyParagraph = commentsDoc.createElementNS(OOXML.W_NS, 'w:p');
502
+ appendCommentBodyRuns(bodyParagraph, paragraph.runs, commentsDoc, params.hyperlinkRelationshipIds);
503
+ commentEl.appendChild(bodyParagraph);
504
+ }
401
505
  root.appendChild(commentEl);
402
506
  }
507
+ /**
508
+ * Emit annotation runs while retaining external hyperlink boundaries.
509
+ *
510
+ * @conformance ECMA-376 edition 5, Part 1 § 17.16.22
511
+ * @see #956
512
+ */
513
+ function appendCommentBodyRuns(paragraph, runs, doc, relationshipIds) {
514
+ let activeDestination;
515
+ let activeHyperlink;
516
+ for (const bodyRun of runs) {
517
+ const destination = bodyRun.hyperlink?.destination;
518
+ if (!destination) {
519
+ activeDestination = undefined;
520
+ activeHyperlink = undefined;
521
+ paragraph.appendChild(buildCommentBodyRun(doc, bodyRun));
522
+ continue;
523
+ }
524
+ const relationshipId = relationshipIds?.get(destination);
525
+ if (!relationshipId)
526
+ throw new Error(`Missing comment hyperlink relationship for ${destination}`);
527
+ if (activeDestination !== destination || !activeHyperlink) {
528
+ activeDestination = destination;
529
+ activeHyperlink = doc.createElementNS(OOXML.W_NS, 'w:hyperlink');
530
+ activeHyperlink.setAttributeNS(OOXML.R_NS, 'r:id', relationshipId);
531
+ paragraph.appendChild(activeHyperlink);
532
+ }
533
+ activeHyperlink.appendChild(buildCommentBodyRun(doc, bodyRun));
534
+ }
535
+ }
536
+ function buildCommentBodyRun(doc, bodyRun) {
537
+ const run = doc.createElementNS(OOXML.W_NS, 'w:r');
538
+ const style = bodyRun.style;
539
+ if (style && Object.values(style).some((value) => value !== undefined && value !== false)) {
540
+ const rPr = doc.createElementNS(OOXML.W_NS, 'w:rPr');
541
+ if (style.styleId) {
542
+ const rStyle = doc.createElementNS(OOXML.W_NS, 'w:rStyle');
543
+ rStyle.setAttributeNS(OOXML.W_NS, 'w:val', style.styleId);
544
+ rPr.appendChild(rStyle);
545
+ }
546
+ if (style.fontSizeHalfPoints !== undefined) {
547
+ const size = doc.createElementNS(OOXML.W_NS, 'w:sz');
548
+ size.setAttributeNS(OOXML.W_NS, 'w:val', String(style.fontSizeHalfPoints));
549
+ rPr.appendChild(size);
550
+ }
551
+ if (style.bold)
552
+ rPr.appendChild(doc.createElementNS(OOXML.W_NS, 'w:b'));
553
+ if (style.italic)
554
+ rPr.appendChild(doc.createElementNS(OOXML.W_NS, 'w:i'));
555
+ if (style.underline) {
556
+ const underline = doc.createElementNS(OOXML.W_NS, 'w:u');
557
+ underline.setAttributeNS(OOXML.W_NS, 'w:val', 'single');
558
+ rPr.appendChild(underline);
559
+ }
560
+ if (style.color) {
561
+ const color = doc.createElementNS(OOXML.W_NS, 'w:color');
562
+ color.setAttributeNS(OOXML.W_NS, 'w:val', style.color);
563
+ rPr.appendChild(color);
564
+ }
565
+ if (style.highlight && style.highlight !== 'none') {
566
+ const highlight = doc.createElementNS(OOXML.W_NS, 'w:highlight');
567
+ highlight.setAttributeNS(OOXML.W_NS, 'w:val', style.highlight);
568
+ rPr.appendChild(highlight);
569
+ }
570
+ run.appendChild(rPr);
571
+ }
572
+ const text = doc.createElementNS(OOXML.W_NS, 'w:t');
573
+ if (bodyRun.text.startsWith(' ') || bodyRun.text.endsWith(' '))
574
+ text.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve');
575
+ text.appendChild(doc.createTextNode(bodyRun.text));
576
+ run.appendChild(text);
577
+ return run;
578
+ }
403
579
  function insertCommentMarkers(documentXml, paragraphEl, commentId, start, end, ctx) {
404
580
  const runs = getParagraphRuns(paragraphEl);
405
581
  const rangeStart = documentXml.createElementNS(OOXML.W_NS, 'w:commentRangeStart');
@@ -583,12 +759,6 @@ async function ensureAuthorInPeople(zip, author) {
583
759
  root.appendChild(personEl);
584
760
  zip.writeText('word/people.xml', serializeXml(peopleDoc));
585
761
  }
586
- var FieldState;
587
- (function (FieldState) {
588
- FieldState[FieldState["OUTSIDE_FIELD"] = 0] = "OUTSIDE_FIELD";
589
- FieldState[FieldState["IN_FIELD_CODE"] = 1] = "IN_FIELD_CODE";
590
- FieldState[FieldState["IN_FIELD_RESULT"] = 2] = "IN_FIELD_RESULT";
591
- })(FieldState || (FieldState = {}));
592
762
  /**
593
763
  * Read all comments from a document, building a threaded tree.
594
764
  *
@@ -596,7 +766,7 @@ var FieldState;
596
766
  * their parent's `replies` array. Thread linkage is resolved via
597
767
  * commentsExtended.xml paraIdParent relationships.
598
768
  */
599
- export async function getComments(zip, documentXml) {
769
+ export async function getComments(zip, documentXml, styles = parseStylesXml(null), theme = parseThemeXml(null)) {
600
770
  const commentsText = await zip.readTextOrNull('word/comments.xml');
601
771
  if (!commentsText)
602
772
  return [];
@@ -618,7 +788,8 @@ export async function getComments(zip, documentXml) {
618
788
  const date = getAttributeSafe(el, OOXML.W_NS, 'date', 'w', { bareFallback: false }) ?? '';
619
789
  const initials = getAttributeSafe(el, OOXML.W_NS, 'initials', 'w', { bareFallback: false }) ?? '';
620
790
  // Extract text from <w:t> elements, skipping annotationRef runs
621
- const text = extractCommentText(el);
791
+ const paragraphs = extractCommentParagraphs(el, styles, theme);
792
+ const text = paragraphs.map((paragraph) => paragraph.text).join('\n');
622
793
  // Get paraId from first <w:p> child (namespace-aware to handle non-`w` prefixes)
623
794
  const paras = el.getElementsByTagNameNS(OOXML.W_NS, W.p);
624
795
  let paragraphId = null;
@@ -634,6 +805,7 @@ export async function getComments(zip, documentXml) {
634
805
  date,
635
806
  initials,
636
807
  text,
808
+ paragraphs,
637
809
  paragraphId,
638
810
  anchoredParagraphId: startPoint?.paragraphId ?? null,
639
811
  endParagraphId: endPoint?.paragraphId ?? startPoint?.paragraphId ?? null,
@@ -702,103 +874,29 @@ function resolveCommentRangeMetadata(documentXml) {
702
874
  return { startById, endById };
703
875
  }
704
876
  function resolveCommentRangeMetadataInParagraph(paragraph, startById, endById) {
705
- const walkState = {
706
- charPos: 0,
707
- fieldState: FieldState.OUTSIDE_FIELD,
708
- allRuns: [],
709
- currentRunIndex: 0,
710
- startMarkers: [],
711
- endMarkers: [],
712
- };
713
- walkParagraphForCommentMarkers(paragraph, paragraph, walkState);
877
+ const index = buildParagraphIndex(paragraph);
714
878
  const paragraphId = getParagraphBookmarkId(paragraph);
715
- for (const marker of walkState.startMarkers) {
716
- if (startById.has(marker.id))
879
+ for (const marker of index.nodes.filter((node) => node.kind === 'comment-range-start')) {
880
+ const id = getCommentMarkerId(marker.element);
881
+ if (id == null || startById.has(id))
717
882
  continue;
718
- startById.set(marker.id, {
883
+ startById.set(id, {
719
884
  paragraphId,
720
- textOffset: marker.textOffset,
721
- ...resolveMarkerToRunBoundary(walkState.allRuns, marker, 'start'),
885
+ textOffset: marker.visibleStart,
886
+ ...resolveIndexedMarkerBoundary(index, marker, 'start'),
722
887
  });
723
888
  }
724
- for (const marker of walkState.endMarkers) {
725
- if (endById.has(marker.id))
889
+ for (const marker of index.nodes.filter((node) => node.kind === 'comment-range-end')) {
890
+ const id = getCommentMarkerId(marker.element);
891
+ if (id == null || endById.has(id))
726
892
  continue;
727
- endById.set(marker.id, {
893
+ endById.set(id, {
728
894
  paragraphId,
729
- textOffset: marker.textOffset,
730
- ...resolveMarkerToRunBoundary(walkState.allRuns, marker, 'end'),
895
+ textOffset: marker.visibleStart,
896
+ ...resolveIndexedMarkerBoundary(index, marker, 'end'),
731
897
  });
732
898
  }
733
899
  }
734
- function walkParagraphForCommentMarkers(rootParagraph, node, state) {
735
- for (const child of childElements(node)) {
736
- if (child !== rootParagraph && isW(child, W.p))
737
- continue;
738
- if (isW(child, W.commentRangeStart)) {
739
- recordParagraphLevelMarker(child, state.startMarkers, state);
740
- continue;
741
- }
742
- if (isW(child, W.commentRangeEnd)) {
743
- recordParagraphLevelMarker(child, state.endMarkers, state);
744
- continue;
745
- }
746
- if (isW(child, W.r)) {
747
- walkRunForCommentMarkers(child, state);
748
- continue;
749
- }
750
- walkParagraphForCommentMarkers(rootParagraph, child, state);
751
- }
752
- }
753
- function walkRunForCommentMarkers(run, state) {
754
- const runIndex = state.currentRunIndex;
755
- state.currentRunIndex += 1;
756
- const runVisibleStart = state.charPos;
757
- for (const child of childElements(run)) {
758
- if (isW(child, W.commentRangeStart)) {
759
- recordInRunMarker(child, state.startMarkers, runIndex, state.charPos - runVisibleStart, state.charPos);
760
- continue;
761
- }
762
- if (isW(child, W.commentRangeEnd)) {
763
- recordInRunMarker(child, state.endMarkers, runIndex, state.charPos - runVisibleStart, state.charPos);
764
- continue;
765
- }
766
- if (!child.namespaceURI || child.namespaceURI !== OOXML.W_NS)
767
- continue;
768
- if (child.localName === W.fldChar) {
769
- const type = getWordAttribute(child, 'fldCharType') ?? '';
770
- if (type === 'begin')
771
- state.fieldState = FieldState.IN_FIELD_CODE;
772
- else if (type === 'separate')
773
- state.fieldState = FieldState.IN_FIELD_RESULT;
774
- else if (type === 'end')
775
- state.fieldState = FieldState.OUTSIDE_FIELD;
776
- continue;
777
- }
778
- if (state.fieldState === FieldState.IN_FIELD_CODE)
779
- continue;
780
- if (child.localName === W.t) {
781
- state.charPos += (getLeafText(child) ?? '').length;
782
- continue;
783
- }
784
- if (child.localName === W.tab || child.localName === W.br) {
785
- state.charPos += 1;
786
- }
787
- }
788
- state.allRuns.push({ visibleLength: state.charPos - runVisibleStart });
789
- }
790
- function recordInRunMarker(markerEl, bucket, runIndex, charOffset, textOffset) {
791
- const id = getCommentMarkerId(markerEl);
792
- if (id == null)
793
- return;
794
- bucket.push({ id, textOffset, inside: { runIndex, charOffset } });
795
- }
796
- function recordParagraphLevelMarker(markerEl, bucket, state) {
797
- const id = getCommentMarkerId(markerEl);
798
- if (id == null)
799
- return;
800
- bucket.push({ id, textOffset: state.charPos, between: { afterRunIndex: state.currentRunIndex - 1 } });
801
- }
802
900
  function getCommentMarkerId(markerEl) {
803
901
  const idStr = getAttributeSafe(markerEl, OOXML.W_NS, 'id', 'w', { bareFallback: false });
804
902
  if (!idStr)
@@ -806,39 +904,20 @@ function getCommentMarkerId(markerEl) {
806
904
  const id = parseInt(idStr, 10);
807
905
  return Number.isNaN(id) ? null : id;
808
906
  }
809
- function getWordAttribute(el, localName) {
810
- return getAttributeSafe(el, OOXML.W_NS, localName, 'w');
811
- }
812
- function resolveMarkerToRunBoundary(allRuns, marker, boundary) {
813
- // Marker seen INSIDE a run already carries the exact runIndex + charOffset.
814
- if (marker.inside) {
815
- return { runIndex: marker.inside.runIndex, charOffset: marker.inside.charOffset };
816
- }
817
- if (!marker.between)
818
- return {};
819
- if (allRuns.length === 0)
907
+ function resolveIndexedMarkerBoundary(index, marker, boundary) {
908
+ if (marker.runIndex !== null)
909
+ return { runIndex: marker.runIndex, charOffset: marker.runVisibleOffset };
910
+ if (index.runs.length === 0)
820
911
  return {};
821
- const { afterRunIndex } = marker.between;
822
- const lastIndex = allRuns.length - 1;
823
- if (boundary === 'start') {
824
- // Marker sits between run `afterRunIndex` and run `afterRunIndex + 1`.
825
- // For a `start` marker, the range opens at offset 0 of the next run if it exists.
826
- const nextIdx = afterRunIndex + 1;
827
- if (nextIdx <= lastIndex) {
828
- return { runIndex: nextIdx, charOffset: 0 };
829
- }
830
- // Marker is past the last run — clamp to end of last run.
831
- return { runIndex: lastIndex, charOffset: allRuns[lastIndex].visibleLength };
832
- }
833
- // boundary === 'end': range closes at the end of the previous run if one exists.
834
- if (afterRunIndex < 0) {
835
- // Marker is before the first run — empty range starting at run 0.
836
- return { runIndex: 0, charOffset: 0 };
837
- }
838
- if (afterRunIndex <= lastIndex) {
839
- return { runIndex: afterRunIndex, charOffset: allRuns[afterRunIndex].visibleLength };
840
- }
841
- return { runIndex: lastIndex, charOffset: allRuns[lastIndex].visibleLength };
912
+ const before = [...index.runs].reverse().find((run) => run.structuralIndex < marker.structuralIndex);
913
+ const after = index.runs.find((run) => run.structuralIndex > marker.structuralIndex);
914
+ if (boundary === 'start' && after)
915
+ return { runIndex: after.runIndex, charOffset: 0 };
916
+ if (before)
917
+ return { runIndex: before.runIndex, charOffset: before.visibleText.length };
918
+ // At least one run exists, and a marker with no preceding run must sort
919
+ // before the first run, so `after` is necessarily defined here.
920
+ return { runIndex: after.runIndex, charOffset: 0 };
842
921
  }
843
922
  /**
844
923
  * Get a single comment by ID, searching the full tree including replies.
@@ -1064,21 +1143,43 @@ function hasVisibleRunContent(run) {
1064
1143
  function hasElementChildren(element) {
1065
1144
  return Array.from(element.childNodes).some((child) => child.nodeType === 1);
1066
1145
  }
1067
- function extractCommentText(commentEl) {
1068
- const parts = [];
1069
- const runs = commentEl.getElementsByTagNameNS(OOXML.W_NS, W.r);
1070
- for (let i = 0; i < runs.length; i++) {
1071
- const run = runs.item(i);
1072
- // Skip runs that contain annotationRef (they're metadata, not user text)
1073
- const annotRefs = run.getElementsByTagNameNS(OOXML.W_NS, W.annotationRef);
1074
- if (annotRefs.length > 0)
1075
- continue;
1076
- const ts = run.getElementsByTagNameNS(OOXML.W_NS, W.t);
1077
- for (let j = 0; j < ts.length; j++) {
1078
- const t = ts.item(j);
1079
- parts.push(t.textContent ?? '');
1146
+ function extractCommentParagraphs(commentEl, styles, theme) {
1147
+ const paragraphs = commentEl.getElementsByTagNameNS(OOXML.W_NS, W.p);
1148
+ const result = [];
1149
+ for (let pi = 0; pi < paragraphs.length; pi++) {
1150
+ const paragraph = paragraphs.item(pi);
1151
+ const pPr = getFirstChild(paragraph, OOXML.W_NS, W.pPr);
1152
+ const pStyle = pPr ? getFirstChild(pPr, OOXML.W_NS, W.pStyle) : null;
1153
+ const style = pStyle ? getAttributeSafe(pStyle, OOXML.W_NS, 'val', 'w') : null;
1154
+ const annotated = [];
1155
+ const runs = paragraph.getElementsByTagNameNS(OOXML.W_NS, W.r);
1156
+ for (let ri = 0; ri < runs.length; ri++) {
1157
+ const run = runs.item(ri);
1158
+ if (run.getElementsByTagNameNS(OOXML.W_NS, W.annotationRef).length > 0)
1159
+ continue;
1160
+ let text = '';
1161
+ const ts = run.getElementsByTagNameNS(OOXML.W_NS, W.t);
1162
+ for (let ti = 0; ti < ts.length; ti++)
1163
+ text += ts.item(ti).textContent ?? '';
1164
+ if (!text)
1165
+ continue;
1166
+ const formatting = extractEffectiveRunFormatting({
1167
+ run,
1168
+ paragraphPPr: pPr,
1169
+ paragraphStyleId: style,
1170
+ styles,
1171
+ theme,
1172
+ });
1173
+ annotated.push({ text, formatting, hyperlinkUrl: null, charCount: text.length, isHeaderRun: false });
1080
1174
  }
1175
+ const tagged_text = mergeAdjacentTags(emitFormattingTags({
1176
+ runs: annotated,
1177
+ baseline: { bold: false, italic: false, underline: false, suppressed: false },
1178
+ fontBaseline: { modalColor: null, colorSuppressed: false, modalFontSizePt: 0, fontSizeSuppressed: true, modalFontName: '', fontNameSuppressed: true },
1179
+ formattingMode: 'full',
1180
+ }));
1181
+ result.push({ text: annotated.map((run) => run.text).join(''), tagged_text, style });
1081
1182
  }
1082
- return parts.join('');
1183
+ return result;
1083
1184
  }
1084
1185
  //# sourceMappingURL=comments.js.map