@stll/folio-core 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -712,14 +712,16 @@ function parseParagraphContents(paraElement, styles, theme, _numbering, rels, me
712
712
  const run = parseRun(runElement, styles, theme, rels, media, inScopeXmlns);
713
713
  const commentReferenceId = getCommentReferenceId(runElement);
714
714
  let hasFieldBegin = false;
715
+ let beginFldLock = false;
716
+ let beginDirty = false;
715
717
  let hasFieldSeparate = false;
716
718
  let hasFieldEnd = false;
717
719
  let endOriginalValue;
718
720
  let instrText = "";
719
721
  for (const content of run.content) if (content.type === "fieldChar") if (content.charType === "begin") {
720
722
  hasFieldBegin = true;
721
- if (content.fldLock) complexFieldLock = true;
722
- if (content.dirty) complexFieldDirty = true;
723
+ beginFldLock = content.fldLock === true;
724
+ beginDirty = content.dirty === true;
723
725
  } else if (content.charType === "separate") hasFieldSeparate = true;
724
726
  else {
725
727
  hasFieldEnd = true;
@@ -736,8 +738,8 @@ function parseParagraphContents(paraElement, styles, theme, _numbering, rels, me
736
738
  complexFieldInstr = "";
737
739
  complexFieldCodeRuns = [];
738
740
  complexFieldResultRuns = [];
739
- complexFieldLock = false;
740
- complexFieldDirty = false;
741
+ complexFieldLock = beginFldLock;
742
+ complexFieldDirty = beginDirty;
741
743
  complexFieldFormatting = run.formatting;
742
744
  }
743
745
  if (inComplexField) {
@@ -371,8 +371,12 @@ function parseHeadersAndFooters(raw, styles, theme, numbering, rels, media) {
371
371
  * Parse footnotes and endnotes from raw content
372
372
  */
373
373
  function parseNotesContent(raw, styles, theme, numbering, rels, media) {
374
- const footnoteMap = parseFootnotes(raw.footnotesXml, styles, theme, numbering, rels, media);
375
- const endnoteMap = parseEndnotes(raw.endnotesXml, styles, theme, numbering, rels, media);
374
+ const relsForNotePart = (partPath) => {
375
+ const xml = getMapCaseInsensitive(raw.allXml, getRelationshipsPathForPart(partPath));
376
+ return xml ? parseRelationships(xml) : rels;
377
+ };
378
+ const footnoteMap = parseFootnotes(raw.footnotesXml, styles, theme, numbering, relsForNotePart("word/footnotes.xml"), media);
379
+ const endnoteMap = parseEndnotes(raw.endnotesXml, styles, theme, numbering, relsForNotePart("word/endnotes.xml"), media);
376
380
  return {
377
381
  footnotes: footnoteMap.getNormalFootnotes(),
378
382
  endnotes: endnoteMap.getNormalEndnotes()
@@ -1,4 +1,5 @@
1
1
  import { document_d_exports } from "../types/document.js";
2
+ import { BlockContent, Hyperlink } from "../types/content.js";
2
3
  import { RawDocxContent } from "./unzip.js";
3
4
  import JSZip from "jszip";
4
5
  //#region src/docx/rezip.d.ts
@@ -9,6 +10,11 @@ declare class DocxPackageFidelityError extends Error {
9
10
  * Find the highest rId number in a relationships XML string.
10
11
  */
11
12
  declare function findMaxRId(relsXml: string): number;
13
+ /**
14
+ * Collect all hyperlinks that have an href but no rId from block content.
15
+ * These are newly created hyperlinks that need relationship entries.
16
+ */
17
+ declare function collectHyperlinksWithoutRId(blocks: BlockContent[]): Hyperlink[];
12
18
  /**
13
19
  * Options for repacking DOCX
14
20
  */
@@ -126,6 +132,8 @@ declare function hasUnmaterializedHeaderFooter(doc: document_d_exports.Document)
126
132
  */
127
133
  declare function hasModelDrivenPictureWatermark(doc: document_d_exports.Document): boolean;
128
134
  declare function collectHeaderFooterUpdates(doc: document_d_exports.Document): Map<string, string>;
135
+ /** `word/Footnotes.xml` -> `word/_rels/Footnotes.xml.rels` (casing preserved). */
136
+ declare function notePartRelsPath(partPath: string): string;
129
137
  /** Update existing core-property values without synthesizing absent metadata. */
130
138
  declare function updateCoreProperties(corePropsXml: string, { updateModifiedDate, modifiedBy }: {
131
139
  updateModifiedDate?: boolean;
@@ -163,4 +171,4 @@ declare function createEmptyDocx(): Promise<ArrayBuffer>;
163
171
  */
164
172
  declare function createDocx(doc: document_d_exports.Document): Promise<ArrayBuffer>;
165
173
  //#endregion
166
- export { COMMENTS_CONTENT_TYPE, COMMENTS_EXTENDED_CONTENT_TYPE, COMMENTS_EXTENDED_PART, COMMENTS_EXTENDED_PART_LOWER, DocxPackageFidelityError, RepackOptions, addCommentsExtendedOverride, addCommentsExtendedRelationship, addMedia, addRelationship, applyUpdatesToZip, collectHeaderFooterUpdates, createDocx, createEmptyDocx, findMaxRId, hasModelDrivenPictureWatermark, hasUnmaterializedHeaderFooter, isDocxBuffer, removeCommentsExtendedOverride, removeCommentsExtendedRelationship, repackDocx, repackDocxFromRaw, updateCoreProperties, updateDocumentXml, updateMultipleFiles, updateXmlFile, validateDocx };
174
+ export { COMMENTS_CONTENT_TYPE, COMMENTS_EXTENDED_CONTENT_TYPE, COMMENTS_EXTENDED_PART, COMMENTS_EXTENDED_PART_LOWER, DocxPackageFidelityError, RepackOptions, addCommentsExtendedOverride, addCommentsExtendedRelationship, addMedia, addRelationship, applyUpdatesToZip, collectHeaderFooterUpdates, collectHyperlinksWithoutRId, createDocx, createEmptyDocx, findMaxRId, hasModelDrivenPictureWatermark, hasUnmaterializedHeaderFooter, isDocxBuffer, notePartRelsPath, removeCommentsExtendedOverride, removeCommentsExtendedRelationship, repackDocx, repackDocxFromRaw, updateCoreProperties, updateDocumentXml, updateMultipleFiles, updateXmlFile, validateDocx };
@@ -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 { buildParagraphOffsetIndex, buildPatchedNoteXml, buildPatchedNumberingXml, collectChangedNumberingDefs, collectParaIds } from "./selectiveXmlPatch.js";
9
+ import { appendNumberingDefs, buildParagraphOffsetIndex, buildPatchedNoteXml, buildPatchedNumberingXml, collectAddedNumberingDefs, 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";
@@ -14,11 +14,11 @@ import { serializeHeaderFooter } from "./serializer/headerFooterSerializer.js";
14
14
  import { serializeEndnotes, serializeFootnotes, serializeNewEndnotesPart, serializeNewFootnotesPart } from "./serializer/noteSerializer.js";
15
15
  import { serializeNumberingXml } from "./serializer/numberingSerializer.js";
16
16
  import { serializeSettingsXml } from "./serializer/settingsSerializer.js";
17
- import { serializeStylesXml } from "./serializer/stylesSerializer.js";
17
+ import { serializeStyle, serializeStylesXml } from "./serializer/stylesSerializer.js";
18
18
  import { serializeThemeXml } from "./serializer/themeSerializer.js";
19
19
  import { escapeXml } from "./serializer/xmlUtils.js";
20
20
  import { isPreservableDocxEntry } from "./unzip.js";
21
- import { findChild, getChildElements, getLocalName, getNamespaceUri, matchesName, parseXml } from "./xmlParser.js";
21
+ import { WORDPROCESSINGML_NAMESPACE_URIS, findChild, getChildElements, getLocalName, getNamespaceUri, matchesName, parseXml } from "./xmlParser.js";
22
22
  import { assertXmlResourceLimits } from "./xmlResourceLimits.js";
23
23
  import { panic } from "better-result";
24
24
  import JSZip from "jszip";
@@ -70,8 +70,7 @@ function findMaxRId(relsXml) {
70
70
  }
71
71
  return maxId;
72
72
  }
73
- const WORDPROCESSINGML_NAMESPACES = /* @__PURE__ */ new Set(["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"]);
74
- const isWordprocessingElement = (element, localName) => getLocalName(element.name) === localName && WORDPROCESSINGML_NAMESPACES.has(getNamespaceUri(element) ?? "");
73
+ const isWordprocessingElement = (element, localName) => getLocalName(element.name) === localName && WORDPROCESSINGML_NAMESPACE_URIS.has(getNamespaceUri(element) ?? "");
75
74
  const countDocumentSections = (xml) => {
76
75
  assertXmlResourceLimits(xml);
77
76
  let count = 0;
@@ -212,15 +211,22 @@ function relativeTargetForPart(partPath, absoluteTarget) {
212
211
  while (shared < fromDir.length && shared < to.length - 1 && fromDir[shared] === to[shared]) shared += 1;
213
212
  return `${"../".repeat(fromDir.length - shared)}${to.slice(shared).join("/")}`;
214
213
  }
215
- function collectImageParts(doc) {
214
+ /**
215
+ * Every package part whose block content may mint relationships (images,
216
+ * external hyperlinks), paired with the rels part those relationships belong
217
+ * to. Header/footer parts own `word/_rels/<part>.rels`; footnotes and endnotes
218
+ * own `word/_rels/<notes part>.rels`, derived from the ZIP entry the note
219
+ * serializer resolves (case-insensitively) so a producer's casing is reused
220
+ * instead of minting a second, lowercase rels part.
221
+ */
222
+ function collectDocxParts(doc, zip) {
216
223
  const parts = [{
217
224
  relsPath: "word/_rels/document.xml.rels",
218
225
  blocks: doc.package.document.content
219
226
  }];
220
227
  const rels = doc.package.relationships;
221
- if (!rels) return parts;
222
228
  const addHeaderFooterParts = (map, type) => {
223
- if (!map) return;
229
+ if (!map || !rels) return;
224
230
  for (const [rId, headerFooter] of map.entries()) {
225
231
  const rel = rels.get(rId);
226
232
  if (!rel || rel.type !== type || !rel.target) continue;
@@ -232,6 +238,16 @@ function collectImageParts(doc) {
232
238
  };
233
239
  addHeaderFooterParts(doc.package.headers, RELATIONSHIP_TYPES.header);
234
240
  addHeaderFooterParts(doc.package.footers, RELATIONSHIP_TYPES.footer);
241
+ const addNoteParts = (notes, conventionalLowerPath) => {
242
+ if (!notes || notes.length === 0) return;
243
+ const partPath = findNotePartEntry(zip, conventionalLowerPath)?.name ?? conventionalLowerPath;
244
+ parts.push({
245
+ relsPath: notePartRelsPath(partPath),
246
+ blocks: notes.flatMap((note) => note.content)
247
+ });
248
+ };
249
+ addNoteParts(doc.package.footnotes, "word/footnotes.xml");
250
+ addNoteParts(doc.package.endnotes, "word/endnotes.xml");
235
251
  return parts;
236
252
  }
237
253
  async function readRelsOrStub(zip, relsPath) {
@@ -360,30 +376,29 @@ function collectHyperlinksWithoutRId(blocks) {
360
376
  for (const block of blocks) if (block.type === "paragraph") {
361
377
  for (const item of block.content) if (item.type === "hyperlink" && item.href && !item.rId && !item.anchor) hyperlinks.push(item);
362
378
  } else if (block.type === "table") for (const row of block.rows) for (const cell of row.cells) hyperlinks.push(...collectHyperlinksWithoutRId(cell.content));
379
+ else hyperlinks.push(...collectHyperlinksWithoutRId(block.content));
363
380
  return hyperlinks;
364
381
  }
365
382
  /**
366
- * Process newly created hyperlinks: assign rIds and add relationship entries.
367
- * Mutates the hyperlinks' rId fields in-place.
383
+ * Process newly created hyperlinks in every part: assign rIds and add
384
+ * relationship entries to the owning part's rels. Mutates the hyperlinks' rId
385
+ * fields in-place.
368
386
  */
369
- async function processNewHyperlinks(newHyperlinks, zip, compressionLevel) {
370
- if (newHyperlinks.length === 0) return;
371
- const relsPath = "word/_rels/document.xml.rels";
372
- const relsFile = zip.file(relsPath);
373
- if (!relsFile) return;
374
- let relsXml = await relsFile.async("text");
375
- let maxId = findMaxRId(relsXml);
376
- const relEntries = [];
377
- for (const hyperlink of newHyperlinks) {
378
- maxId++;
379
- const newRId = `rId${maxId}`;
380
- if (!hyperlink.href) continue;
381
- relEntries.push(`<Relationship Id="${newRId}" Type="${RELATIONSHIP_TYPES.hyperlink}" Target="${escapeXml(hyperlink.href)}" TargetMode="External"/>`);
382
- hyperlink.rId = newRId;
383
- }
384
- if (relEntries.length > 0) {
385
- relsXml = relsXml.replace("</Relationships>", `${relEntries.join("")}</Relationships>`);
386
- zip.file(relsPath, relsXml, {
387
+ async function processNewHyperlinks(parts, zip, compressionLevel) {
388
+ for (const { relsPath, blocks } of parts) {
389
+ const newHyperlinks = collectHyperlinksWithoutRId(blocks);
390
+ if (newHyperlinks.length === 0) continue;
391
+ const relsXml = await readRelsOrStub(zip, relsPath);
392
+ let maxId = findMaxRId(relsXml);
393
+ const relEntries = [];
394
+ for (const hyperlink of newHyperlinks) {
395
+ if (!hyperlink.href) continue;
396
+ maxId++;
397
+ const newRId = `rId${maxId}`;
398
+ relEntries.push(`<Relationship Id="${newRId}" Type="${RELATIONSHIP_TYPES.hyperlink}" Target="${escapeXml(hyperlink.href)}" TargetMode="External"/>`);
399
+ hyperlink.rId = newRId;
400
+ }
401
+ zip.file(relsPath, relsXml.replace("</Relationships>", `${relEntries.join("")}</Relationships>`), {
387
402
  compression: "DEFLATE",
388
403
  compressionOptions: { level: compressionLevel }
389
404
  });
@@ -420,8 +435,9 @@ const cloneDocxZip = (source) => {
420
435
  };
421
436
  const finishRepack = async ({ document, originalZip, outputZip, originalDocumentXml, originalCorePropertiesXml, compressionLevel, updateModifiedDate, modifiedBy }) => {
422
437
  await materializeNewHeaderFooterParts(document, outputZip, compressionLevel);
423
- await processNewImages(collectImageParts(document), outputZip, compressionLevel);
424
- await processNewHyperlinks(collectHyperlinksWithoutRId(document.package.document.content), outputZip, compressionLevel);
438
+ const parts = collectDocxParts(document, outputZip);
439
+ await processNewImages(parts, outputZip, compressionLevel);
440
+ await processNewHyperlinks(parts, outputZip, compressionLevel);
425
441
  assertValidFolioDocumentModel(document, "Cannot repack invalid DOCX document model");
426
442
  applyReplyThreadMarkers(document);
427
443
  const documentXml = serializeDocument(document);
@@ -434,6 +450,7 @@ const finishRepack = async ({ document, originalZip, outputZip, originalDocument
434
450
  serializeHeadersFootersToZip(document, outputZip, compressionLevel);
435
451
  await serializeNotesToZip(document, originalZip, outputZip, compressionLevel);
436
452
  await serializeNumberingIntoZip(document, originalZip, outputZip, compressionLevel);
453
+ await serializeAddedStylesIntoZip(document, originalZip, outputZip, compressionLevel);
437
454
  await serializeCommentsToZip(document, outputZip, compressionLevel);
438
455
  if (updateModifiedDate && originalCorePropertiesXml) {
439
456
  const updatedCoreProperties = updateCoreProperties(originalCorePropertiesXml, {
@@ -499,8 +516,9 @@ async function repackDocxFromRaw(doc, rawContent, options = {}) {
499
516
  });
500
517
  }
501
518
  await materializeNewHeaderFooterParts(exportDocument, newZip, compressionLevel);
502
- await processNewImages(collectImageParts(exportDocument), newZip, compressionLevel);
503
- await processNewHyperlinks(collectHyperlinksWithoutRId(exportDocument.package.document.content), newZip, compressionLevel);
519
+ const parts = collectDocxParts(exportDocument, newZip);
520
+ await processNewImages(parts, newZip, compressionLevel);
521
+ await processNewHyperlinks(parts, newZip, compressionLevel);
504
522
  assertValidFolioDocumentModel(exportDocument, "Cannot repack invalid DOCX document model");
505
523
  applyReplyThreadMarkers(exportDocument);
506
524
  const documentXml = serializeDocument(exportDocument);
@@ -513,6 +531,7 @@ async function repackDocxFromRaw(doc, rawContent, options = {}) {
513
531
  serializeHeadersFootersToZip(exportDocument, newZip, compressionLevel);
514
532
  await serializeNotesToZip(exportDocument, rawContent.originalZip, newZip, compressionLevel);
515
533
  await serializeNumberingIntoZip(exportDocument, rawContent.originalZip, newZip, compressionLevel);
534
+ await serializeAddedStylesIntoZip(exportDocument, rawContent.originalZip, newZip, compressionLevel);
516
535
  await serializeCommentsToZip(exportDocument, newZip, compressionLevel);
517
536
  if (updateModifiedDate && rawContent.corePropsXml) {
518
537
  const updatedCoreProps = updateCoreProperties(rawContent.corePropsXml, {
@@ -768,14 +787,17 @@ function findMaxHeaderFooterNum(zip, prefix) {
768
787
  * relationship.
769
788
  */
770
789
  async function materializeNewHeaderFooterParts(doc, zip, compressionLevel) {
771
- const rels = doc.package.relationships;
772
- if (!rels) return;
773
790
  if (!hasUnmaterializedHeaderFooter(doc)) return;
791
+ doc.package.relationships ??= /* @__PURE__ */ new Map();
792
+ const rels = doc.package.relationships;
774
793
  const relEntries = [];
775
794
  const overrides = [];
776
795
  let maxHeaderNum = findMaxHeaderFooterNum(zip, "header");
777
796
  let maxFooterNum = findMaxHeaderFooterNum(zip, "footer");
778
- let maxRId = 0;
797
+ const relsPath = "word/_rels/document.xml.rels";
798
+ const relsXml = await readRelsOrStub(zip, relsPath);
799
+ const zipRels = parseRelationships(relsXml);
800
+ let maxRId = findMaxRId(relsXml);
779
801
  const considerNumericRId = (id) => {
780
802
  const match = /^rId(?<num>\d+)$/u.exec(id);
781
803
  if (match) {
@@ -792,7 +814,7 @@ async function materializeNewHeaderFooterParts(doc, zip, compressionLevel) {
792
814
  const materialize = (map, relType, prefix, contentType, isHeader) => {
793
815
  if (!map) return;
794
816
  for (const rId of [...map.keys()]) {
795
- const existing = rels.get(rId);
817
+ const existing = rels.get(rId) ?? zipRels.get(rId);
796
818
  if (existing && existing.type === relType && existing.target) continue;
797
819
  let effectiveRId = rId;
798
820
  if (existing) {
@@ -820,8 +842,6 @@ async function materializeNewHeaderFooterParts(doc, zip, compressionLevel) {
820
842
  materialize(doc.package.footers, RELATIONSHIP_TYPES.footer, "footer", FOOTER_CONTENT_TYPE, false);
821
843
  if (relEntries.length === 0) return;
822
844
  const compressionOptions = { level: compressionLevel };
823
- const relsPath = "word/_rels/document.xml.rels";
824
- const relsXml = await readRelsOrStub(zip, relsPath);
825
845
  zip.file(relsPath, relsXml.replace("</Relationships>", `${relEntries.join("")}</Relationships>`), {
826
846
  compression: "DEFLATE",
827
847
  compressionOptions
@@ -1063,14 +1083,56 @@ async function serializeNumberingIntoZip(doc, originalZip, newZip, compressionLe
1063
1083
  }
1064
1084
  const currentXml = serializeNumberingXml(numbering);
1065
1085
  const changed = collectChangedNumberingDefs(baseline.serializedXml, currentXml);
1066
- if (changed.abstractNums.size === 0 && changed.nums.size === 0) return;
1067
- const patched = buildPatchedNumberingXml(baseline.originalXml, currentXml, changed);
1086
+ const added = collectAddedNumberingDefs(baseline.serializedXml, currentXml);
1087
+ const hasChanged = changed.abstractNums.size > 0 || changed.nums.size > 0;
1088
+ const hasAdded = added.abstractNums.size > 0 || added.nums.size > 0;
1089
+ if (!hasChanged && !hasAdded) return;
1090
+ const spliced = buildPatchedNumberingXml(baseline.originalXml, currentXml, changed);
1091
+ if (spliced === null) return;
1092
+ const patched = appendNumberingDefs(spliced, currentXml, added);
1068
1093
  if (patched === null) return;
1069
1094
  newZip.file(file.name, patched, {
1070
1095
  compression: "DEFLATE",
1071
1096
  compressionOptions: { level: compressionLevel }
1072
1097
  });
1073
1098
  }
1099
+ const STYLES_PART_PATH = "word/styles.xml";
1100
+ const STYLES_CLOSE_ROOT = "</w:styles>";
1101
+ const STYLE_ID_PATTERN = /<w:style\b[^>]*?\bw:styleId="(?<id>[^"]+)"/gu;
1102
+ /**
1103
+ * Append styles the model defines but the original `word/styles.xml` lacks.
1104
+ * Existing styles stay byte-exact (edits to them are not written here, the
1105
+ * part is otherwise preserved verbatim); only minted definitions, such as the
1106
+ * per-language clones a bilingual transform adds, are emitted before the root
1107
+ * close so paragraphs referencing them resolve on reopen.
1108
+ */
1109
+ async function serializeAddedStylesIntoZip(doc, originalZip, newZip, compressionLevel) {
1110
+ const styles = doc.package.styles;
1111
+ if (!styles || styles.styles.length === 0) return;
1112
+ const file = findNotePartEntry(originalZip, STYLES_PART_PATH);
1113
+ const originalXml = file ? await file.async("text") : null;
1114
+ const rootClose = originalXml?.lastIndexOf(STYLES_CLOSE_ROOT) ?? -1;
1115
+ if (file === null || originalXml === null || rootClose < 0) {
1116
+ await materializeNewNotePart({
1117
+ contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml",
1118
+ newZip,
1119
+ partPath: STYLES_PART_PATH,
1120
+ relationshipType: RELATIONSHIP_TYPES.styles,
1121
+ serializedPart: serializeStylesXml(styles),
1122
+ compressionLevel
1123
+ });
1124
+ return;
1125
+ }
1126
+ const existing = /* @__PURE__ */ new Set();
1127
+ for (const match of originalXml.matchAll(STYLE_ID_PATTERN)) existing.add(match.groups["id"]);
1128
+ const added = styles.styles.filter((style) => !existing.has(style.styleId));
1129
+ if (added.length === 0) return;
1130
+ const patched = originalXml.slice(0, rootClose) + added.map(serializeStyle).join("") + originalXml.slice(rootClose);
1131
+ newZip.file(file.name, patched, {
1132
+ compression: "DEFLATE",
1133
+ compressionOptions: { level: compressionLevel }
1134
+ });
1135
+ }
1074
1136
  async function patchNotePartIntoZip(conventionalLowerPath, currentXml, baselineFrom, originalZip, newZip, compressionLevel) {
1075
1137
  const file = findNotePartEntry(originalZip, conventionalLowerPath);
1076
1138
  if (!file) return;
@@ -1109,6 +1171,11 @@ function collectChangedNoteParaIds(baselineXml, currentXml) {
1109
1171
  }
1110
1172
  return changed;
1111
1173
  }
1174
+ /** `word/Footnotes.xml` -> `word/_rels/Footnotes.xml.rels` (casing preserved). */
1175
+ function notePartRelsPath(partPath) {
1176
+ const lastSlash = partPath.lastIndexOf("/");
1177
+ return `${partPath.slice(0, lastSlash + 1)}_rels/${partPath.slice(lastSlash + 1)}.rels`;
1178
+ }
1112
1179
  /**
1113
1180
  * Locate a note part in the ZIP, matching case-insensitively so a producer that
1114
1181
  * cased the entry differently still resolves to the existing part.
@@ -1362,4 +1429,4 @@ const assertStyleNumberingReferences = (doc) => {
1362
1429
  for (const numbering of doc.package.numbering?.nums ?? []) if (!availableAbstract.has(numbering.abstractNumId)) panic(`Numbering definition ${numbering.numId} references missing abstract numbering`);
1363
1430
  };
1364
1431
  //#endregion
1365
- export { COMMENTS_CONTENT_TYPE, COMMENTS_EXTENDED_CONTENT_TYPE, COMMENTS_EXTENDED_PART, COMMENTS_EXTENDED_PART_LOWER, DocxPackageFidelityError, addCommentsExtendedOverride, addCommentsExtendedRelationship, addMedia, addRelationship, applyUpdatesToZip, collectHeaderFooterUpdates, createDocx, createEmptyDocx, findMaxRId, hasModelDrivenPictureWatermark, hasUnmaterializedHeaderFooter, isDocxBuffer, removeCommentsExtendedOverride, removeCommentsExtendedRelationship, repackDocx, repackDocxFromRaw, updateCoreProperties, updateDocumentXml, updateMultipleFiles, updateXmlFile, validateDocx };
1432
+ export { COMMENTS_CONTENT_TYPE, COMMENTS_EXTENDED_CONTENT_TYPE, COMMENTS_EXTENDED_PART, COMMENTS_EXTENDED_PART_LOWER, DocxPackageFidelityError, addCommentsExtendedOverride, addCommentsExtendedRelationship, addMedia, addRelationship, applyUpdatesToZip, collectHeaderFooterUpdates, collectHyperlinksWithoutRId, createDocx, createEmptyDocx, findMaxRId, hasModelDrivenPictureWatermark, hasUnmaterializedHeaderFooter, isDocxBuffer, notePartRelsPath, removeCommentsExtendedOverride, removeCommentsExtendedRelationship, repackDocx, repackDocxFromRaw, updateCoreProperties, updateDocumentXml, updateMultipleFiles, updateXmlFile, validateDocx };
@@ -182,6 +182,12 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
182
182
  if (originalBuffer.byteLength > maxBytes) return null;
183
183
  const content = doc.package.document.content;
184
184
  if (hasNewImagesOrHyperlinks(content)) return null;
185
+ for (const part of [
186
+ ...doc.package.headers?.values() ?? [],
187
+ ...doc.package.footers?.values() ?? [],
188
+ ...doc.package.footnotes ?? [],
189
+ ...doc.package.endnotes ?? []
190
+ ]) if (hasNewImagesOrHyperlinks(part.content)) return null;
185
191
  if (hasUnmaterializedHeaderFooter(doc)) return null;
186
192
  if (hasModelDrivenPictureWatermark(doc)) return null;
187
193
  if (withoutOrphanCommentRanges(doc) !== doc) return null;
@@ -129,5 +129,23 @@ declare function collectChangedNumberingDefs(baselineXml: string, currentXml: st
129
129
  * in either input (so the caller preserves the original part verbatim).
130
130
  */
131
131
  declare function buildPatchedNumberingXml(originalXml: string, currentXml: string, changed: ChangedNumberingDefs): string | null;
132
+ /**
133
+ * Numbering definitions present in `currentXml` (the model's serialization)
134
+ * but absent from `baselineXml` (the re-parsed original): definitions a
135
+ * transform minted. `collectChangedNumberingDefs` deliberately skips these
136
+ * because they cannot be spliced by id; they are appended instead.
137
+ */
138
+ declare function collectAddedNumberingDefs(baselineXml: string, currentXml: string): ChangedNumberingDefs;
139
+ /**
140
+ * Append added `w:abstractNum` / `w:num` definitions from `currentXml` to
141
+ * `xml`. ECMA-376 §17.9 orders every `w:abstractNum` before the first `w:num`,
142
+ * so abstract definitions go right before the first `<w:num ` (or the root
143
+ * close when the part has none) and instances before the root close. A
144
+ * synthetic numFmt in an added abstract is restored from the original
145
+ * definition it was cloned from (the one with an identical body), so a custom
146
+ * format survives cloning. Returns null when an added id cannot be extracted
147
+ * or the part has no `</w:numbering>`.
148
+ */
149
+ declare function appendNumberingDefs(xml: string, currentXml: string, added: ChangedNumberingDefs): string | null;
132
150
  //#endregion
133
- export { ChangedNumberingDefs, ParagraphOffsets, PatchSafetyOptions, PatchValidationResult, buildParagraphOffsetIndex, buildPatchedDocumentXml, buildPatchedNoteXml, buildPatchedNumberingXml, collectChangedNumberingDefs, collectParaIds, countParagraphElements, extractParagraphXml, findParagraphOffsets, isXmlNameBoundary, validatePatchSafety };
151
+ export { ChangedNumberingDefs, ParagraphOffsets, PatchSafetyOptions, PatchValidationResult, appendNumberingDefs, buildParagraphOffsetIndex, buildPatchedDocumentXml, buildPatchedNoteXml, buildPatchedNumberingXml, collectAddedNumberingDefs, collectChangedNumberingDefs, collectParaIds, countParagraphElements, extractParagraphXml, findParagraphOffsets, isXmlNameBoundary, validatePatchSafety };
@@ -552,8 +552,79 @@ function buildPatchedNumberingXml(originalXml, currentXml, changed) {
552
552
  for (const { start, end, newXml } of replacements) result = result.slice(0, start) + newXml + result.slice(end);
553
553
  return result;
554
554
  }
555
+ /**
556
+ * Numbering definitions present in `currentXml` (the model's serialization)
557
+ * but absent from `baselineXml` (the re-parsed original): definitions a
558
+ * transform minted. `collectChangedNumberingDefs` deliberately skips these
559
+ * because they cannot be spliced by id; they are appended instead.
560
+ */
561
+ function collectAddedNumberingDefs(baselineXml, currentXml) {
562
+ const addedForKind = (kind) => {
563
+ const added = /* @__PURE__ */ new Set();
564
+ const baselineIndex = buildNumberingElementOffsetIndex(baselineXml, kind);
565
+ const currentIndex = buildNumberingElementOffsetIndex(currentXml, kind);
566
+ for (const [id, range] of currentIndex) if (range && !baselineIndex.has(id)) added.add(id);
567
+ return added;
568
+ };
569
+ return {
570
+ abstractNums: addedForKind("abstractNum"),
571
+ nums: addedForKind("num")
572
+ };
573
+ }
574
+ const NUMBERING_CLOSE_ROOT = "</w:numbering>";
575
+ /**
576
+ * Append added `w:abstractNum` / `w:num` definitions from `currentXml` to
577
+ * `xml`. ECMA-376 §17.9 orders every `w:abstractNum` before the first `w:num`,
578
+ * so abstract definitions go right before the first `<w:num ` (or the root
579
+ * close when the part has none) and instances before the root close. A
580
+ * synthetic numFmt in an added abstract is restored from the original
581
+ * definition it was cloned from (the one with an identical body), so a custom
582
+ * format survives cloning. Returns null when an added id cannot be extracted
583
+ * or the part has no `</w:numbering>`.
584
+ */
585
+ function appendNumberingDefs(xml, currentXml, added) {
586
+ if (added.abstractNums.size === 0 && added.nums.size === 0) return xml;
587
+ const rootClose = xml.lastIndexOf(NUMBERING_CLOSE_ROOT);
588
+ if (rootClose < 0) return null;
589
+ const abstractXmls = [];
590
+ for (const id of added.abstractNums) {
591
+ const def = extractNumberingElementXml(currentXml, "abstractNum", id);
592
+ if (def === null) return null;
593
+ abstractXmls.push(restoreClonedLevelNumFmts(xml, currentXml, def, id));
594
+ }
595
+ const numXmls = [];
596
+ for (const id of added.nums) {
597
+ const def = extractNumberingElementXml(currentXml, "num", id);
598
+ if (def === null) return null;
599
+ numXmls.push(def);
600
+ }
601
+ const firstNum = findFirstElement(xml, NUMBERING_OPEN_LITERAL.num, NUMBERING_CLOSE_TAG.num);
602
+ const abstractInsertAt = firstNum ? firstNum.start : rootClose;
603
+ const head = xml.slice(0, abstractInsertAt);
604
+ const middle = xml.slice(abstractInsertAt, rootClose);
605
+ const tail = xml.slice(rootClose);
606
+ return head + abstractXmls.join("") + middle + numXmls.join("") + tail;
607
+ }
608
+ /**
609
+ * For an added abstract definition that still carries a synthetic numFmt, find
610
+ * the original abstract it was cloned from (same serialized body apart from the
611
+ * id) and restore the level formats from it.
612
+ */
613
+ function restoreClonedLevelNumFmts(originalXml, currentXml, addedDefXml, addedId) {
614
+ if (!SYNTHETIC_NUM_FMT_PATTERN.test(addedDefXml)) return addedDefXml;
615
+ const addedBody = stripAbstractNumId(addedDefXml, addedId);
616
+ const originalIds = collectElementIds(originalXml, NUMBERING_OPEN_LITERAL.abstractNum, NUMBERING_ID_ATTR.abstractNum);
617
+ for (const [id] of originalIds) {
618
+ const candidate = extractNumberingElementXml(currentXml, "abstractNum", id);
619
+ if (candidate === null || stripAbstractNumId(candidate, id) !== addedBody) continue;
620
+ const originalDef = extractNumberingElementXml(originalXml, "abstractNum", id);
621
+ if (originalDef !== null) return restoreLevelNumFmts(originalDef, addedDefXml);
622
+ }
623
+ return restoreLevelNumFmts("", addedDefXml);
624
+ }
625
+ const stripAbstractNumId = (defXml, id) => defXml.replace(`${NUMBERING_ID_ATTR.abstractNum}="${id}"`, "");
555
626
  function escapeRegExp(str) {
556
627
  return str.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
557
628
  }
558
629
  //#endregion
559
- export { buildParagraphOffsetIndex, buildPatchedDocumentXml, buildPatchedNoteXml, buildPatchedNumberingXml, collectChangedNumberingDefs, collectParaIds, countParagraphElements, extractParagraphXml, findParagraphOffsets, isXmlNameBoundary, validatePatchSafety };
630
+ export { appendNumberingDefs, buildParagraphOffsetIndex, buildPatchedDocumentXml, buildPatchedNoteXml, buildPatchedNumberingXml, collectAddedNumberingDefs, collectChangedNumberingDefs, collectParaIds, countParagraphElements, extractParagraphXml, findParagraphOffsets, isXmlNameBoundary, validatePatchSafety };
@@ -263,6 +263,7 @@ function serializeComplexField(field) {
263
263
  const rPrXml = structuralFormatting ? serializeTextFormatting(structuralFormatting) : "";
264
264
  const beginAttrs = ["w:fldCharType=\"begin\""];
265
265
  if (field.fldLock) beginAttrs.push("w:fldLock=\"true\"");
266
+ if (field.dirty) beginAttrs.push("w:dirty=\"true\"");
266
267
  parts.push(`<w:r>${rPrXml}<w:fldChar ${beginAttrs.join(" ")}/></w:r>`);
267
268
  if (field.fieldCode.length > 0) parts.push(...field.fieldCode.map((run) => serializeRun(run)));
268
269
  else if (field.instruction.length > 0) {
@@ -4,6 +4,7 @@ const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
4
4
  const serializeSettingsXml = (settings) => {
5
5
  const parts = [`<w:defaultTabStop w:val="${intAttr(settings.defaultTabStop)}"/>`];
6
6
  if (settings.evenAndOddHeaders) parts.push("<w:evenAndOddHeaders/>");
7
+ if (settings.updateFields) parts.push("<w:updateFields w:val=\"true\"/>");
7
8
  if (settings.themeFontLang) {
8
9
  const attrs = [];
9
10
  if (settings.themeFontLang.eastAsia) attrs.push(`w:eastAsia="${escapeXml(settings.themeFontLang.eastAsia)}"`);
@@ -1,5 +1,6 @@
1
1
  import { document_d_exports } from "../../types/document.js";
2
2
  //#region src/docx/serializer/stylesSerializer.d.ts
3
3
  declare const serializeStylesXml: (definitions: document_d_exports.StyleDefinitions) => string;
4
+ declare const serializeStyle: (style: document_d_exports.Style) => string;
4
5
  //#endregion
5
- export { serializeStylesXml };
6
+ export { serializeStyle, serializeStylesXml };
@@ -53,4 +53,4 @@ const pushBooleanAttr = (attrs, name, value) => {
53
53
  if (value !== void 0) attrs.push(`${name}="${value ? 1 : 0}"`);
54
54
  };
55
55
  //#endregion
56
- export { serializeStylesXml };
56
+ export { serializeStyle, serializeStylesXml };
@@ -0,0 +1,103 @@
1
+ import { document_d_exports } from "../../types/document.js";
2
+ import { ShadingProperties } from "../../types/colors.js";
3
+ import { Hyperlink, Paragraph, ParagraphContent, Run, Table } from "../../types/content.js";
4
+ import { ParagraphFormatting, TextFormatting } from "../../types/formatting.js";
5
+ //#region src/docx/server/build.d.ts
6
+ declare const HEADING_LEVELS: readonly [1, 2, 3, 4, 5, 6];
7
+ type HeadingLevel = (typeof HEADING_LEVELS)[number];
8
+ declare const InvalidFolioReportBuilderOptionsError_base: import("better-result").TaggedErrorClass<"InvalidFolioReportBuilderOptionsError", {
9
+ message: string;
10
+ path: string;
11
+ }>;
12
+ /**
13
+ * A builder received a value outside the model's domain (a zero `gridSpan`,
14
+ * a negative column width, a reversed TOC range). Thrown at the boundary so
15
+ * the invalid value never reaches the serializer.
16
+ */
17
+ declare class InvalidFolioReportBuilderOptionsError extends InvalidFolioReportBuilderOptionsError_base {}
18
+ declare const run: (text: string, formatting?: TextFormatting) => Run;
19
+ declare const paragraph: (content: string | ParagraphContent[], formatting?: ParagraphFormatting) => Paragraph;
20
+ type HeadingOptions = {
21
+ text: string;
22
+ level: HeadingLevel;
23
+ };
24
+ /** A paragraph in the `Heading<level>` style. */
25
+ declare const heading: ({ text, level }: HeadingOptions) => Paragraph;
26
+ /** An empty paragraph carrying a hard page break. */
27
+ declare const pageBreak: () => Paragraph;
28
+ type HyperlinkTarget = {
29
+ href: string;
30
+ anchor?: never;
31
+ } | {
32
+ anchor: string;
33
+ href?: never;
34
+ };
35
+ type HyperlinkOptions = HyperlinkTarget & {
36
+ text: string;
37
+ formatting?: TextFormatting;
38
+ tooltip?: string;
39
+ };
40
+ /**
41
+ * An external (`href`) or in-document (`anchor`, a bookmark name) link. The
42
+ * relationship for an external link is minted when the document is written.
43
+ */
44
+ declare const hyperlink: ({ text, formatting, tooltip, href, anchor }: HyperlinkOptions) => Hyperlink;
45
+ type BookmarkOptions = {
46
+ name: string;
47
+ content: ParagraphContent[];
48
+ /**
49
+ * Bookmark id, unique per document. Defaults to a process-wide counter,
50
+ * which is unique for documents built from scratch; pass an explicit id
51
+ * when adding bookmarks to a parsed document.
52
+ */
53
+ id?: number;
54
+ };
55
+ /** `content` wrapped in a named bookmark, the target of `hyperlink({ anchor })`. */
56
+ declare const bookmark: ({ name, content, id }: BookmarkOptions) => ParagraphContent[];
57
+ type TableCellSpec = string | {
58
+ content: Paragraph[];
59
+ shading?: ShadingProperties;
60
+ gridSpan?: number;
61
+ vMerge?: "restart" | "continue";
62
+ };
63
+ type TableOptions = {
64
+ /** Header row labels; rendered bold, optionally shaded and repeated per page. */
65
+ header?: string[];
66
+ rows: TableCellSpec[][];
67
+ /** Grid column widths in twips. Omitted: the consumer autofits. */
68
+ columnWidths?: number[];
69
+ headerShading?: ShadingProperties;
70
+ /** Repeat the header row at the top of every page (default true). */
71
+ repeatHeader?: boolean;
72
+ };
73
+ /**
74
+ * A full-width grid table in the `TableGrid` style. A string cell becomes one
75
+ * plain paragraph; an object cell supplies its own paragraphs plus optional
76
+ * shading and horizontal (`gridSpan`) or vertical (`vMerge`) merge.
77
+ */
78
+ declare const table: ({ header, rows, columnWidths, headerShading, repeatHeader }: TableOptions) => Table;
79
+ /**
80
+ * Register an endnote on `doc` and return the reference run to place in body
81
+ * text. Allocates the next free endnote id (Word reserves 0 and -1 for the
82
+ * separator notes) and pushes the note into `doc.package.endnotes`.
83
+ */
84
+ declare const endnote: (doc: document_d_exports.Document, content: string | Paragraph[]) => Run;
85
+ type TableOfContentsOptions = {
86
+ /** Heading levels to include, `1 <= from <= to <= 9` (default 1-3). */
87
+ levels?: {
88
+ from: number;
89
+ to: number;
90
+ };
91
+ /** Make entries hyperlinks (`\h`, default true). */
92
+ hyperlinks?: boolean;
93
+ /** Result text shown until the consumer recomputes the field. */
94
+ placeholderText?: string;
95
+ };
96
+ /**
97
+ * A paragraph holding a dirty `TOC` field, so the consumer recomputes the
98
+ * table on open. Set `package.settings.updateFields` as well to have Word
99
+ * recompute without prompting for each field.
100
+ */
101
+ declare const createTableOfContentsField: ({ levels, hyperlinks, placeholderText }?: TableOfContentsOptions) => Paragraph;
102
+ //#endregion
103
+ export { HEADING_LEVELS, HeadingLevel, InvalidFolioReportBuilderOptionsError, TableCellSpec, bookmark, createTableOfContentsField, endnote, heading, hyperlink, pageBreak, paragraph, run, table };