@stll/folio-core 0.25.1 → 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.
- package/dist/ai-edits/headless.d.ts +6 -0
- package/dist/ai-edits/headless.js +60 -7
- package/dist/ai-edits/read.d.ts +26 -1
- package/dist/ai-edits/read.js +26 -1
- package/dist/docx/rezip.d.ts +2 -0
- package/dist/docx/rezip.js +61 -58
- package/dist/docx/selectiveXmlPatch.d.ts +24 -1
- package/dist/docx/selectiveXmlPatch.js +186 -1
- package/dist/docx/xmlParser.d.ts +7 -1
- package/dist/docx/xmlParser.js +11 -4
- package/dist/prosemirror/conversion/toProseDoc.js +7 -5
- package/dist/prosemirror/extensions/marks/CommentExtension.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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)
|
|
680
|
-
|
|
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) => {
|
package/dist/ai-edits/read.d.ts
CHANGED
|
@@ -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 };
|
package/dist/ai-edits/read.js
CHANGED
|
@@ -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 };
|
package/dist/docx/rezip.d.ts
CHANGED
|
@@ -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
|
package/dist/docx/rezip.js
CHANGED
|
@@ -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,
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
1142
|
-
|
|
1143
|
-
const patched =
|
|
1144
|
-
|
|
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 };
|
package/dist/docx/xmlParser.d.ts
CHANGED
|
@@ -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 };
|
package/dist/docx/xmlParser.js
CHANGED
|
@@ -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
|
-
/**
|
|
212
|
-
function
|
|
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
|
|
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 };
|
|
@@ -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
|
|
224
|
-
|
|
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
|
|
227
|
-
|
|
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) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stll/folio-core",
|
|
3
|
-
"version": "0.25.
|
|
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",
|