@stll/folio-core 0.25.0 → 0.25.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.
@@ -148,7 +148,9 @@ declare class FolioDocxReviewer {
148
148
  private readonly originalBuffer;
149
149
  private state;
150
150
  private readonly secondaryStoryStates;
151
+ private readonly resolvedStoryExpectations;
151
152
  private readonly createdComments;
153
+ private readonly usedCommentIds;
152
154
  private readonly documentOperationUndoEntries;
153
155
  /**
154
156
  * Resolved-state overrides recorded by {@link resolveComment}, keyed by
@@ -289,6 +291,8 @@ declare class FolioDocxReviewer {
289
291
  * structural edits — the same two-tier path the editor's save uses.
290
292
  */
291
293
  toBuffer(): Promise<ArrayBuffer>;
294
+ private assertResolvedStoriesSerialized;
295
+ private getChangedNoteParaIds;
292
296
  /**
293
297
  * Attempt the selective patch, treating a throw the same as a decline. Odd
294
298
  * source XML can make the paragraph diff throw rather than return `null`; a
@@ -308,6 +312,8 @@ declare class FolioDocxReviewer {
308
312
  private withResolvedOverrides;
309
313
  /** Map each anchored comment id to its anchored text and containing block id. */
310
314
  private commentAnchors;
315
+ /** Allocate outside every parsed or newly-created comment and reply id. */
316
+ private nextCommentId;
311
317
  /**
312
318
  * Drive a ProseMirror command against the reviewer's headless state via the
313
319
  * same `{ state, dispatch }` seam {@link applyOperations} uses, retaining the
@@ -53,8 +53,8 @@ let undoHandleCursor = Date.now();
53
53
  * headless `commentOnBlock` / `comment` op serialises the same `comments.xml`
54
54
  * shape the editor produces.
55
55
  */
56
- const createReviewerComment = (text, author) => ({
57
- id: commentIdCursor++,
56
+ const createReviewerComment = (id, text, author) => ({
57
+ id,
58
58
  author,
59
59
  date: (/* @__PURE__ */ new Date()).toISOString(),
60
60
  content: [{
@@ -133,10 +133,12 @@ const FOLIO_REVIEWED_VIEWS = Object.freeze([
133
133
  const FOLIO_RESOLVED_REVIEWED_VIEWS = Object.freeze(["original", "final"]);
134
134
  var UnsupportedFolioReviewedViewError = class extends TaggedError("UnsupportedFolioReviewedViewError") {};
135
135
  var FolioDocumentStoryNotFoundError = class extends TaggedError("FolioDocumentStoryNotFoundError") {};
136
+ var FolioResolvedStorySerializationError = class extends TaggedError("FolioResolvedStorySerializationError") {};
136
137
  const MAIN_STORY = Object.freeze({ type: "main" });
137
138
  const headerFooterStoryKey = ({ type, relationshipId }) => `${type}:${relationshipId}`;
138
139
  const noteStoryKey = ({ type, noteId }) => `${type}:${noteId}`;
139
140
  const secondaryStoryKey = (story) => story.type === "header" || story.type === "footer" ? headerFooterStoryKey(story) : noteStoryKey(story);
141
+ const editableStoryKey = (story) => story.type === "main" ? "main" : secondaryStoryKey(story);
140
142
  const isFolioReviewedView = (value) => FOLIO_REVIEWED_VIEWS.some((view) => view === value);
141
143
  const isFolioResolvedReviewedView = (value) => FOLIO_RESOLVED_REVIEWED_VIEWS.some((view) => view === value);
142
144
  const applyCommandToState = (state, command) => {
@@ -211,7 +213,9 @@ var FolioDocxReviewer = class FolioDocxReviewer {
211
213
  originalBuffer;
212
214
  state;
213
215
  secondaryStoryStates = /* @__PURE__ */ new Map();
216
+ resolvedStoryExpectations = /* @__PURE__ */ new Map();
214
217
  createdComments = [];
218
+ usedCommentIds;
215
219
  documentOperationUndoEntries = [];
216
220
  /**
217
221
  * Resolved-state overrides recorded by {@link resolveComment}, keyed by
@@ -227,6 +231,7 @@ var FolioDocxReviewer = class FolioDocxReviewer {
227
231
  this.originalBuffer = args.originalBuffer;
228
232
  this.state = args.state;
229
233
  this.author = args.author;
234
+ this.usedCommentIds = new Set((args.baseDocument.package.document.comments ?? []).map(({ id }) => id));
230
235
  }
231
236
  /** Parse a `.docx` buffer into a reviewer. */
232
237
  static async fromBuffer(buffer, options = {}) {
@@ -297,7 +302,13 @@ var FolioDocxReviewer = class FolioDocxReviewer {
297
302
  });
298
303
  const sourceState = this.getEditableStoryState(story);
299
304
  if (!sourceState) return false;
300
- this.setEditableStoryState(story, resolveReviewedState(sourceState, view));
305
+ const resolvedState = resolveReviewedState(sourceState, view);
306
+ this.setEditableStoryState(story, resolvedState);
307
+ this.resolvedStoryExpectations.set(editableStoryKey(story), {
308
+ story,
309
+ text: formatStoryStateForLLM(resolvedState, false),
310
+ blocks: createFolioAIEditSnapshot(resolvedState.doc).blocks
311
+ });
301
312
  return true;
302
313
  }
303
314
  /**
@@ -360,7 +371,7 @@ var FolioDocxReviewer = class FolioDocxReviewer {
360
371
  story: story.type === "main" ? "main" : story,
361
372
  author: this.author,
362
373
  createCommentId: (text) => {
363
- const comment = createReviewerComment(text, this.author);
374
+ const comment = createReviewerComment(this.nextCommentId(), text, this.author);
364
375
  this.createdComments.push(comment);
365
376
  return comment.id;
366
377
  },
@@ -602,6 +613,7 @@ var FolioDocxReviewer = class FolioDocxReviewer {
602
613
  });
603
614
  if (!reply) return null;
604
615
  this.createdComments.push(reply);
616
+ this.usedCommentIds.add(reply.id);
605
617
  return {
606
618
  id: reply.id,
607
619
  author: reply.author,
@@ -676,11 +688,43 @@ var FolioDocxReviewer = class FolioDocxReviewer {
676
688
  async toBuffer() {
677
689
  const document = this.toDocument();
678
690
  const selective = await this.trySelectiveSave(document);
679
- if (selective) return selective;
680
- return repackDocx({
691
+ if (selective) {
692
+ await this.assertResolvedStoriesSerialized(selective);
693
+ return selective;
694
+ }
695
+ const buffer = await repackDocx({
681
696
  ...document,
682
697
  originalBuffer: this.originalBuffer
683
- });
698
+ }, { changedNoteParaIds: this.getChangedNoteParaIds() });
699
+ await this.assertResolvedStoriesSerialized(buffer);
700
+ return buffer;
701
+ }
702
+ async assertResolvedStoriesSerialized(buffer) {
703
+ if (this.resolvedStoryExpectations.size === 0) return;
704
+ const reopened = await FolioDocxReviewer.fromBuffer(buffer);
705
+ for (const { story, text, blocks } of this.resolvedStoryExpectations.values()) {
706
+ const serialized = reopened.readReviewedStory({
707
+ story,
708
+ view: "current-markup"
709
+ });
710
+ const serializedState = reopened.getEditableStoryState(story);
711
+ if (serialized && serializedState && serialized.changes.length === 0 && formatStoryStateForLLM(serializedState, false) === text && JSON.stringify(createFolioAIEditSnapshot(serializedState.doc).blocks) === JSON.stringify(blocks)) continue;
712
+ throw new FolioResolvedStorySerializationError({
713
+ message: "Resolved document story did not persist to the serialized DOCX.",
714
+ story,
715
+ expectedText: text,
716
+ actualText: serializedState ? formatStoryStateForLLM(serializedState, false) : null,
717
+ remainingChangeCount: serialized?.changes.length ?? null
718
+ });
719
+ }
720
+ }
721
+ getChangedNoteParaIds() {
722
+ const changed = /* @__PURE__ */ new Set();
723
+ for (const entry of this.secondaryStoryStates.values()) {
724
+ if (entry.handle.type !== "footnote" && entry.handle.type !== "endnote") continue;
725
+ for (const paraId of getChangedParagraphIds(entry.state)) changed.add(paraId);
726
+ }
727
+ return changed;
684
728
  }
685
729
  /**
686
730
  * Attempt the selective patch, treating a throw the same as a decline. Odd
@@ -741,6 +785,7 @@ var FolioDocxReviewer = class FolioDocxReviewer {
741
785
  });
742
786
  }
743
787
  setEditableStoryState(story, state) {
788
+ this.resolvedStoryExpectations.delete(editableStoryKey(story));
744
789
  if (story.type === "main") {
745
790
  this.state = state;
746
791
  return;
@@ -840,12 +885,20 @@ var FolioDocxReviewer = class FolioDocxReviewer {
840
885
  });
841
886
  return anchors;
842
887
  }
888
+ /** Allocate outside every parsed or newly-created comment and reply id. */
889
+ nextCommentId() {
890
+ while (this.usedCommentIds.has(commentIdCursor)) commentIdCursor += 1;
891
+ const id = commentIdCursor++;
892
+ this.usedCommentIds.add(id);
893
+ return id;
894
+ }
843
895
  /**
844
896
  * Drive a ProseMirror command against the reviewer's headless state via the
845
897
  * same `{ state, dispatch }` seam {@link applyOperations} uses, retaining the
846
898
  * resulting state for {@link toBuffer}.
847
899
  */
848
900
  runCommand(command) {
901
+ this.resolvedStoryExpectations.delete("main");
849
902
  const view = {
850
903
  state: this.state,
851
904
  dispatch: (transaction) => {
@@ -2,6 +2,31 @@ import { FolioNodeRevisionKind } from "../prosemirror/revisionCarriers.js";
2
2
  import { Node } from "prosemirror-model";
3
3
  //#region src/ai-edits/read.d.ts
4
4
  type FolioReviewChangeKind = "insertion" | "deletion" | "formatting" | "rowInserted" | "rowDeleted" | "cellInserted" | "cellDeleted" | "cellMerged" | FolioNodeRevisionKind;
5
+ /**
6
+ * Runtime census of every tracked-change discriminator the reviewer exposes.
7
+ *
8
+ * The total Record is intentional: adding a new FolioReviewChangeKind without
9
+ * adding it here is a compile-time error. Cross-product persistence tests use
10
+ * this census, so a newly modeled revision cannot silently miss the resolver
11
+ * and serialization matrix.
12
+ */
13
+ declare const FOLIO_REVIEW_CHANGE_KINDS: Readonly<{
14
+ readonly insertion: "insertion";
15
+ readonly deletion: "deletion";
16
+ readonly formatting: "formatting";
17
+ readonly rowInserted: "rowInserted";
18
+ readonly rowDeleted: "rowDeleted";
19
+ readonly cellInserted: "cellInserted";
20
+ readonly cellDeleted: "cellDeleted";
21
+ readonly cellMerged: "cellMerged";
22
+ readonly paragraphMarkInserted: "paragraphMarkInserted";
23
+ readonly paragraphMarkDeleted: "paragraphMarkDeleted";
24
+ readonly paragraphPropertiesChanged: "paragraphPropertiesChanged";
25
+ readonly sectionPropertiesChanged: "sectionPropertiesChanged";
26
+ readonly tablePropertiesChanged: "tablePropertiesChanged";
27
+ readonly rowPropertiesChanged: "rowPropertiesChanged";
28
+ readonly cellPropertiesChanged: "cellPropertiesChanged";
29
+ }>;
5
30
  /** A tracked change discovered in the document body. */
6
31
  type FolioReviewChange = {
7
32
  /**
@@ -44,4 +69,4 @@ declare const getTrackedChangesFromDoc: (doc: Node) => FolioReviewChange[];
44
69
  */
45
70
  declare const getCommentAnchorsFromDoc: (doc: Node) => FolioCommentAnchor[];
46
71
  //#endregion
47
- export { FolioCommentAnchor, FolioReviewChange, FolioReviewChangeKind, getCommentAnchorsFromDoc, getTrackedChangesFromDoc };
72
+ export { FOLIO_REVIEW_CHANGE_KINDS, FolioCommentAnchor, FolioReviewChange, FolioReviewChangeKind, getCommentAnchorsFromDoc, getTrackedChangesFromDoc };
@@ -3,6 +3,31 @@ import { getFolioNodeRevisionCarriers } from "../prosemirror/revisionCarriers.js
3
3
  import { getTableCellMergeChange } from "../prosemirror/tableCellMergeRevision.js";
4
4
  import { createFolioAIEditSnapshot } from "./snapshot.js";
5
5
  //#region src/ai-edits/read.ts
6
+ /**
7
+ * Runtime census of every tracked-change discriminator the reviewer exposes.
8
+ *
9
+ * The total Record is intentional: adding a new FolioReviewChangeKind without
10
+ * adding it here is a compile-time error. Cross-product persistence tests use
11
+ * this census, so a newly modeled revision cannot silently miss the resolver
12
+ * and serialization matrix.
13
+ */
14
+ const FOLIO_REVIEW_CHANGE_KINDS = Object.freeze({
15
+ insertion: "insertion",
16
+ deletion: "deletion",
17
+ formatting: "formatting",
18
+ rowInserted: "rowInserted",
19
+ rowDeleted: "rowDeleted",
20
+ cellInserted: "cellInserted",
21
+ cellDeleted: "cellDeleted",
22
+ cellMerged: "cellMerged",
23
+ paragraphMarkInserted: "paragraphMarkInserted",
24
+ paragraphMarkDeleted: "paragraphMarkDeleted",
25
+ paragraphPropertiesChanged: "paragraphPropertiesChanged",
26
+ sectionPropertiesChanged: "sectionPropertiesChanged",
27
+ tablePropertiesChanged: "tablePropertiesChanged",
28
+ rowPropertiesChanged: "rowPropertiesChanged",
29
+ cellPropertiesChanged: "cellPropertiesChanged"
30
+ });
6
31
  /** Map each snapshot block's start position to its stable id. */
7
32
  const blockStartIdsFromDoc = (doc) => {
8
33
  const starts = /* @__PURE__ */ new Map();
@@ -214,4 +239,4 @@ const getCommentAnchorsFromDoc = (doc) => {
214
239
  return [...anchors.values()];
215
240
  };
216
241
  //#endregion
217
- export { getCommentAnchorsFromDoc, getTrackedChangesFromDoc };
242
+ export { FOLIO_REVIEW_CHANGE_KINDS, getCommentAnchorsFromDoc, getTrackedChangesFromDoc };
@@ -25,6 +25,8 @@ type RepackOptions = {
25
25
  updateModifiedDate?: boolean;
26
26
  /** Custom modifier name for lastModifiedBy */
27
27
  modifiedBy?: string;
28
+ /** Changed note paragraphs that must be serialized even without source paraIds. */
29
+ changedNoteParaIds?: ReadonlySet<string>;
28
30
  };
29
31
  /**
30
32
  * Repack a Document into a valid DOCX file
@@ -6,7 +6,7 @@ import { assertValidFolioDocumentModel } from "./modelValidation.js";
6
6
  import { isNewDataUrlDrawing } from "./newImage.js";
7
7
  import { parseNumbering } from "./numberingParser.js";
8
8
  import { RELATIONSHIP_TYPES, parseRelationships, resolveRelativePath } from "./relsParser.js";
9
- import { appendNumberingDefs, buildParagraphOffsetIndex, buildPatchedNoteXml, buildPatchedNumberingXml, collectAddedNumberingDefs, collectChangedNumberingDefs, collectParaIds } from "./selectiveXmlPatch.js";
9
+ import { appendNumberingDefs, buildPatchedNotePartXml, buildPatchedNumberingXml, collectAddedNumberingDefs, collectChangedNoteParaIds, collectChangedNumberingDefs, collectParaIds } from "./selectiveXmlPatch.js";
10
10
  import { ensureThreadedCommentParaIds, serializeComments, serializeCommentsExtended } from "./serializer/commentSerializer.js";
11
11
  import { serializeDocument } from "./serializer/documentSerializer.js";
12
12
  import { serializeFontTableXml } from "./serializer/fontTableSerializer.js";
@@ -434,7 +434,7 @@ const cloneDocxZip = (source) => {
434
434
  clone.files = { ...source.files };
435
435
  return clone;
436
436
  };
437
- const finishRepack = async ({ document, originalZip, outputZip, originalDocumentXml, originalCorePropertiesXml, compressionLevel, updateModifiedDate, modifiedBy }) => {
437
+ const finishRepack = async ({ document, originalZip, outputZip, originalDocumentXml, originalCorePropertiesXml, compressionLevel, updateModifiedDate, modifiedBy, changedNoteParaIds }) => {
438
438
  await materializeNewHeaderFooterParts(document, outputZip, compressionLevel);
439
439
  const parts = collectDocxParts(document, outputZip);
440
440
  await processNewImages(parts, outputZip, compressionLevel);
@@ -449,7 +449,13 @@ const finishRepack = async ({ document, originalZip, outputZip, originalDocument
449
449
  });
450
450
  await rebindWatermarkRelIds(document, outputZip, compressionLevel);
451
451
  serializeHeadersFootersToZip(document, outputZip, compressionLevel);
452
- await serializeNotesToZip(document, originalZip, outputZip, compressionLevel);
452
+ await serializeNotesToZip({
453
+ doc: document,
454
+ originalZip,
455
+ newZip: outputZip,
456
+ compressionLevel,
457
+ changedNoteParaIds
458
+ });
453
459
  await serializeNumberingIntoZip(document, originalZip, outputZip, compressionLevel);
454
460
  await serializeAddedStylesIntoZip(document, originalZip, outputZip, compressionLevel);
455
461
  await serializeCommentsToZip(document, outputZip, compressionLevel);
@@ -475,7 +481,7 @@ const finishRepack = async ({ document, originalZip, outputZip, originalDocument
475
481
  */
476
482
  async function repackDocx(doc, options = {}) {
477
483
  if (!doc.originalBuffer) panic("Cannot repack document: no original buffer for round-trip. Use createDocx() for new documents.");
478
- const { compressionLevel = 6, updateModifiedDate = true, modifiedBy } = options;
484
+ const { compressionLevel = 6, updateModifiedDate = true, modifiedBy, changedNoteParaIds } = options;
479
485
  const exportDocument = withoutOrphanCommentRanges(doc);
480
486
  const originalZip = await JSZip.loadAsync(doc.originalBuffer);
481
487
  const [originalDocumentXml, originalCorePropertiesXml] = await Promise.all([originalZip.file("word/document.xml")?.async("text"), originalZip.file("docProps/core.xml")?.async("text")]);
@@ -489,7 +495,8 @@ async function repackDocx(doc, options = {}) {
489
495
  originalCorePropertiesXml,
490
496
  compressionLevel,
491
497
  updateModifiedDate,
492
- ...modifiedBy !== void 0 ? { modifiedBy } : {}
498
+ ...modifiedBy !== void 0 ? { modifiedBy } : {},
499
+ ...changedNoteParaIds !== void 0 ? { changedNoteParaIds } : {}
493
500
  });
494
501
  }
495
502
  /**
@@ -501,7 +508,7 @@ async function repackDocx(doc, options = {}) {
501
508
  * @returns Promise resolving to DOCX as ArrayBuffer
502
509
  */
503
510
  async function repackDocxFromRaw(doc, rawContent, options = {}) {
504
- const { compressionLevel = 6, updateModifiedDate = true, modifiedBy } = options;
511
+ const { compressionLevel = 6, updateModifiedDate = true, modifiedBy, changedNoteParaIds } = options;
505
512
  const exportDocument = withoutOrphanCommentRanges(doc);
506
513
  const newZip = new JSZip();
507
514
  for (const [path, file] of Object.entries(rawContent.originalZip.files)) {
@@ -530,7 +537,13 @@ async function repackDocxFromRaw(doc, rawContent, options = {}) {
530
537
  });
531
538
  await rebindWatermarkRelIds(exportDocument, newZip, compressionLevel);
532
539
  serializeHeadersFootersToZip(exportDocument, newZip, compressionLevel);
533
- await serializeNotesToZip(exportDocument, rawContent.originalZip, newZip, compressionLevel);
540
+ await serializeNotesToZip({
541
+ doc: exportDocument,
542
+ originalZip: rawContent.originalZip,
543
+ newZip,
544
+ compressionLevel,
545
+ changedNoteParaIds
546
+ });
534
547
  await serializeNumberingIntoZip(exportDocument, rawContent.originalZip, newZip, compressionLevel);
535
548
  await serializeAddedStylesIntoZip(exportDocument, rawContent.originalZip, newZip, compressionLevel);
536
549
  await serializeCommentsToZip(exportDocument, newZip, compressionLevel);
@@ -1001,27 +1014,19 @@ function serializeHeadersFootersToZip(doc, zip, compressionLevel) {
1001
1014
  compressionOptions
1002
1015
  });
1003
1016
  }
1004
- /**
1005
- * Write edited footnote/endnote bodies into the repacked ZIP.
1006
- *
1007
- * The full repack rebuilds document.xml from the model, but note parts are
1008
- * preserved from the original ZIP: the model only retains the normal notes, so
1009
- * re-emitting the whole part would drop the separator / continuationSeparator
1010
- * notes Word requires. Instead each edited note paragraph is spliced by `paraId`
1011
- * into the ORIGINAL part, so an edited note gets the model content while
1012
- * separators and every unedited note stay byte-exact.
1013
- *
1014
- * Full repack has no change-tracking signal, so "edited" is detected by
1015
- * comparing the current model's note serialization against a BASELINE that
1016
- * re-parses and re-serializes the original part. Both go through the same
1017
- * (lossy) parse+serialize — e.g. the in-note `w:footnoteRef` auto-number mark
1018
- * the model does not represent is dropped on both sides — so an unedited note
1019
- * matches its baseline and is left verbatim (mark intact); only a genuine body
1020
- * edit differs and is spliced.
1021
- */
1022
- async function serializeNotesToZip(doc, originalZip, newZip, compressionLevel) {
1017
+ async function serializeNotesToZip({ doc, originalZip, newZip, compressionLevel, changedNoteParaIds }) {
1023
1018
  const footnotes = doc.package.footnotes ?? [];
1024
- if (footnotes.length > 0) if (findNotePartEntry(originalZip, "word/footnotes.xml")) await patchNotePartIntoZip("word/footnotes.xml", serializeFootnotes(footnotes), (xml) => serializeFootnotes(parseFootnotes(xml).getNormalFootnotes()), originalZip, newZip, compressionLevel);
1019
+ if (footnotes.length > 0) if (findNotePartEntry(originalZip, "word/footnotes.xml")) await patchNotePartIntoZip({
1020
+ conventionalLowerPath: "word/footnotes.xml",
1021
+ currentXml: serializeFootnotes(footnotes),
1022
+ replacementXml: serializeNewFootnotesPart(footnotes),
1023
+ baselineFrom: (xml) => serializeFootnotes(parseFootnotes(xml).getNormalFootnotes()),
1024
+ elementName: "footnote",
1025
+ changedNoteParaIds,
1026
+ originalZip,
1027
+ newZip,
1028
+ compressionLevel
1029
+ });
1025
1030
  else await materializeNewNotePart({
1026
1031
  contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml",
1027
1032
  newZip,
@@ -1031,7 +1036,17 @@ async function serializeNotesToZip(doc, originalZip, newZip, compressionLevel) {
1031
1036
  compressionLevel
1032
1037
  });
1033
1038
  const endnotes = doc.package.endnotes ?? [];
1034
- if (endnotes.length > 0) if (findNotePartEntry(originalZip, "word/endnotes.xml")) await patchNotePartIntoZip("word/endnotes.xml", serializeEndnotes(endnotes), (xml) => serializeEndnotes(parseEndnotes(xml).getNormalEndnotes()), originalZip, newZip, compressionLevel);
1039
+ if (endnotes.length > 0) if (findNotePartEntry(originalZip, "word/endnotes.xml")) await patchNotePartIntoZip({
1040
+ conventionalLowerPath: "word/endnotes.xml",
1041
+ currentXml: serializeEndnotes(endnotes),
1042
+ replacementXml: serializeNewEndnotesPart(endnotes),
1043
+ baselineFrom: (xml) => serializeEndnotes(parseEndnotes(xml).getNormalEndnotes()),
1044
+ elementName: "endnote",
1045
+ changedNoteParaIds,
1046
+ originalZip,
1047
+ newZip,
1048
+ compressionLevel
1049
+ });
1035
1050
  else await materializeNewNotePart({
1036
1051
  contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml",
1037
1052
  newZip,
@@ -1134,44 +1149,32 @@ async function serializeAddedStylesIntoZip(doc, originalZip, newZip, compression
1134
1149
  compressionOptions: { level: compressionLevel }
1135
1150
  });
1136
1151
  }
1137
- async function patchNotePartIntoZip(conventionalLowerPath, currentXml, baselineFrom, originalZip, newZip, compressionLevel) {
1152
+ async function patchNotePartIntoZip({ conventionalLowerPath, currentXml, replacementXml, baselineFrom, elementName, changedNoteParaIds, originalZip, newZip, compressionLevel }) {
1138
1153
  const file = findNotePartEntry(originalZip, conventionalLowerPath);
1139
1154
  if (!file) return;
1140
1155
  const originalXml = await file.async("text");
1141
- const changedIds = collectChangedNoteParaIds(baselineFrom(originalXml), currentXml);
1142
- if (changedIds.size === 0) return;
1143
- const patched = buildPatchedNoteXml(originalXml, currentXml, changedIds);
1144
- if (patched === null) return;
1156
+ const baselineXml = baselineFrom(originalXml);
1157
+ const effectiveChangedNoteParaIds = changedNoteParaIds ?? collectChangedNoteParaIds(baselineXml, currentXml);
1158
+ const patched = buildPatchedNotePartXml({
1159
+ originalXml,
1160
+ baselineXml,
1161
+ serializedXml: currentXml,
1162
+ replacementXml,
1163
+ elementName,
1164
+ changedParaIds: effectiveChangedNoteParaIds
1165
+ });
1166
+ const currentParaIds = collectParaIds(currentXml);
1167
+ const hasDirtyParagraph = [...effectiveChangedNoteParaIds].some((paraId) => currentParaIds.has(paraId));
1168
+ if (patched === null || hasDirtyParagraph && patched === originalXml) {
1169
+ if (hasDirtyParagraph) throw new DocxPackageFidelityError(`Cannot serialize changed ${elementName} paragraphs into ${file.name}`);
1170
+ return;
1171
+ }
1172
+ if (patched === originalXml) return;
1145
1173
  newZip.file(file.name, patched, {
1146
1174
  compression: "DEFLATE",
1147
1175
  compressionOptions: { level: compressionLevel }
1148
1176
  });
1149
1177
  }
1150
- /**
1151
- * The note paragraphs whose current serialization differs from the baseline
1152
- * (re-parsed original) serialization — i.e. the ones actually edited. A
1153
- * paragraph is only considered when its `paraId` resolves uniquely in both,
1154
- * so it can be spliced safely.
1155
- *
1156
- * Builds one {@link buildParagraphOffsetIndex} per side (a single linear scan
1157
- * each) instead of calling `extractParagraphXml` per candidate id, which
1158
- * re-scanned the whole XML per id — O(note count * XML size) for a document
1159
- * with many footnotes/endnotes. The index turns each lookup below into O(1).
1160
- */
1161
- function collectChangedNoteParaIds(baselineXml, currentXml) {
1162
- const changed = /* @__PURE__ */ new Set();
1163
- const baselineIds = collectParaIds(baselineXml);
1164
- const baselineOffsets = buildParagraphOffsetIndex(baselineXml);
1165
- const currentOffsets = buildParagraphOffsetIndex(currentXml);
1166
- for (const [id, count] of collectParaIds(currentXml)) {
1167
- if (count !== 1 || baselineIds.get(id) !== 1) continue;
1168
- const beforeRange = baselineOffsets.get(id);
1169
- const afterRange = currentOffsets.get(id);
1170
- if (!beforeRange || !afterRange) continue;
1171
- if (baselineXml.slice(beforeRange.start, beforeRange.end) !== currentXml.slice(afterRange.start, afterRange.end)) changed.add(id);
1172
- }
1173
- return changed;
1174
- }
1175
1178
  /** `word/Footnotes.xml` -> `word/_rels/Footnotes.xml.rels` (casing preserved). */
1176
1179
  function notePartRelsPath(partPath) {
1177
1180
  const lastSlash = partPath.lastIndexOf("/");
@@ -109,6 +109,29 @@ declare function buildPatchedDocumentXml(originalXml: string, serializedXml: str
109
109
  * the caller can fall back to preserving the original part verbatim.
110
110
  */
111
111
  declare function buildPatchedNoteXml(originalXml: string, serializedXml: string, changedIds: Set<string>): string | null;
112
+ type NoteElementName = "footnote" | "endnote";
113
+ declare const collectChangedNoteParaIds: (baselineXml: string, currentXml: string) => Set<string>;
114
+ type BuildPatchedNotePartXmlOptions = {
115
+ originalXml: string;
116
+ baselineXml: string;
117
+ serializedXml: string;
118
+ replacementXml: string;
119
+ elementName: NoteElementName;
120
+ changedParaIds?: ReadonlySet<string>;
121
+ };
122
+ /**
123
+ * Patch an existing note part from its model serialization.
124
+ *
125
+ * Dirty paragraph ids locate their owning note in the model serialization;
126
+ * `(note w:id, paragraph ordinal)` then locates the corresponding source XML
127
+ * even when the producer omitted paragraph ids. Equal-shape edits replace only
128
+ * dirty paragraphs. A tracked paragraph-break resolution can change that
129
+ * shape, so it replaces the one affected note. Separator notes, unrelated
130
+ * notes, and unaffected equal-shape paragraphs remain byte-exact.
131
+ * `replacementXml` also supplies synthesized automatic note-reference marks,
132
+ * which the parsed model intentionally omits.
133
+ */
134
+ declare function buildPatchedNotePartXml({ originalXml, baselineXml, serializedXml, replacementXml, elementName, changedParaIds }: BuildPatchedNotePartXmlOptions): string | null;
112
135
  type ChangedNumberingDefs = {
113
136
  abstractNums: Set<string>;
114
137
  nums: Set<string>;
@@ -148,4 +171,4 @@ declare function collectAddedNumberingDefs(baselineXml: string, currentXml: stri
148
171
  */
149
172
  declare function appendNumberingDefs(xml: string, currentXml: string, added: ChangedNumberingDefs): string | null;
150
173
  //#endregion
151
- export { ChangedNumberingDefs, ParagraphOffsets, PatchSafetyOptions, PatchValidationResult, appendNumberingDefs, buildParagraphOffsetIndex, buildPatchedDocumentXml, buildPatchedNoteXml, buildPatchedNumberingXml, collectAddedNumberingDefs, collectChangedNumberingDefs, collectParaIds, countParagraphElements, extractParagraphXml, findParagraphOffsets, isXmlNameBoundary, validatePatchSafety };
174
+ export { ChangedNumberingDefs, ParagraphOffsets, PatchSafetyOptions, PatchValidationResult, appendNumberingDefs, buildParagraphOffsetIndex, buildPatchedDocumentXml, buildPatchedNotePartXml, buildPatchedNoteXml, buildPatchedNumberingXml, collectAddedNumberingDefs, collectChangedNoteParaIds, collectChangedNumberingDefs, collectParaIds, countParagraphElements, extractParagraphXml, findParagraphOffsets, isXmlNameBoundary, validatePatchSafety };
@@ -1,3 +1,4 @@
1
+ import { WORDPROCESSINGML_NAMESPACE_URIS, findAttributeByNamespaceUri, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, parseXmlDocument } from "./xmlParser.js";
1
2
  //#region src/docx/selectiveXmlPatch.ts
2
3
  /**
3
4
  * Selective XML Patch Module
@@ -317,6 +318,190 @@ function extractElementByIdAttr(xml, openLiteral, closeTag, idAttr, id) {
317
318
  const offsets = findElementByIdAttr(xml, openLiteral, closeTag, idAttr, id);
318
319
  return offsets ? xml.slice(offsets.start, offsets.end) : null;
319
320
  }
321
+ const qualifiedName = (prefix, localName) => prefix.length === 0 ? localName : `${prefix}:${localName}`;
322
+ const collectNoteElementSyntax = (xml, elementName) => {
323
+ const byId = /* @__PURE__ */ new Map();
324
+ const root = parseXmlDocument(xml);
325
+ for (const element of getChildElements(root)) {
326
+ if (getLocalName(element.name) !== elementName || !WORDPROCESSINGML_NAMESPACE_URIS.has(getNamespaceUri(element) ?? "")) continue;
327
+ const idAttribute = findAttributeByNamespaceUri(element, WORDPROCESSINGML_NAMESPACE_URIS, "id");
328
+ if (!element.name || !idAttribute) continue;
329
+ const syntax = {
330
+ elementName: element.name,
331
+ idAttributeName: idAttribute.name,
332
+ elementPrefix: getNamespacePrefix(element.name) ?? "",
333
+ attributePrefix: getNamespacePrefix(idAttribute.name) ?? ""
334
+ };
335
+ const entries = byId.get(idAttribute.value);
336
+ if (entries) entries.push(syntax);
337
+ else byId.set(idAttribute.value, [syntax]);
338
+ }
339
+ return byId;
340
+ };
341
+ const syntaxLiteral = (syntax) => ({
342
+ openLiteral: `<${syntax.elementName}`,
343
+ closeTag: `</${syntax.elementName}>`,
344
+ idAttr: syntax.idAttributeName
345
+ });
346
+ const extractNoteElement = (xml, syntax, id) => {
347
+ const { openLiteral, closeTag, idAttr } = syntaxLiteral(syntax);
348
+ return extractElementByIdAttr(xml, openLiteral, closeTag, idAttr, id);
349
+ };
350
+ const findNoteElement = (xml, syntax, id) => {
351
+ const { openLiteral, closeTag, idAttr } = syntaxLiteral(syntax);
352
+ return findElementByIdAttr(xml, openLiteral, closeTag, idAttr, id);
353
+ };
354
+ const rewriteWordprocessingPrefixes = (xml, { source, target, sourceXmlnsDeclarations }) => {
355
+ let rewritten = "";
356
+ let quote = null;
357
+ let insideTag = false;
358
+ for (let index = 0; index < xml.length; index++) {
359
+ const character = xml[index];
360
+ if (!insideTag) {
361
+ rewritten += character;
362
+ insideTag = character === "<";
363
+ continue;
364
+ }
365
+ if (quote) {
366
+ rewritten += character;
367
+ if (character === quote) quote = null;
368
+ continue;
369
+ }
370
+ if (character === "\"" || character === "'") {
371
+ quote = character;
372
+ rewritten += character;
373
+ continue;
374
+ }
375
+ if (character === ">") {
376
+ insideTag = false;
377
+ rewritten += character;
378
+ continue;
379
+ }
380
+ const previous = xml[index - 1];
381
+ const isElementName = previous === "<" || previous === "/" && xml[index - 2] === "<";
382
+ const sourcePrefix = isElementName ? source.elementPrefix : source.attributePrefix;
383
+ if (sourcePrefix.length > 0 && xml.startsWith(`${sourcePrefix}:`, index)) {
384
+ const targetPrefix = isElementName ? target.elementPrefix : target.attributePrefix;
385
+ rewritten += targetPrefix.length === 0 ? "" : `${targetPrefix}:`;
386
+ index += sourcePrefix.length;
387
+ continue;
388
+ }
389
+ rewritten += character;
390
+ }
391
+ return withXmlnsDeclarations(rewritten, sourceXmlnsDeclarations);
392
+ };
393
+ const paragraphRanges = (xml, wordPrefix) => {
394
+ const ranges = [];
395
+ const paragraphName = qualifiedName(wordPrefix, "p");
396
+ const openLiteral = `<${paragraphName}`;
397
+ const closeTag = `</${paragraphName}>`;
398
+ let pos = 0;
399
+ while (pos < xml.length) {
400
+ const start = xml.indexOf(openLiteral, pos);
401
+ if (start === -1) break;
402
+ if (!isXmlNameBoundary(xml[start + openLiteral.length])) {
403
+ pos = start + 1;
404
+ continue;
405
+ }
406
+ const range = scanElementRange(xml, start, openLiteral, closeTag);
407
+ if (!range) break;
408
+ ranges.push(range);
409
+ pos = range.end;
410
+ }
411
+ return ranges;
412
+ };
413
+ const collectChangedNoteParaIds = (baselineXml, currentXml) => {
414
+ const changed = /* @__PURE__ */ new Set();
415
+ const baselineIds = collectParaIds(baselineXml);
416
+ const baselineOffsets = buildParagraphOffsetIndex(baselineXml);
417
+ const currentOffsets = buildParagraphOffsetIndex(currentXml);
418
+ for (const [id, count] of collectParaIds(currentXml)) {
419
+ if (count !== 1 || baselineIds.get(id) !== 1) continue;
420
+ const before = baselineOffsets.get(id);
421
+ const after = currentOffsets.get(id);
422
+ if (before && after && baselineXml.slice(before.start, before.end) !== currentXml.slice(after.start, after.end)) changed.add(id);
423
+ }
424
+ return changed;
425
+ };
426
+ const replaceRanges = (xml, replacements) => {
427
+ let result = xml;
428
+ for (const { start, end, newXml } of [...replacements].toSorted((a, b) => b.start - a.start)) result = result.slice(0, start) + newXml + result.slice(end);
429
+ return result;
430
+ };
431
+ /**
432
+ * Patch an existing note part from its model serialization.
433
+ *
434
+ * Dirty paragraph ids locate their owning note in the model serialization;
435
+ * `(note w:id, paragraph ordinal)` then locates the corresponding source XML
436
+ * even when the producer omitted paragraph ids. Equal-shape edits replace only
437
+ * dirty paragraphs. A tracked paragraph-break resolution can change that
438
+ * shape, so it replaces the one affected note. Separator notes, unrelated
439
+ * notes, and unaffected equal-shape paragraphs remain byte-exact.
440
+ * `replacementXml` also supplies synthesized automatic note-reference marks,
441
+ * which the parsed model intentionally omits.
442
+ */
443
+ function buildPatchedNotePartXml({ originalXml, baselineXml, serializedXml, replacementXml, elementName, changedParaIds }) {
444
+ const currentElements = collectNoteElementSyntax(serializedXml, elementName);
445
+ const originalElements = collectNoteElementSyntax(originalXml, elementName);
446
+ const replacementElements = collectNoteElementSyntax(replacementXml, elementName);
447
+ const replacementXmlnsDeclarations = collectXmlnsFromOpeningTag(replacementXml);
448
+ const ordinalReplacements = [];
449
+ const serializedParaIds = collectParaIds(serializedXml);
450
+ const effectiveChangedParaIds = changedParaIds ?? collectChangedNoteParaIds(baselineXml, serializedXml);
451
+ const unroutedChangedParaIds = new Set([...effectiveChangedParaIds].filter((paraId) => serializedParaIds.has(paraId)));
452
+ for (const [id, currentSyntaxEntries] of currentElements) {
453
+ const originalSyntaxEntries = originalElements.get(id);
454
+ const replacementSyntaxEntries = replacementElements.get(id);
455
+ if (currentSyntaxEntries.length !== 1 || originalSyntaxEntries?.length !== 1 || replacementSyntaxEntries?.length !== 1) return null;
456
+ const currentSyntax = currentSyntaxEntries[0];
457
+ const originalSyntax = originalSyntaxEntries[0];
458
+ const replacementSyntax = replacementSyntaxEntries[0];
459
+ if (!currentSyntax || !originalSyntax || !replacementSyntax) return null;
460
+ const currentNote = extractNoteElement(serializedXml, currentSyntax, id);
461
+ const originalOffsets = findNoteElement(originalXml, originalSyntax, id);
462
+ const replacementNote = extractNoteElement(replacementXml, replacementSyntax, id);
463
+ if (!currentNote || !originalOffsets || !replacementNote) return null;
464
+ const originalNote = originalXml.slice(originalOffsets.start, originalOffsets.end);
465
+ const currentParagraphs = paragraphRanges(currentNote, currentSyntax.elementPrefix);
466
+ const originalParagraphs = paragraphRanges(originalNote, originalSyntax.elementPrefix);
467
+ const replacementParagraphs = paragraphRanges(replacementNote, replacementSyntax.elementPrefix);
468
+ const noteChangedParaIds = [...collectParaIds(currentNote).keys()].filter((paraId) => unroutedChangedParaIds.has(paraId));
469
+ if (noteChangedParaIds.length === 0) continue;
470
+ if (currentParagraphs.length !== originalParagraphs.length || currentParagraphs.length !== replacementParagraphs.length) {
471
+ for (const paraId of noteChangedParaIds) unroutedChangedParaIds.delete(paraId);
472
+ ordinalReplacements.push({
473
+ start: originalOffsets.start,
474
+ end: originalOffsets.end,
475
+ newXml: rewriteWordprocessingPrefixes(replacementNote, {
476
+ source: replacementSyntax,
477
+ target: originalSyntax,
478
+ sourceXmlnsDeclarations: replacementXmlnsDeclarations
479
+ })
480
+ });
481
+ continue;
482
+ }
483
+ for (let index = 0; index < currentParagraphs.length; index++) {
484
+ const currentRange = currentParagraphs[index];
485
+ const originalRange = originalParagraphs[index];
486
+ const replacementRange = replacementParagraphs[index];
487
+ if (!currentRange || !originalRange || !replacementRange) return null;
488
+ const routedIds = [...collectParaIds(currentNote.slice(currentRange.start, currentRange.end)).keys()].filter((paraId) => unroutedChangedParaIds.has(paraId));
489
+ if (routedIds.length === 0) continue;
490
+ for (const paraId of routedIds) unroutedChangedParaIds.delete(paraId);
491
+ ordinalReplacements.push({
492
+ start: originalOffsets.start + originalRange.start,
493
+ end: originalOffsets.start + originalRange.end,
494
+ newXml: rewriteWordprocessingPrefixes(replacementNote.slice(replacementRange.start, replacementRange.end), {
495
+ source: replacementSyntax,
496
+ target: originalSyntax,
497
+ sourceXmlnsDeclarations: replacementXmlnsDeclarations
498
+ })
499
+ });
500
+ }
501
+ }
502
+ if (unroutedChangedParaIds.size > 0) return null;
503
+ return replaceRanges(originalXml, ordinalReplacements);
504
+ }
320
505
  /**
321
506
  * The full range of the first `<openLiteral …>…</closeTag>` element, or null.
322
507
  * Used to locate an unkeyed sub-element (a level's `mc:AlternateContent`).
@@ -627,4 +812,4 @@ function escapeRegExp(str) {
627
812
  return str.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
628
813
  }
629
814
  //#endregion
630
- export { appendNumberingDefs, buildParagraphOffsetIndex, buildPatchedDocumentXml, buildPatchedNoteXml, buildPatchedNumberingXml, collectAddedNumberingDefs, collectChangedNumberingDefs, collectParaIds, countParagraphElements, extractParagraphXml, findParagraphOffsets, isXmlNameBoundary, validatePatchSafety };
815
+ export { appendNumberingDefs, buildParagraphOffsetIndex, buildPatchedDocumentXml, buildPatchedNotePartXml, buildPatchedNoteXml, buildPatchedNumberingXml, collectAddedNumberingDefs, collectChangedNoteParaIds, collectChangedNumberingDefs, collectParaIds, countParagraphElements, extractParagraphXml, findParagraphOffsets, isXmlNameBoundary, validatePatchSafety };
@@ -104,6 +104,12 @@ declare const WORDPROCESSINGML_NAMESPACE_URIS: ReadonlySet<string>;
104
104
  * from a foreign namespace is not accepted.
105
105
  */
106
106
  declare function findChildByNamespaceUri(parent: XmlElement | null | undefined, namespaceUris: ReadonlySet<string>, localName: string): XmlElement | null;
107
+ type XmlAttributeMatch = {
108
+ name: string;
109
+ value: string;
110
+ };
111
+ /** Find an attribute whose prefix resolves to one of the accepted namespace URIs. */
112
+ declare function findAttributeByNamespaceUri(element: XmlElement | null | undefined, namespaceUris: ReadonlySet<string>, localName: string): XmlAttributeMatch | null;
107
113
  /** Get an attribute whose prefix resolves to one of the accepted namespace URIs. */
108
114
  declare function getAttributeByNamespaceUri(element: XmlElement | null | undefined, namespaceUris: ReadonlySet<string>, localName: string): string | null;
109
115
  /**
@@ -338,4 +344,4 @@ declare function mergeXmlnsDeclarations(inherited: Record<string, string>, eleme
338
344
  */
339
345
  declare function cloneWithXmlnsDeclarations(element: XmlElement, xmlnsDecls: Record<string, string>): XmlElement;
340
346
  //#endregion
341
- export { NAMESPACES, WORDPROCESSINGML_NAMESPACE_URIS, XmlElement, XmlNamespaceScope, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findByFullName, findChild, findChildByLocalName, findChildByNamespaceUri, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributeByNamespaceUri, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseOnOffValue, parseTableMeasurementValue, parseXml, parseXmlDocument };
347
+ export { NAMESPACES, WORDPROCESSINGML_NAMESPACE_URIS, XmlAttributeMatch, XmlElement, XmlNamespaceScope, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findAttributeByNamespaceUri, findByFullName, findChild, findChildByLocalName, findChildByNamespaceUri, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributeByNamespaceUri, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseOnOffValue, parseTableMeasurementValue, parseXml, parseXmlDocument };
@@ -208,16 +208,23 @@ function findChildByNamespaceUri(parent, namespaceUris, localName) {
208
208
  for (const child of parent.elements) if (child.type === "element" && hasLocalName(child.name, localName) && namespaceUris.has(child.namespaceUri ?? "")) return child;
209
209
  return null;
210
210
  }
211
- /** Get an attribute whose prefix resolves to one of the accepted namespace URIs. */
212
- function getAttributeByNamespaceUri(element, namespaceUris, localName) {
211
+ /** Find an attribute whose prefix resolves to one of the accepted namespace URIs. */
212
+ function findAttributeByNamespaceUri(element, namespaceUris, localName) {
213
213
  if (!element?.attributes) return null;
214
214
  for (const [name, value] of Object.entries(element.attributes)) {
215
215
  if (value === void 0 || getLocalName(name) !== localName) continue;
216
216
  const prefix = getNamespacePrefix(name);
217
- if (prefix !== null && namespaceUris.has(resolveNamespaceUri(element.namespaceScope, prefix) ?? "")) return String(value);
217
+ if (prefix !== null && namespaceUris.has(resolveNamespaceUri(element.namespaceScope, prefix) ?? "")) return {
218
+ name,
219
+ value: String(value)
220
+ };
218
221
  }
219
222
  return null;
220
223
  }
224
+ /** Get an attribute whose prefix resolves to one of the accepted namespace URIs. */
225
+ function getAttributeByNamespaceUri(element, namespaceUris, localName) {
226
+ return findAttributeByNamespaceUri(element, namespaceUris, localName)?.value ?? null;
227
+ }
221
228
  function hasLocalName(name, localName) {
222
229
  if (!name) return false;
223
230
  if (name === localName) return true;
@@ -712,4 +719,4 @@ function cloneWithXmlnsDeclarations(element, xmlnsDecls) {
712
719
  return element;
713
720
  }
714
721
  //#endregion
715
- export { NAMESPACES, WORDPROCESSINGML_NAMESPACE_URIS, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findByFullName, findChild, findChildByLocalName, findChildByNamespaceUri, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributeByNamespaceUri, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseOnOffValue, parseTableMeasurementValue, parseXml, parseXmlDocument };
722
+ export { NAMESPACES, WORDPROCESSINGML_NAMESPACE_URIS, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findAttributeByNamespaceUri, findByFullName, findChild, findChildByLocalName, findChildByNamespaceUri, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributeByNamespaceUri, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseOnOffValue, parseTableMeasurementValue, parseXml, parseXmlDocument };
@@ -560,20 +560,39 @@ function layoutTable(block, measure, paginator, footnoteHeightById) {
560
560
  const splittableRow = rows[currentRowIndex];
561
561
  if (canSplitRow(currentRowIndex)) {
562
562
  let consumed = 0;
563
+ const renderedBreakHints = tableRowHasTrackedChanges(block, currentRowIndex) ? [] : breakInfo.renderedBreakHints[currentRowIndex] ?? [];
564
+ let renderedBreakHintIndex = 0;
565
+ const discardPassedRenderedBreakHints = () => {
566
+ while ((renderedBreakHints[renderedBreakHintIndex]?.offset ?? Infinity) <= consumed) renderedBreakHintIndex += 1;
567
+ };
568
+ const advanceAfterNaturalFlowBreak = (previousPageNumber) => {
569
+ discardPassedRenderedBreakHints();
570
+ if (paginator.getCurrentState().page.number > previousPageNumber && renderedBreakHintIndex < renderedBreakHints.length) renderedBreakHintIndex += 1;
571
+ };
563
572
  while (consumed < splittableRow.height) {
564
573
  const sliceState = paginator.getCurrentState();
565
574
  const repeatHeaderRows = shouldRepeatHeaderRows(currentRowIndex, consumed, sliceState);
566
575
  const headerOverhead = repeatHeaderRows ? headerRowsHeight : 0;
567
576
  const sliceAvail = paginator.getAvailableHeight() - headerOverhead - (consumed === 0 ? sliceState.trailingSpacing : 0);
568
577
  let slice = snapRowBreak(breakInfo, currentRowIndex, consumed, sliceAvail);
578
+ let forceRenderedPageBreak = false;
569
579
  if (slice <= 0) {
570
580
  if (!(sliceState.cursorY === sliceState.topMargin && sliceState.page.fragments.length === 0)) {
581
+ const previousPageNumber = sliceState.page.number;
571
582
  paginator.forceColumnBreak();
583
+ advanceAfterNaturalFlowBreak(previousPageNumber);
572
584
  continue;
573
585
  }
574
586
  const from = consumed;
575
587
  slice = (breakInfo.breakOffsets[currentRowIndex]?.find((o) => o > from) ?? splittableRow.height) - consumed;
576
588
  }
589
+ discardPassedRenderedBreakHints();
590
+ const renderedBreakHint = renderedBreakHints[renderedBreakHintIndex];
591
+ if (renderedBreakHint && renderedBreakHint.offset <= consumed + slice && sliceAvail - (renderedBreakHint.offset - consumed) <= renderedBreakHint.lineAdvance * RENDERED_BREAK_REFLOW_TOLERANCE_LINES) {
592
+ slice = renderedBreakHint.offset - consumed;
593
+ renderedBreakHintIndex += 1;
594
+ forceRenderedPageBreak = true;
595
+ }
577
596
  const sliceBottom = consumed + slice;
578
597
  const continuationSkip = sliceBottom < splittableRow.height ? getRowContinuationSkip(breakInfo, currentRowIndex, sliceBottom) : 0;
579
598
  const nextConsumed = Math.min(splittableRow.height, sliceBottom + continuationSkip);
@@ -601,7 +620,12 @@ function layoutTable(block, measure, paginator, footnoteHeightById) {
601
620
  sliceFragment.y = sliceResult.y;
602
621
  sliceFragment.x = computeTableX(sliceResult.state.columnIndex);
603
622
  consumed = nextConsumed;
604
- if (consumed < splittableRow.height) paginator.forceColumnBreak();
623
+ if (consumed < splittableRow.height) if (forceRenderedPageBreak) paginator.forcePageBreak();
624
+ else {
625
+ const previousPageNumber = paginator.getCurrentState().page.number;
626
+ paginator.forceColumnBreak();
627
+ advanceAfterNaturalFlowBreak(previousPageNumber);
628
+ }
605
629
  }
606
630
  currentRowIndex += 1;
607
631
  continue;
@@ -1,5 +1,9 @@
1
1
  import { TableBlock, TableMeasure } from "./types.js";
2
2
  //#region src/layout-engine/tableRowBreak.d.ts
3
+ type RenderedRowBreakHint = {
4
+ offset: number;
5
+ lineAdvance: number;
6
+ };
3
7
  /** Precomputed break geometry for a table. */
4
8
  type TableRowBreakInfo = {
5
9
  /** Cumulative y of the top of each row; `rowTops[rows.length]` is the table height. */
@@ -10,6 +14,8 @@ type TableRowBreakInfo = {
10
14
  * final boundary.
11
15
  */
12
16
  breakOffsets: number[][];
17
+ /** Safe cached page boundaries within each row, relative to the row top. */
18
+ renderedBreakHints: RenderedRowBreakHint[][];
13
19
  /** Suppressible leading paragraph whitespace after each matching break offset. */
14
20
  continuationSkips: number[][];
15
21
  };
@@ -1,3 +1,4 @@
1
+ import { measuredLineAdvance } from "./lineFlow.js";
1
2
  import { measureParagraph } from "./measure/measureParagraph.js";
2
3
  import { buildTableCellFloatingZones, getTableCellContentWidth, getTableCellFloatingImages } from "./measure/tableCellFloating.js";
3
4
  import { createTableCellFlowState, placeTableCellBlock } from "./measure/tableCellFlow.js";
@@ -32,6 +33,10 @@ function shiftCellGeometry(geometry, offset) {
32
33
  if (offset <= 0) return geometry;
33
34
  return {
34
35
  bottoms: geometry.bottoms.map((bottom) => bottom + offset),
36
+ renderedBreakHints: geometry.renderedBreakHints.map((hint) => ({
37
+ offset: hint.offset + offset,
38
+ lineAdvance: hint.lineAdvance
39
+ })),
35
40
  unsafeRanges: geometry.unsafeRanges.map((range) => ({
36
41
  top: range.top + offset,
37
42
  bottom: range.bottom + offset
@@ -45,6 +50,7 @@ function shiftCellGeometry(geometry, offset) {
45
50
  /** Cumulative break geometry within a single cell's content. */
46
51
  function cellBreakGeometry(cell, measure) {
47
52
  const bottoms = [];
53
+ const renderedBreakHints = [];
48
54
  const unsafeRanges = [];
49
55
  const suppressibleLeadingRanges = [];
50
56
  const cellBlocks = cell?.blocks;
@@ -71,6 +77,10 @@ function cellBreakGeometry(cell, measure) {
71
77
  for (const line of blockMeasure.lines) {
72
78
  y += line.floatSkipBefore ?? 0;
73
79
  const top = y;
80
+ if (line.renderedPageBreakBefore === true) renderedBreakHints.push({
81
+ offset: top,
82
+ lineAdvance: measuredLineAdvance(line)
83
+ });
74
84
  y += line.lineHeight;
75
85
  unsafeRanges.push({
76
86
  top,
@@ -97,10 +107,23 @@ function cellBreakGeometry(cell, measure) {
97
107
  }
98
108
  return {
99
109
  bottoms,
110
+ renderedBreakHints,
100
111
  unsafeRanges,
101
112
  suppressibleLeadingRanges
102
113
  };
103
114
  }
115
+ const mergeRenderedBreakHints = (hints) => {
116
+ const merged = [];
117
+ for (const hint of hints.sort((a, b) => a.offset - b.offset)) {
118
+ const previous = merged.at(-1);
119
+ if (previous && Math.abs(previous.offset - hint.offset) <= BREAK_OFFSET_EPSILON) {
120
+ previous.lineAdvance = Math.max(previous.lineAdvance, hint.lineAdvance);
121
+ continue;
122
+ }
123
+ merged.push({ ...hint });
124
+ }
125
+ return merged;
126
+ };
104
127
  const continuationSkipForCell = (geometry, offset) => {
105
128
  if (!geometry.unsafeRanges.some(({ bottom }) => bottom > offset + BREAK_OFFSET_EPSILON)) return;
106
129
  const leadingRange = geometry.suppressibleLeadingRanges.find(({ top, bottom }) => top <= offset + BREAK_OFFSET_EPSILON && bottom > offset + BREAK_OFFSET_EPSILON);
@@ -128,6 +151,7 @@ function buildTableRowBreakInfo(block, measure) {
128
151
  }
129
152
  rowTops.push(acc);
130
153
  const breakOffsets = [];
154
+ const renderedBreakHints = [];
131
155
  const continuationSkips = [];
132
156
  for (let r = 0; r < rowCount; r++) {
133
157
  const rowHeight = measure.rows[r]?.height ?? 0;
@@ -146,11 +170,13 @@ function buildTableRowBreakInfo(block, measure) {
146
170
  }
147
171
  const sortedOffsets = [...offsets].filter((offset) => offset === rowHeight || cellGeometries.every((geometry) => geometry.unsafeRanges.every((range) => !isInsideRange(offset, range)))).sort((a, b) => a - b);
148
172
  breakOffsets.push(sortedOffsets);
173
+ renderedBreakHints.push(mergeRenderedBreakHints(cellGeometries.flatMap((geometry) => geometry.renderedBreakHints).filter(({ offset }) => offset > BREAK_OFFSET_EPSILON && offset < rowHeight - BREAK_OFFSET_EPSILON && cellGeometries.every((geometry) => geometry.unsafeRanges.every((range) => !isInsideRange(offset, range))))));
149
174
  continuationSkips.push(sortedOffsets.map((offset) => continuationSkipAfter(cellGeometries, offset)));
150
175
  }
151
176
  return {
152
177
  rowTops,
153
178
  breakOffsets,
179
+ renderedBreakHints,
154
180
  continuationSkips
155
181
  };
156
182
  }
@@ -216,15 +216,17 @@ const resolveParagraphStyleFontFamily = (styleId, styleResolver) => {
216
216
  };
217
217
  /**
218
218
  * Apply comment marks to PM nodes within a comment range.
219
- * Only the first active comment ID is used (comments don't overlap visually).
220
219
  */
221
220
  function applyCommentMarks(nodes, commentIds) {
222
221
  if (commentIds.size === 0) return nodes;
223
- const commentId = [...commentIds][0];
224
- const commentMark = schema.marks["comment"].create({ commentId });
222
+ const commentMarkType = schema.marks["comment"];
223
+ if (!commentMarkType) return nodes;
224
+ const commentMarks = [...commentIds].toSorted((left, right) => left - right).map((commentId) => commentMarkType.create({ commentId }));
225
225
  return nodes.map((node) => {
226
- if (node.isText || node.isInline && node.type.allowsMarkType(commentMark.type)) return node.mark(commentMark.addToSet(node.marks));
227
- return node;
226
+ if (!node.isText && (!node.isInline || !node.type.allowsMarkType(commentMarkType))) return node;
227
+ let marks = node.marks;
228
+ for (const commentMark of commentMarks) marks = commentMark.addToSet(marks);
229
+ return node.mark(marks);
228
230
  });
229
231
  }
230
232
  function anchorPointComment(nodes, commentId) {
@@ -11,6 +11,7 @@ const CommentExtension = createMarkExtension({
11
11
  name: "comment",
12
12
  schemaMarkName: "comment",
13
13
  markSpec: {
14
+ excludes: "",
14
15
  attrs: {
15
16
  /** Comment ID (matches Comment.id) */
16
17
  commentId: { default: 0 } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
4
4
  "description": "Headless, framework-neutral core of folio: the OOXML (.docx) parser, document model, ProseMirror integration, and page-layout engine. No React.",
5
5
  "keywords": [
6
6
  "document-model",