@stll/folio-core 0.27.0 → 0.27.1

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.
@@ -14,6 +14,7 @@ const mergeTableRectangle = ({ tr, tablePosition, table, rectangle }) => {
14
14
  const tableStart = tablePosition + 1;
15
15
  const seen = /* @__PURE__ */ new Set();
16
16
  const cells = [];
17
+ const topRowCells = [];
17
18
  let appendedContent = Fragment.empty;
18
19
  for (let row = rectangle.top; row < rectangle.bottom; row++) for (let column = rectangle.left; column < rectangle.right; column++) {
19
20
  const cellPosition = map.map[row * map.width + column];
@@ -25,6 +26,7 @@ const mergeTableRectangle = ({ tr, tablePosition, table, rectangle }) => {
25
26
  position: cellPosition,
26
27
  cell
27
28
  });
29
+ if (row === rectangle.top) topRowCells.push(cell);
28
30
  if (cells.length > 1 && !isEmptyTableCell(cell)) appendedContent = appendedContent.append(cell.content);
29
31
  }
30
32
  const merged = cells.at(0);
@@ -34,8 +36,8 @@ const mergeTableRectangle = ({ tr, tablePosition, table, rectangle }) => {
34
36
  const colwidth = merged.cell.attrs["colwidth"];
35
37
  if (typeof colspan !== "number" || !Number.isInteger(colspan) || colspan < 1 || typeof rowspan !== "number" || !Number.isInteger(rowspan) || rowspan < 1 || colwidth !== null && colwidth !== void 0 && (!Array.isArray(colwidth) || colwidth.length !== colspan || !colwidth.every((width) => typeof width === "number"))) return null;
36
38
  const mergedColspan = rectangle.right - rectangle.left;
37
- const nextColwidth = Array.isArray(colwidth) ? [...colwidth] : null;
38
- while (nextColwidth && nextColwidth.length < mergedColspan) nextColwidth.push(0);
39
+ const nextColwidth = mergeTopRowColwidths(topRowCells, mergedColspan);
40
+ const preferredWidth = mergeTopRowPreferredWidths(topRowCells);
39
41
  const mapFrom = tr.mapping.maps.length;
40
42
  for (const { position: cellPosition, cell } of cells.slice(1)) {
41
43
  const position = tr.mapping.slice(mapFrom).map(tableStart + cellPosition);
@@ -46,7 +48,9 @@ const mergeTableRectangle = ({ tr, tablePosition, table, rectangle }) => {
46
48
  ...merged.cell.attrs,
47
49
  colspan: mergedColspan,
48
50
  rowspan: rectangle.bottom - rectangle.top,
49
- colwidth: nextColwidth
51
+ colwidth: nextColwidth,
52
+ width: preferredWidth.type === "value" ? preferredWidth.width : null,
53
+ widthType: preferredWidth.type === "value" ? preferredWidth.widthType : null
50
54
  });
51
55
  if (appendedContent.size > 0) {
52
56
  const contentEnd = absoluteMergedPosition + 1 + merged.cell.content.size;
@@ -55,6 +59,37 @@ const mergeTableRectangle = ({ tr, tablePosition, table, rectangle }) => {
55
59
  }
56
60
  return tr;
57
61
  };
62
+ const mergeTopRowColwidths = (cells, mergedColspan) => {
63
+ const widths = [];
64
+ for (const cell of cells) {
65
+ const colspan = cell.attrs["colspan"];
66
+ const colwidth = cell.attrs["colwidth"];
67
+ if (typeof colspan !== "number" || !Number.isInteger(colspan) || colspan < 1 || !Array.isArray(colwidth) || colwidth.length !== colspan || !colwidth.every((width) => typeof width === "number" && width > 0)) return null;
68
+ widths.push(...colwidth);
69
+ }
70
+ return widths.length === mergedColspan ? widths : null;
71
+ };
72
+ /** A horizontal merge owns the whole top-row span. Sum compatible explicit
73
+ * preferred widths; retaining only the first cell's width makes a 50%+50%
74
+ * bilingual row serialize as a 50%-wide spanning cell. Mixed/implicit units
75
+ * cannot be composed safely, so clear the preference and let the table grid
76
+ * define the merged width. */
77
+ const mergeTopRowPreferredWidths = (cells) => {
78
+ let width = 0;
79
+ let widthType;
80
+ for (const cell of cells) {
81
+ const candidateWidth = cell.attrs["width"];
82
+ const candidateType = cell.attrs["widthType"];
83
+ if (typeof candidateWidth !== "number" || !Number.isFinite(candidateWidth) || candidateType !== "dxa" && candidateType !== "pct" || widthType !== void 0 && candidateType !== widthType) return { type: "absent" };
84
+ width += candidateWidth;
85
+ widthType = candidateType;
86
+ }
87
+ return widthType === void 0 ? { type: "absent" } : {
88
+ type: "value",
89
+ width,
90
+ widthType
91
+ };
92
+ };
58
93
  const mergeTrackedVerticalTableCells = ({ tr, tablePosition, table, rectangle, revisionId, author, date }) => {
59
94
  const map = TableMap.get(table);
60
95
  if (rectangle.right - rectangle.left !== 1 || rectangle.bottom - rectangle.top < 2 || rectangle.left < 0 || rectangle.top < 0 || rectangle.right > map.width || rectangle.bottom > map.height || tableRectangleCutsMergedCell(map, rectangle)) return null;
@@ -1,3 +1,4 @@
1
+ import { isValidHexColor } from "../utils/colorResolver.js";
1
2
  import { isValidHexId } from "../utils/hexId.js";
2
3
  import { parseBookmarkEnd as parseBookmarkEnd$1, parseBookmarkStart as parseBookmarkStart$1 } from "./bookmarkParser.js";
3
4
  import { parseFieldType } from "./fieldParser.js";
@@ -48,9 +49,9 @@ function parseShadingProperties(shd) {
48
49
  if (!shd) return;
49
50
  const props = {};
50
51
  const color = getAttribute(shd, "w", "color");
51
- if (color && color !== "auto") props.color = { rgb: color };
52
+ if (color && color !== "auto" && isValidHexColor(color)) props.color = { rgb: color };
52
53
  const fill = getAttribute(shd, "w", "fill");
53
- if (fill && fill !== "auto") props.fill = { rgb: fill };
54
+ if (fill && fill !== "auto" && isValidHexColor(fill)) props.fill = { rgb: fill };
54
55
  const validatedThemeFill = narrowEnum(getAttribute(shd, "w", "themeFill"), ThemeColorSlotSchema);
55
56
  if (validatedThemeFill) {
56
57
  props.fill = props.fill || {};
@@ -1,6 +1,6 @@
1
1
  import { toArrayBuffer } from "../utils/docxInput.js";
2
2
  import { loadFontsWithMapping } from "../utils/fontLoader.js";
3
- import { convertTiffToPngDataUrl, isTiffMimeType } from "../utils/tiffConverter.js";
3
+ import { MAX_PACKAGE_TIFF_PIXELS, convertTiffToPngDataUrl, isTiffMimeType } from "../utils/tiffConverter.js";
4
4
  import { parseComments } from "./commentParser.js";
5
5
  import { normalizeCommentReferences } from "./commentReferenceNormalization.js";
6
6
  import { detectDocxConformanceClass } from "./conformance.js";
@@ -237,14 +237,37 @@ function copyBytesToArrayBuffer(bytes) {
237
237
  new Uint8Array(buffer).set(bytes);
238
238
  return buffer;
239
239
  }
240
- async function buildMediaMap(raw, _rels) {
240
+ /**
241
+ * Media paths reachable from the package relationship graph: the document's own
242
+ * relationships plus every `.rels` part the unzip kept. Entries outside it are
243
+ * still stored so a round-trip keeps their bytes, but nothing decodes them.
244
+ */
245
+ function collectReferencedMediaPaths(raw, rels) {
246
+ const referenced = /* @__PURE__ */ new Set();
247
+ const addTargets = (map, relsPath) => {
248
+ for (const relationship of map.values()) {
249
+ if (!relationship.target || relationship.targetMode === "External") continue;
250
+ const partPath = resolveRelativePath(relsPath, relationship.target);
251
+ referenced.add(partPath.toLowerCase());
252
+ referenced.add(partPath.replace(/^word\//u, "").toLowerCase());
253
+ }
254
+ };
255
+ addTargets(rels, DOCUMENT_RELATIONSHIPS_PATH);
256
+ for (const [path, xml] of raw.allXml.entries()) if (path.toLowerCase().endsWith(".rels")) addTargets(parseRelationships(xml), path);
257
+ return referenced;
258
+ }
259
+ async function buildMediaMap(raw, rels) {
241
260
  const media = /* @__PURE__ */ new Map();
261
+ const referenced = collectReferencedMediaPaths(raw, rels);
262
+ let remainingTiffPixels = MAX_PACKAGE_TIFF_PIXELS;
242
263
  for (const [path, data] of raw.media.entries()) {
243
264
  const filename = path.split("/").pop() || path;
244
265
  const mimeType = getMediaMimeType(path);
245
- if (isTiffMimeType(mimeType)) {
246
- const converted = await convertTiffToPngDataUrl(data);
266
+ const isReferenced = referenced.has(path.toLowerCase());
267
+ if (isReferenced && isTiffMimeType(mimeType) && remainingTiffPixels > 0) {
268
+ const converted = await convertTiffToPngDataUrl(data, remainingTiffPixels);
247
269
  if (converted) {
270
+ remainingTiffPixels -= converted.pixels;
248
271
  const mediaFile = {
249
272
  path,
250
273
  filename: filename.replace(/\.tiff?$/iu, ".png"),
@@ -258,7 +281,7 @@ async function buildMediaMap(raw, _rels) {
258
281
  continue;
259
282
  }
260
283
  }
261
- const raster = isMetafileMimeType(mimeType) ? extractMetafileRaster(data) : null;
284
+ const raster = isReferenced && isMetafileMimeType(mimeType) ? extractMetafileRaster(data) : null;
262
285
  if (raster) {
263
286
  const mediaFile = {
264
287
  path,
@@ -54,6 +54,26 @@ declare function addCommentsExtendedOverride(contentTypesXml: string): string;
54
54
  declare function removeCommentsExtendedOverride(contentTypesXml: string): string;
55
55
  declare function addCommentsExtendedRelationship(relsXml: string): string;
56
56
  declare function removeCommentsExtendedRelationship(relsXml: string): string;
57
+ /** Result of filtering the settings part; `undefined` means "leave as it is". */
58
+ type SettingsWithoutAttachedTemplate = {
59
+ settingsXml: string | undefined;
60
+ relsXml: string | undefined;
61
+ };
62
+ /**
63
+ * Drop `w:attachedTemplate` from a `word/settings.xml` payload together with the
64
+ * relationships it resolves through.
65
+ *
66
+ * The elements are located in the parsed tree by namespace URI plus local name,
67
+ * so both the Transitional and the Strict WordprocessingML namespace are
68
+ * covered and a same-named element from a foreign namespace is left alone. Only
69
+ * the relationship ids those elements reference are removed from the `.rels`
70
+ * part: the settings part may also carry mail-merge and transform
71
+ * relationships, and their `r:id` values must keep resolving.
72
+ *
73
+ * The removal itself is a byte splice, so everything else in both parts
74
+ * round-trips exactly as authored.
75
+ */
76
+ declare function withoutAttachedTemplate(settingsXml: string, relsXml: string | undefined): SettingsWithoutAttachedTemplate;
57
77
  /**
58
78
  * Update only document.xml in a DOCX buffer (minimal changes)
59
79
  *
@@ -173,4 +193,4 @@ declare function createEmptyDocx(): Promise<ArrayBuffer>;
173
193
  */
174
194
  declare function createDocx(doc: document_d_exports.Document): Promise<ArrayBuffer>;
175
195
  //#endregion
176
- 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 };
196
+ 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, withoutAttachedTemplate };
@@ -18,7 +18,7 @@ import { serializeStyle, serializeStylesXml } from "./serializer/stylesSerialize
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 { WORDPROCESSINGML_NAMESPACE_URIS, findChild, getChildElements, getLocalName, getNamespaceUri, matchesName, parseXml } from "./xmlParser.js";
21
+ import { WORDPROCESSINGML_NAMESPACE_URIS, findChild, getAttribute, getChildElements, getLocalName, getNamespaceUri, matchesName, parseXml, parseXmlDocument } from "./xmlParser.js";
22
22
  import { assertXmlResourceLimits } from "./xmlResourceLimits.js";
23
23
  import { panic } from "better-result";
24
24
  import { validateDocxPackage } from "@stll/docx-core";
@@ -459,6 +459,7 @@ const finishRepack = async ({ document, originalZip, outputZip, originalDocument
459
459
  await serializeNumberingIntoZip(document, originalZip, outputZip, compressionLevel);
460
460
  await serializeAddedStylesIntoZip(document, originalZip, outputZip, compressionLevel);
461
461
  await serializeCommentsToZip(document, outputZip, compressionLevel);
462
+ await dropAttachedTemplateReference(outputZip, compressionLevel);
462
463
  if (updateModifiedDate && originalCorePropertiesXml) {
463
464
  const updatedCoreProperties = updateCoreProperties(originalCorePropertiesXml, {
464
465
  updateModifiedDate,
@@ -547,6 +548,7 @@ async function repackDocxFromRaw(doc, rawContent, options = {}) {
547
548
  await serializeNumberingIntoZip(exportDocument, rawContent.originalZip, newZip, compressionLevel);
548
549
  await serializeAddedStylesIntoZip(exportDocument, rawContent.originalZip, newZip, compressionLevel);
549
550
  await serializeCommentsToZip(exportDocument, newZip, compressionLevel);
551
+ await dropAttachedTemplateReference(newZip, compressionLevel);
550
552
  if (updateModifiedDate && rawContent.corePropsXml) {
551
553
  const updatedCoreProps = updateCoreProperties(rawContent.corePropsXml, {
552
554
  updateModifiedDate,
@@ -582,6 +584,82 @@ function addCommentsExtendedRelationship(relsXml) {
582
584
  function removeCommentsExtendedRelationship(relsXml) {
583
585
  return relsXml.replace(/<Relationship\b[^>]*commentsExtended\.xml[^>]*\/>/giu, "");
584
586
  }
587
+ const SETTINGS_PART = "word/settings.xml";
588
+ const SETTINGS_RELS_PART = "word/_rels/settings.xml.rels";
589
+ const ATTACHED_TEMPLATE_LOCAL_NAME = "attachedTemplate";
590
+ const ATTACHED_TEMPLATE_ELEMENT = /<(?<prefix>[\w.-]+:)?attachedTemplate\b[^>]*?(?:\/>|>\s*<\/(?:[\w.-]+:)?attachedTemplate>)/giu;
591
+ const RELATIONSHIP_ELEMENT = /<Relationship\b[^>]*?(?:\/>|>\s*<\/Relationship>)/giu;
592
+ const RELATIONSHIP_ID_ATTRIBUTE = /\bId\s*=\s*(?<quote>["'])(?<value>[^"']*)\k<quote>/u;
593
+ /**
594
+ * Drop `w:attachedTemplate` from a `word/settings.xml` payload together with the
595
+ * relationships it resolves through.
596
+ *
597
+ * The elements are located in the parsed tree by namespace URI plus local name,
598
+ * so both the Transitional and the Strict WordprocessingML namespace are
599
+ * covered and a same-named element from a foreign namespace is left alone. Only
600
+ * the relationship ids those elements reference are removed from the `.rels`
601
+ * part: the settings part may also carry mail-merge and transform
602
+ * relationships, and their `r:id` values must keep resolving.
603
+ *
604
+ * The removal itself is a byte splice, so everything else in both parts
605
+ * round-trips exactly as authored.
606
+ */
607
+ function withoutAttachedTemplate(settingsXml, relsXml) {
608
+ const root = parseXmlDocument(settingsXml);
609
+ if (!root) return {
610
+ settingsXml: void 0,
611
+ relsXml: void 0
612
+ };
613
+ const prefixes = /* @__PURE__ */ new Set();
614
+ const referencedRIds = /* @__PURE__ */ new Set();
615
+ for (const element of getChildElements(root)) {
616
+ if (getLocalName(element.name) !== ATTACHED_TEMPLATE_LOCAL_NAME || !WORDPROCESSINGML_NAMESPACE_URIS.has(getNamespaceUri(element) ?? "")) continue;
617
+ const name = element.name ?? "";
618
+ const separatorIndex = name.indexOf(":");
619
+ prefixes.add(separatorIndex === -1 ? "" : name.slice(0, separatorIndex));
620
+ const rId = getAttribute(element, "r", "id");
621
+ if (rId) referencedRIds.add(rId);
622
+ }
623
+ if (prefixes.size === 0) return {
624
+ settingsXml: void 0,
625
+ relsXml: void 0
626
+ };
627
+ const filteredSettings = settingsXml.replace(ATTACHED_TEMPLATE_ELEMENT, (match, prefix) => prefixes.has(prefix?.slice(0, -1) ?? "") ? "" : match);
628
+ return {
629
+ settingsXml: filteredSettings === settingsXml ? void 0 : filteredSettings,
630
+ relsXml: relsXml === void 0 ? void 0 : withoutRelationships(relsXml, referencedRIds)
631
+ };
632
+ }
633
+ /** Drop the named relationships from a `.rels` payload; `undefined` when unchanged. */
634
+ function withoutRelationships(relsXml, ids) {
635
+ if (ids.size === 0) return;
636
+ const filtered = relsXml.replace(RELATIONSHIP_ELEMENT, (relationship) => {
637
+ const id = RELATIONSHIP_ID_ATTRIBUTE.exec(relationship)?.groups?.["value"];
638
+ return id !== void 0 && ids.has(id) ? "" : relationship;
639
+ });
640
+ return filtered === relsXml ? void 0 : filtered;
641
+ }
642
+ /**
643
+ * The settings part and its relationships are otherwise copied from the source
644
+ * package byte for byte. `w:attachedTemplate` resolves through a relationship
645
+ * whose target sits outside the package (`TargetMode="External"`); the document
646
+ * model has no field for it, so a preserved copy would carry a reference folio
647
+ * can neither read nor rewrite. Both sides are filtered on save.
648
+ */
649
+ async function dropAttachedTemplateReference(zip, compressionLevel) {
650
+ const settingsFile = zip.file(SETTINGS_PART);
651
+ if (!settingsFile) return;
652
+ const relsFile = zip.file(SETTINGS_RELS_PART);
653
+ const filtered = withoutAttachedTemplate(await settingsFile.async("text"), await relsFile?.async("text"));
654
+ if (filtered.settingsXml !== void 0) zip.file(SETTINGS_PART, filtered.settingsXml, {
655
+ compression: "DEFLATE",
656
+ compressionOptions: { level: compressionLevel }
657
+ });
658
+ if (filtered.relsXml !== void 0) zip.file(SETTINGS_RELS_PART, filtered.relsXml, {
659
+ compression: "DEFLATE",
660
+ compressionOptions: { level: compressionLevel }
661
+ });
662
+ }
585
663
  /**
586
664
  * Ensure [Content_Types].xml contains an Override for word/comments.xml.
587
665
  * If the document already had comments, this is a no-op.
@@ -1413,4 +1491,4 @@ const assertStyleNumberingReferences = (doc) => {
1413
1491
  for (const numbering of doc.package.numbering?.nums ?? []) if (!availableAbstract.has(numbering.abstractNumId)) panic(`Numbering definition ${numbering.numId} references missing abstract numbering`);
1414
1492
  };
1415
1493
  //#endregion
1416
- 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 };
1494
+ 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, withoutAttachedTemplate };
@@ -1,3 +1,4 @@
1
+ import { isValidHexColor } from "../utils/colorResolver.js";
1
2
  import { parseGroupDrawing } from "./groupDrawingParser.js";
2
3
  import { parseImage } from "./imageParser.js";
3
4
  import { EmphasisMarkSchema, FontHintSchema, FontThemeSchema, HighlightColorSchema, PositionalTabAlignmentSchema, PositionalTabLeaderSchema, PositionalTabRelativeToSchema, ShadingPatternSchema, TextEffectSchema, ThemeColorSlotSchema, UnderlineStyleSchema, narrowEnum } from "./parserEnums.js";
@@ -37,9 +38,9 @@ function parseShadingProperties(shd) {
37
38
  if (!shd) return;
38
39
  const props = {};
39
40
  const color = getAttribute(shd, "w", "color");
40
- if (color && color !== "auto") props.color = { rgb: color };
41
+ if (color && color !== "auto" && isValidHexColor(color)) props.color = { rgb: color };
41
42
  const fill = getAttribute(shd, "w", "fill");
42
- if (fill && fill !== "auto") props.fill = { rgb: fill };
43
+ if (fill && fill !== "auto" && isValidHexColor(fill)) props.fill = { rgb: fill };
43
44
  const validatedThemeFill = narrowEnum(getAttribute(shd, "w", "themeFill"), ThemeColorSlotSchema);
44
45
  if (validatedThemeFill) {
45
46
  if (!props.fill) props.fill = {};
@@ -252,7 +253,7 @@ function parseRunProperties(rPr, theme, _styles) {
252
253
  if (resolved) fontFamily.ascii = resolved;
253
254
  }
254
255
  }
255
- const hAnsiTheme = getAttribute(rFonts, "w", "hAnsiTheme");
256
+ const hAnsiTheme = narrowEnum(getAttribute(rFonts, "w", "hAnsiTheme"), FontThemeSchema);
256
257
  if (hAnsiTheme) {
257
258
  fontFamily.hAnsiTheme = hAnsiTheme;
258
259
  if (theme && !fontFamily.hAnsi) {
@@ -260,7 +261,7 @@ function parseRunProperties(rPr, theme, _styles) {
260
261
  if (resolved) fontFamily.hAnsi = resolved;
261
262
  }
262
263
  }
263
- const eastAsiaTheme = getAttribute(rFonts, "w", "eastAsiaTheme");
264
+ const eastAsiaTheme = narrowEnum(getAttribute(rFonts, "w", "eastAsiaTheme"), FontThemeSchema);
264
265
  if (eastAsiaTheme) {
265
266
  fontFamily.eastAsiaTheme = eastAsiaTheme;
266
267
  if (theme && !fontFamily.eastAsia) {
@@ -268,7 +269,7 @@ function parseRunProperties(rPr, theme, _styles) {
268
269
  if (resolved) fontFamily.eastAsia = resolved;
269
270
  }
270
271
  }
271
- const csTheme = getAttribute(rFonts, "w", "cstheme");
272
+ const csTheme = narrowEnum(getAttribute(rFonts, "w", "cstheme"), FontThemeSchema);
272
273
  if (csTheme) {
273
274
  fontFamily.csTheme = csTheme;
274
275
  if (theme && !fontFamily.cs) {
@@ -14,14 +14,6 @@ type SelectiveSaveOptions = {
14
14
  */
15
15
  maxBytes?: number;
16
16
  };
17
- /**
18
- * Attempt a selective save — patch only changed paragraphs in document.xml.
19
- * Also updates comments, headers/footers, and core properties so that
20
- * all document parts stay in sync even when only paragraphs are patched.
21
- *
22
- * Returns the saved ArrayBuffer, or null if selective save is not possible
23
- * (caller should fall back to full repack).
24
- */
25
17
  declare function attemptSelectiveSave(doc: document_d_exports.Document, originalBuffer: ArrayBuffer, options: SelectiveSaveOptions): Promise<ArrayBuffer | null>;
26
18
  //#endregion
27
19
  export { SelectiveSaveOptions, attemptSelectiveSave };
@@ -5,7 +5,7 @@ import { validateFolioDocumentModel } from "./modelValidation.js";
5
5
  import { isNewDataUrlDrawing } from "./newImage.js";
6
6
  import { parseNumbering } from "./numberingParser.js";
7
7
  import { RELATIONSHIP_TYPES } from "./relsParser.js";
8
- import { COMMENTS_CONTENT_TYPE, COMMENTS_EXTENDED_PART_LOWER, addCommentsExtendedOverride, addCommentsExtendedRelationship, applyUpdatesToZip, collectHeaderFooterUpdates, findMaxRId, hasModelDrivenPictureWatermark, hasUnmaterializedHeaderFooter, updateCoreProperties } from "./rezip.js";
8
+ import { COMMENTS_CONTENT_TYPE, COMMENTS_EXTENDED_PART_LOWER, addCommentsExtendedOverride, addCommentsExtendedRelationship, applyUpdatesToZip, collectHeaderFooterUpdates, findMaxRId, hasModelDrivenPictureWatermark, hasUnmaterializedHeaderFooter, updateCoreProperties, withoutAttachedTemplate } from "./rezip.js";
9
9
  import "./selectiveSaveFlags.js";
10
10
  import { buildPatchedDocumentXml, buildPatchedNoteXml, buildPatchedNumberingXml, collectChangedNumberingDefs, collectParaIds } from "./selectiveXmlPatch.js";
11
11
  import { ensureThreadedCommentParaIds, serializeComments, serializeCommentsExtended } from "./serializer/commentSerializer.js";
@@ -174,6 +174,20 @@ async function patchNumberingPart(zip, doc, updates) {
174
174
  * Returns the saved ArrayBuffer, or null if selective save is not possible
175
175
  * (caller should fall back to full repack).
176
176
  */
177
+ /**
178
+ * Queue the filtered `word/settings.xml` and `word/_rels/settings.xml.rels`
179
+ * when the source package carries an attached-template reference. Shares
180
+ * `withoutAttachedTemplate` with the full-repack path so both saves emit the
181
+ * same package for the same source.
182
+ */
183
+ const queueSettingsUpdates = async (zip, updates) => {
184
+ const settingsFile = zip.file("word/settings.xml");
185
+ if (!settingsFile) return;
186
+ const relsFile = zip.file("word/_rels/settings.xml.rels");
187
+ const filtered = withoutAttachedTemplate(await settingsFile.async("text"), await relsFile?.async("text"));
188
+ if (filtered.settingsXml !== void 0) updates.set("word/settings.xml", filtered.settingsXml);
189
+ if (filtered.relsXml !== void 0) updates.set("word/_rels/settings.xml.rels", filtered.relsXml);
190
+ };
177
191
  async function attemptSelectiveSave(doc, originalBuffer, options) {
178
192
  const { changedParaIds, structuralChange, hasUntrackedChanges } = options;
179
193
  const maxBytes = options.maxBytes ?? 104857600;
@@ -243,6 +257,7 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
243
257
  if (!await patchCommentsExtended(zip, comments, updates)) return null;
244
258
  await patchNumberingPart(zip, doc, updates);
245
259
  for (const [path, xml] of headerFooterUpdates) updates.set(path, xml);
260
+ await queueSettingsUpdates(zip, updates);
246
261
  const corePropsFile = zip.file("docProps/core.xml");
247
262
  if (corePropsFile) {
248
263
  const corePropsXml = await corePropsFile.async("text");
@@ -85,10 +85,10 @@ function serializeTextFormatting(formatting) {
85
85
  if (formatting.fontFamily.eastAsia) fontAttrs.push(`w:eastAsia="${escapeXml(formatting.fontFamily.eastAsia)}"`);
86
86
  if (formatting.fontFamily.cs) fontAttrs.push(`w:cs="${escapeXml(formatting.fontFamily.cs)}"`);
87
87
  if (formatting.fontFamily.hint) fontAttrs.push(`w:hint="${escapeXml(formatting.fontFamily.hint)}"`);
88
- if (formatting.fontFamily.asciiTheme) fontAttrs.push(`w:asciiTheme="${formatting.fontFamily.asciiTheme}"`);
89
- if (formatting.fontFamily.hAnsiTheme) fontAttrs.push(`w:hAnsiTheme="${formatting.fontFamily.hAnsiTheme}"`);
90
- if (formatting.fontFamily.eastAsiaTheme) fontAttrs.push(`w:eastAsiaTheme="${formatting.fontFamily.eastAsiaTheme}"`);
91
- if (formatting.fontFamily.csTheme) fontAttrs.push(`w:cstheme="${formatting.fontFamily.csTheme}"`);
88
+ if (formatting.fontFamily.asciiTheme) fontAttrs.push(`w:asciiTheme="${escapeXml(formatting.fontFamily.asciiTheme)}"`);
89
+ if (formatting.fontFamily.hAnsiTheme) fontAttrs.push(`w:hAnsiTheme="${escapeXml(formatting.fontFamily.hAnsiTheme)}"`);
90
+ if (formatting.fontFamily.eastAsiaTheme) fontAttrs.push(`w:eastAsiaTheme="${escapeXml(formatting.fontFamily.eastAsiaTheme)}"`);
91
+ if (formatting.fontFamily.csTheme) fontAttrs.push(`w:cstheme="${escapeXml(formatting.fontFamily.csTheme)}"`);
92
92
  if (fontAttrs.length > 0) parts.push(`<w:rFonts ${fontAttrs.join(" ")}/>`);
93
93
  }
94
94
  if (formatting.language) {
@@ -83,7 +83,7 @@ function serializePageNumbering(props) {
83
83
  if (pageNumbering.format) attrs.push(`w:fmt="${pageNumbering.format}"`);
84
84
  if (pageNumbering.start !== void 0) attrs.push(`w:start="${intAttr(pageNumbering.start)}"`);
85
85
  if (pageNumbering.chapterStyle !== void 0) attrs.push(`w:chapStyle="${intAttr(pageNumbering.chapterStyle)}"`);
86
- if (pageNumbering.chapterSeparator) attrs.push(`w:chapSep="${pageNumbering.chapterSeparator}"`);
86
+ if (pageNumbering.chapterSeparator) attrs.push(`w:chapSep="${escapeXml(pageNumbering.chapterSeparator)}"`);
87
87
  return attrs.length > 0 ? `<w:pgNumType ${attrs.join(" ")}/>` : "";
88
88
  }
89
89
  function serializePageBorders(props) {
@@ -113,10 +113,10 @@ function serializeBackground(props) {
113
113
  const attrs = [];
114
114
  const { background } = props;
115
115
  if (background.color?.auto) attrs.push("w:color=\"auto\"");
116
- else if (background.color?.rgb) attrs.push(`w:color="${background.color.rgb}"`);
117
- if (background.themeColor ?? background.color?.themeColor) attrs.push(`w:themeColor="${background.themeColor ?? background.color?.themeColor}"`);
118
- if (background.themeTint ?? background.color?.themeTint) attrs.push(`w:themeTint="${background.themeTint ?? background.color?.themeTint}"`);
119
- if (background.themeShade ?? background.color?.themeShade) attrs.push(`w:themeShade="${background.themeShade ?? background.color?.themeShade}"`);
116
+ else if (background.color?.rgb) attrs.push(`w:color="${escapeXml(background.color.rgb)}"`);
117
+ if (background.themeColor ?? background.color?.themeColor) attrs.push(`w:themeColor="${escapeXml(background.themeColor ?? background.color?.themeColor ?? "")}"`);
118
+ if (background.themeTint ?? background.color?.themeTint) attrs.push(`w:themeTint="${escapeXml(background.themeTint ?? background.color?.themeTint ?? "")}"`);
119
+ if (background.themeShade ?? background.color?.themeShade) attrs.push(`w:themeShade="${escapeXml(background.themeShade ?? background.color?.themeShade ?? "")}"`);
120
120
  return attrs.length > 0 ? `<w:background ${attrs.join(" ")}/>` : "";
121
121
  }
122
122
  function serializeDocGrid(props) {
@@ -10,7 +10,10 @@ import { TaggedError } from "better-result";
10
10
  * cloned per language, so both columns count independently (1. / 1. instead of
11
11
  * 1. / 2.) and stay live in Word. Right-column paragraphs receive fresh
12
12
  * `paraId`s so callers can address each row later (for example to replace the
13
- * placeholder copy with a translation by block id).
13
+ * placeholder copy with a translation by block id). Horizontal paragraph
14
+ * geometry is projected into the half-width cells: full-page indents and tab
15
+ * stops otherwise place signature fields outside their column and let prose
16
+ * overlap the translation.
14
17
  *
15
18
  * Section breaks cannot live inside a table cell, so the body is split at
16
19
  * paragraphs carrying `sectionProperties`: each section becomes its own table
@@ -28,6 +31,8 @@ const HALF_WIDTH_PCT = 2500;
28
31
  const A4_TEXT_WIDTH_TWIPS = 9072;
29
32
  const ROW_ID_NAMESPACE = "folio-bilingual";
30
33
  const BILINGUAL_TABLE_STYLE_ID = "FolioBilingualTranslation";
34
+ const MIN_COLUMN_TEXT_WIDTH_TWIPS = 720;
35
+ const MIN_TAB_TRAILING_WIDTH_TWIPS = 360;
31
36
  const GRID_BORDER = {
32
37
  style: "single",
33
38
  size: 4,
@@ -111,7 +116,7 @@ function createBilingualDocument(source, options) {
111
116
  rowId: ref.targetParaId,
112
117
  ...ref
113
118
  });
114
- sectionRows.push(buildRow(block, copy));
119
+ sectionRows.push(buildRow(block, copy, styleById, textWidth));
115
120
  continue;
116
121
  }
117
122
  const paragraphs = collectTableParagraphs(block).filter((paragraph) => paragraph.paraId !== void 0 && options.editableParagraphIds.has(paragraph.paraId)).map((paragraph) => ({
@@ -362,11 +367,72 @@ const collectTableParagraphs = (table) => {
362
367
  else out.push(...collectTableParagraphs(item));
363
368
  return out;
364
369
  };
365
- const buildRow = (left, right) => ({
366
- type: "tableRow",
367
- formatting: { cantSplit: true },
368
- cells: [buildCell(left), buildCell(right)]
369
- });
370
+ const buildRow = (left, right, styleById, textWidth) => {
371
+ const columnWidth = Math.floor(textWidth / 2);
372
+ const geometry = resolveHorizontalParagraphGeometry(left, styleById);
373
+ return {
374
+ type: "tableRow",
375
+ formatting: { cantSplit: true },
376
+ cells: [buildCell(projectParagraphIntoColumn(left, geometry, textWidth, columnWidth)), buildCell(projectParagraphIntoColumn(right, geometry, textWidth, columnWidth))]
377
+ };
378
+ };
379
+ const HORIZONTAL_PARAGRAPH_KEYS = [
380
+ "indentLeft",
381
+ "indentRight",
382
+ "indentFirstLine",
383
+ "hangingIndent",
384
+ "tabs"
385
+ ];
386
+ /** Resolve only the paragraph properties whose coordinates change when a
387
+ * full-width paragraph is placed in a half-width cell. Direct pPr wins over
388
+ * the basedOn style chain, matching Word's paragraph-style cascade. */
389
+ const resolveHorizontalParagraphGeometry = (paragraph, styleById) => {
390
+ const chain = [];
391
+ const seen = /* @__PURE__ */ new Set();
392
+ let style = paragraph.formatting?.styleId ? styleById.get(paragraph.formatting.styleId) : void 0;
393
+ while (style && !seen.has(style.styleId)) {
394
+ seen.add(style.styleId);
395
+ chain.push(style);
396
+ style = style.basedOn ? styleById.get(style.basedOn) : void 0;
397
+ }
398
+ const geometry = {};
399
+ for (const current of chain.toReversed()) assignHorizontalParagraphGeometry(geometry, current.pPr);
400
+ assignHorizontalParagraphGeometry(geometry, paragraph.formatting);
401
+ return geometry;
402
+ };
403
+ const assignHorizontalParagraphGeometry = (target, source) => {
404
+ for (const key of HORIZONTAL_PARAGRAPH_KEYS) {
405
+ const value = source?.[key];
406
+ if (value !== void 0) Object.assign(target, { [key]: value });
407
+ }
408
+ };
409
+ const projectParagraphIntoColumn = (paragraph, geometry, sourceWidth, columnWidth) => {
410
+ const scale = columnWidth / sourceWidth;
411
+ const maxSideIndent = Math.max(0, columnWidth - MIN_COLUMN_TEXT_WIDTH_TWIPS);
412
+ let indentLeft = projectSideIndent(geometry.indentLeft, scale, maxSideIndent);
413
+ let indentRight = projectSideIndent(geometry.indentRight, scale, maxSideIndent);
414
+ if (indentLeft + indentRight - maxSideIndent > 0) {
415
+ const total = indentLeft + indentRight;
416
+ indentLeft = Math.round(indentLeft / total * maxSideIndent);
417
+ indentRight = maxSideIndent - indentLeft;
418
+ }
419
+ const formatting = {
420
+ ...paragraph.formatting,
421
+ ...geometry.indentLeft !== void 0 && { indentLeft },
422
+ ...geometry.indentRight !== void 0 && { indentRight },
423
+ ...geometry.indentFirstLine !== void 0 && { indentFirstLine: Math.round(geometry.indentFirstLine * scale) },
424
+ ...geometry.hangingIndent !== void 0 && { hangingIndent: geometry.hangingIndent },
425
+ ...geometry.tabs !== void 0 && { tabs: geometry.tabs.map((tab) => ({
426
+ ...tab,
427
+ position: Math.min(Math.max(0, Math.round(tab.position * scale)), Math.max(0, columnWidth - MIN_TAB_TRAILING_WIDTH_TWIPS))
428
+ })) }
429
+ };
430
+ return {
431
+ ...paragraph,
432
+ formatting
433
+ };
434
+ };
435
+ const projectSideIndent = (value, scale, maximum) => Math.min(Math.max(0, Math.round((value ?? 0) * scale)), maximum);
370
436
  const buildCell = (paragraph) => ({
371
437
  type: "tableCell",
372
438
  formatting: {
@@ -103,16 +103,26 @@ const NESTED_TABLE_CELL_SEPARATOR = " / ";
103
103
  const MAX_TABLE_COLUMNS = 256;
104
104
  /** Bound the mutual recursion between a cell and the tables nested inside it. */
105
105
  const MAX_NESTED_TABLE_DEPTH = 8;
106
+ /** Rows collected from one `w:tbl`. The column cap alone leaves row count unbounded. */
107
+ const MAX_TABLE_ROWS = 8192;
108
+ /**
109
+ * Characters one extraction emits, shared by the body and every header/footer
110
+ * part. Element count is bounded at unzip, but a bounded element count still
111
+ * renders an unbounded number of table rows once `w:gridSpan` padding and the
112
+ * GFM scaffolding are counted, so the emitted side carries its own ceiling.
113
+ */
114
+ const MAX_EXTRACTED_CHARS = 8e6;
106
115
  /**
107
116
  * Collect a table's `w:tr`, or a row's `w:tc`, seeing through the wrappers Word
108
117
  * puts around them (`w:sdt` / `w:sdtContent` content controls, `w:customXml`).
109
118
  * The walk stops at `w:tbl` and `w:p` so a nested table's rows and cells never
110
119
  * leak into the grid of the table that contains them.
111
120
  */
112
- const collectTableParts = (parent, localName) => {
121
+ const collectTableParts = (parent, localName, limit) => {
113
122
  const parts = [];
114
123
  const walk = (node) => {
115
124
  for (const child of childElements(node)) {
125
+ if (parts.length >= limit) return;
116
126
  const childName = wordElementName(child);
117
127
  if (childName === localName) {
118
128
  parts.push(child);
@@ -142,7 +152,7 @@ const readCellSourceParagraphs = (cell, depth) => {
142
152
  }
143
153
  if (childName === "tbl") {
144
154
  if (depth >= MAX_NESTED_TABLE_DEPTH) continue;
145
- for (const row of collectTableParts(child, "tr")) for (const nestedCell of collectTableParts(row, "tc")) for (const paragraph of readCellSourceParagraphs(nestedCell, depth + 1)) paragraphs.push(paragraph);
155
+ for (const row of collectTableParts(child, "tr", MAX_TABLE_ROWS)) for (const nestedCell of collectTableParts(row, "tc", MAX_TABLE_COLUMNS)) for (const paragraph of readCellSourceParagraphs(nestedCell, depth + 1)) paragraphs.push(paragraph);
146
156
  continue;
147
157
  }
148
158
  walk(child);
@@ -173,8 +183,8 @@ const readCellRenderedLines = (cell, depth) => {
173
183
  };
174
184
  const flattenNestedTable = (table, depth) => {
175
185
  const lines = [];
176
- for (const row of collectTableParts(table, "tr")) {
177
- const cells = collectTableParts(row, "tc").map((cell) => readCellRenderedLines(cell, depth).join("\n"));
186
+ for (const row of collectTableParts(table, "tr", MAX_TABLE_ROWS)) {
187
+ const cells = collectTableParts(row, "tc", MAX_TABLE_COLUMNS).map((cell) => readCellRenderedLines(cell, depth).join("\n"));
178
188
  if (cells.some((text) => text.length > 0)) lines.push(cells.join(NESTED_TABLE_CELL_SEPARATOR));
179
189
  }
180
190
  return lines;
@@ -229,12 +239,12 @@ const readTableGrid = (table) => {
229
239
  const rows = [];
230
240
  let columnCount = 0;
231
241
  let firstRowIsHeader = false;
232
- for (const [rowIndex, row] of collectTableParts(table, "tr").entries()) {
242
+ for (const [rowIndex, row] of collectTableParts(table, "tr", MAX_TABLE_ROWS).entries()) {
233
243
  if (rowIndex === 0) firstRowIsHeader = declaresHeaderRow(row);
234
244
  const columns = [];
235
245
  const gridBefore = readRowGridOffset(row, "gridBefore");
236
246
  for (let index = 0; index < gridBefore; index += 1) columns.push(emptyTableCell());
237
- for (const cell of collectTableParts(row, "tc")) {
247
+ for (const cell of collectTableParts(row, "tc", MAX_TABLE_COLUMNS)) {
238
248
  if (columns.length >= MAX_TABLE_COLUMNS) break;
239
249
  const extractedCell = readTableCell(cell, 0);
240
250
  columns.push(extractedCell);
@@ -293,7 +303,8 @@ const renderTableRows = (table, tableIndex) => {
293
303
  for (const row of firstRowIsHeader ? remainingRows : rows) pushCells(row);
294
304
  return rendered;
295
305
  };
296
- const extractContainer = ({ container, source, startIndex, startTableIndex }) => {
306
+ const createCharBudget = () => ({ remaining: MAX_EXTRACTED_CHARS });
307
+ const extractContainer = ({ container, source, startIndex, startTableIndex, budget }) => {
297
308
  const paragraphs = [];
298
309
  let charCount = 0;
299
310
  let tableCount = 0;
@@ -307,6 +318,7 @@ const extractContainer = ({ container, source, startIndex, startTableIndex }) =>
307
318
  };
308
319
  paragraphs.push(entry);
309
320
  charCount += text.length;
321
+ budget.remaining -= text.length;
310
322
  };
311
323
  const pushTableRow = ({ text, position }) => {
312
324
  paragraphs.push({
@@ -316,6 +328,7 @@ const extractContainer = ({ container, source, startIndex, startTableIndex }) =>
316
328
  tableRow: position
317
329
  });
318
330
  charCount += text.length;
331
+ budget.remaining -= text.length;
319
332
  };
320
333
  /**
321
334
  * Walk block content in document order. Descent mirrors the previous
@@ -325,6 +338,7 @@ const extractContainer = ({ container, source, startIndex, startTableIndex }) =>
325
338
  */
326
339
  const walkBlocks = (node) => {
327
340
  for (const child of childElements(node)) {
341
+ if (budget.remaining <= 0) return;
328
342
  const childName = wordElementName(child);
329
343
  if (childName === "tbl") {
330
344
  for (const row of renderTableRows(child, startTableIndex + tableCount)) pushTableRow(row);
@@ -342,7 +356,7 @@ const extractContainer = ({ container, source, startIndex, startTableIndex }) =>
342
356
  tableCount
343
357
  };
344
358
  };
345
- const extractParts = async ({ archive, source, rootName, startIndex, startTableIndex, paths }) => {
359
+ const extractParts = async ({ archive, source, rootName, startIndex, startTableIndex, paths, budget }) => {
346
360
  const paragraphs = [];
347
361
  let charCount = 0;
348
362
  let tableCount = 0;
@@ -356,7 +370,8 @@ const extractParts = async ({ archive, source, rootName, startIndex, startTableI
356
370
  container,
357
371
  source,
358
372
  startIndex: nextIndex,
359
- startTableIndex: startTableIndex + tableCount
373
+ startTableIndex: startTableIndex + tableCount,
374
+ budget
360
375
  });
361
376
  for (const paragraph of result.paragraphs) paragraphs.push(paragraph);
362
377
  charCount += result.charCount;
@@ -427,19 +442,22 @@ const extractDocxText = async (bytes) => {
427
442
  const body = findDeep(root, "w", "body");
428
443
  if (!body) return createEmptyResult();
429
444
  const referencedParts = await resolveReferencedHeaderFooterParts(archive, root);
445
+ const budget = createCharBudget();
430
446
  const headers = await extractParts({
431
447
  archive,
432
448
  source: "header",
433
449
  rootName: "hdr",
434
450
  startIndex: 0,
435
451
  startTableIndex: 0,
436
- paths: referencedParts.headers
452
+ paths: referencedParts.headers,
453
+ budget
437
454
  });
438
455
  const bodyResult = extractContainer({
439
456
  container: body,
440
457
  source: "body",
441
458
  startIndex: headers.paragraphs.length,
442
- startTableIndex: headers.tableCount
459
+ startTableIndex: headers.tableCount,
460
+ budget
443
461
  });
444
462
  const footers = await extractParts({
445
463
  archive,
@@ -447,7 +465,8 @@ const extractDocxText = async (bytes) => {
447
465
  rootName: "ftr",
448
466
  startIndex: headers.paragraphs.length + bodyResult.paragraphs.length,
449
467
  startTableIndex: headers.tableCount + bodyResult.tableCount,
450
- paths: referencedParts.footers
468
+ paths: referencedParts.footers,
469
+ budget
451
470
  });
452
471
  return {
453
472
  paragraphs: [
@@ -1,3 +1,4 @@
1
+ import { isValidHexColor } from "../utils/colorResolver.js";
1
2
  import { mergeParagraphFormatting } from "../utils/paragraphFormattingMerge.js";
2
3
  import { mergeTextFormatting } from "../utils/textFormattingMerge.js";
3
4
  import { BorderStyleSchema, ConditionalStyleTypeSchema, EmphasisMarkSchema, FontHintSchema, FontThemeSchema, HighlightColorSchema, LineSpacingRuleSchema, ParagraphAlignmentSchema, ShadingPatternSchema, StyleTypeSchema, TabLeaderSchema, TabStopAlignmentSchema, TableCellTextDirectionSchema, TableRowHeightRuleSchema, TableWidthTypeSchema, TextEffectSchema, ThemeColorSlotSchema, UnderlineStyleSchema, narrowEnum } from "./parserEnums.js";
@@ -86,7 +87,7 @@ function parseRunProperties(rPr, theme) {
86
87
  if (resolved) fontFamily.ascii = resolved;
87
88
  }
88
89
  }
89
- const hAnsiTheme = getAttribute(rFonts, "w", "hAnsiTheme");
90
+ const hAnsiTheme = narrowEnum(getAttribute(rFonts, "w", "hAnsiTheme"), FontThemeSchema);
90
91
  if (hAnsiTheme) {
91
92
  fontFamily.hAnsiTheme = hAnsiTheme;
92
93
  if (theme && !fontFamily.hAnsi) {
@@ -94,7 +95,7 @@ function parseRunProperties(rPr, theme) {
94
95
  if (resolved) fontFamily.hAnsi = resolved;
95
96
  }
96
97
  }
97
- const eastAsiaTheme = getAttribute(rFonts, "w", "eastAsiaTheme");
98
+ const eastAsiaTheme = narrowEnum(getAttribute(rFonts, "w", "eastAsiaTheme"), FontThemeSchema);
98
99
  if (eastAsiaTheme) {
99
100
  fontFamily.eastAsiaTheme = eastAsiaTheme;
100
101
  if (theme && !fontFamily.eastAsia) {
@@ -102,7 +103,7 @@ function parseRunProperties(rPr, theme) {
102
103
  if (resolved) fontFamily.eastAsia = resolved;
103
104
  }
104
105
  }
105
- const csTheme = getAttribute(rFonts, "w", "cstheme");
106
+ const csTheme = narrowEnum(getAttribute(rFonts, "w", "cstheme"), FontThemeSchema);
106
107
  if (csTheme) {
107
108
  fontFamily.csTheme = csTheme;
108
109
  if (theme && !fontFamily.cs) {
@@ -192,9 +193,9 @@ function parseShadingProperties(shd) {
192
193
  if (!shd) return;
193
194
  const props = {};
194
195
  const color = getAttribute(shd, "w", "color");
195
- if (color && color !== "auto") props.color = { rgb: color };
196
+ if (color && color !== "auto" && isValidHexColor(color)) props.color = { rgb: color };
196
197
  const fill = getAttribute(shd, "w", "fill");
197
- if (fill && fill !== "auto") props.fill = { rgb: fill };
198
+ if (fill && fill !== "auto" && isValidHexColor(fill)) props.fill = { rgb: fill };
198
199
  const validatedThemeFill = narrowEnum(getAttribute(shd, "w", "themeFill"), ThemeColorSlotSchema);
199
200
  if (validatedThemeFill) {
200
201
  if (!props.fill) props.fill = {};
@@ -1,6 +1,6 @@
1
1
  import { DOCX_CONTAINER_TYPES, detectDocxContainerType } from "./encryption/containerFormat.js";
2
2
  import { openDocxBuffer } from "./encryption/openEncryptedDocx.js";
3
- import { FOLIO_XML_RESOURCE_LIMITS } from "./xmlResourceLimits.js";
3
+ import { FOLIO_XML_RESOURCE_LIMITS, assertXmlResourceLimits } from "./xmlResourceLimits.js";
4
4
  import JSZip from "jszip";
5
5
  //#region src/docx/unzip.ts
6
6
  /**
@@ -191,7 +191,7 @@ async function unzipDocx(buffer, options = {}) {
191
191
  for (const extracted of await Promise.all(extractionTasks.map((extract) => extract()))) {
192
192
  if (!extracted) continue;
193
193
  if (extracted.type === "xml") {
194
- assignXmlContent(content, extracted);
194
+ assignXmlContent(content, extracted, limits);
195
195
  continue;
196
196
  }
197
197
  if (extracted.type === "media") {
@@ -202,7 +202,21 @@ async function unzipDocx(buffer, options = {}) {
202
202
  }
203
203
  return content;
204
204
  }
205
- function assignXmlContent(content, { path, lowerPath, content: xmlContent }) {
205
+ /**
206
+ * Parts that every consumer expands into an object tree. Preflighting them once
207
+ * here puts the bound on the unzip, so `parseDocx`, the selective save and the
208
+ * repack path all share it instead of each entry point carrying its own.
209
+ */
210
+ const PREFLIGHT_XML_PARTS = /* @__PURE__ */ new Set([
211
+ "word/document.xml",
212
+ "word/styles.xml",
213
+ "word/numbering.xml"
214
+ ]);
215
+ function assignXmlContent(content, { path, lowerPath, content: xmlContent }, limits) {
216
+ if (PREFLIGHT_XML_PARTS.has(lowerPath)) assertXmlResourceLimits(xmlContent, {
217
+ ...FOLIO_XML_RESOURCE_LIMITS,
218
+ maxBytes: limits.maxXmlBytes
219
+ });
206
220
  content.allXml.set(path, xmlContent);
207
221
  if (lowerPath === "word/document.xml") content.documentXml = xmlContent;
208
222
  else if (lowerPath === "word/styles.xml") content.stylesXml = xmlContent;
@@ -610,6 +610,24 @@ function findAllDeep(root, namespace, localName) {
610
610
  */
611
611
  const MAX_XMLNS_DECLARATIONS_PER_ELEMENT = 64;
612
612
  /**
613
+ * Sanity cap on one declaration's value. Namespace URIs are short; a longer
614
+ * binding is dropped rather than replayed onto every captured subtree that
615
+ * inherits from the declaring element.
616
+ */
617
+ const MAX_XMLNS_VALUE_LENGTH = 512;
618
+ /**
619
+ * Sanity cap on an accumulated declaration set. Applied both when collecting one
620
+ * element's declarations and when merging down the ancestor chain, so every set
621
+ * this module produces or returns is bounded and a captured `w:pict` subtree
622
+ * replays at most this much regardless of how the chain was built.
623
+ */
624
+ const MAX_XMLNS_DECLARATION_CHARS = 8192;
625
+ const xmlnsDeclarationChars = (declarations) => {
626
+ let chars = 0;
627
+ for (const [name, value] of Object.entries(declarations)) chars += name.length + value.length;
628
+ return chars;
629
+ };
630
+ /**
613
631
  * Collect every `xmlns` / `xmlns:*` declaration from an element's attributes.
614
632
  *
615
633
  * The serializer's hard-coded root namespaces only cover canonical prefixes
@@ -623,13 +641,18 @@ function collectXmlnsDeclarations(element) {
623
641
  const attrs = element.attributes;
624
642
  if (!attrs) return out;
625
643
  let declarationCount = 0;
644
+ let declarationChars = 0;
626
645
  for (const key in attrs) {
627
646
  if (declarationCount >= MAX_XMLNS_DECLARATIONS_PER_ELEMENT) break;
628
647
  const value = attrs[key];
629
- if ((key === "xmlns" || key.startsWith("xmlns:")) && value !== void 0) {
630
- out[key] = String(value);
631
- declarationCount += 1;
632
- }
648
+ if (key !== "xmlns" && !key.startsWith("xmlns:")) continue;
649
+ if (value === void 0) continue;
650
+ const declaration = String(value);
651
+ if (declaration.length > MAX_XMLNS_VALUE_LENGTH) continue;
652
+ if (declarationChars + key.length + declaration.length > MAX_XMLNS_DECLARATION_CHARS) break;
653
+ out[key] = declaration;
654
+ declarationCount += 1;
655
+ declarationChars += key.length + declaration.length;
633
656
  }
634
657
  return out;
635
658
  }
@@ -643,10 +666,13 @@ function collectXmlnsDeclarations(element) {
643
666
  */
644
667
  function mergeXmlnsDeclarations(inherited, element) {
645
668
  const own = collectXmlnsDeclarations(element);
646
- for (const _key in own) return {
647
- ...inherited,
648
- ...own
649
- };
669
+ for (const _key in own) {
670
+ const merged = {
671
+ ...inherited,
672
+ ...own
673
+ };
674
+ return xmlnsDeclarationChars(merged) > MAX_XMLNS_DECLARATION_CHARS ? inherited : merged;
675
+ }
650
676
  return inherited;
651
677
  }
652
678
  const QNAME_VALUE_ATTRIBUTES = /* @__PURE__ */ new Set([
@@ -15,6 +15,7 @@ import { resolvePhysicalParagraphInlineLayout } from "../utils/paragraphInlineLa
15
15
  import { inlineImageBoundingBox, parseRotationDegrees, rotatedBoundingBox } from "../utils/rotationBoundingBox.js";
16
16
  import { applySanitizedImageSrc } from "../utils/sanitizeImageSrc.js";
17
17
  import { SCRIPT_CLASS, hasCjk, hasComplexScript, segmentByScript } from "../utils/scriptSegments.js";
18
+ import { sanitizeExternalUrl } from "../utils/urlSecurity.js";
18
19
  import { borderStrokeToCss, resolveParagraphBorderHorizontalOutsets } from "./borderStroke.js";
19
20
  import { planCursiveJoiners, withCursiveJoiners } from "./cursiveJoiners.js";
20
21
  import { getAutomaticTextColorForBackground } from "./documentColors.js";
@@ -114,6 +115,12 @@ const DEFAULT_BLACK_TEXT_COLOR_VALUES = /* @__PURE__ */ new Set(["000000", "000"
114
115
  const SUGGESTION_COLOR_CSS = "var(--suggestion-color, #6d3bd6)";
115
116
  const SUGGESTION_TINT_CSS = "var(--suggestion-bg, color-mix(in oklch, #6d3bd6 12%, transparent))";
116
117
  const SUGGESTION_TINT_LAYER_CSS = `linear-gradient(${SUGGESTION_TINT_CSS}, ${SUGGESTION_TINT_CSS})`;
118
+ const RUN_BACKGROUND_TEXT_COLOR_VAR = "--doc-run-background-text-color";
119
+ const setRunBackgroundTextColor = (element, color) => {
120
+ element.classList.add("docx-run-background-text");
121
+ element.style.setProperty(RUN_BACKGROUND_TEXT_COLOR_VAR, color);
122
+ };
123
+ const hasRunBackgroundTextSurface = (run) => Boolean(run.highlight ?? run.shading) && !run.isInsertion && !run.isDeletion && !(run.commentIds !== void 0 && run.commentIds.length > 0);
117
124
  function normalizeTextColorValue(color) {
118
125
  return color.trim().toLowerCase().replace(/^#/u, "");
119
126
  }
@@ -205,6 +212,8 @@ function applyRunStyles(element, run) {
205
212
  const hasCommentHighlight = run.commentIds !== void 0 && run.commentIds.length > 0;
206
213
  const automaticTextColor = hasExplicitTextColor || hasTrackedChangeColor || hasCommentHighlight ? void 0 : getAutomaticTextColorForBackground(runBackground);
207
214
  if (automaticTextColor) element.style.color = automaticTextColor;
215
+ const backgroundTextColor = hasExplicitTextColor ? textColor : automaticTextColor;
216
+ if (backgroundTextColor && hasRunBackgroundTextSurface(run)) setRunBackgroundTextColor(element, backgroundTextColor);
208
217
  }
209
218
  const decorations = [];
210
219
  let explicitDecorationStyle = false;
@@ -315,11 +324,13 @@ function renderTextRun(run, doc, hyperlinkDirection) {
315
324
  applyRunStyles(span, run);
316
325
  applyPmPositions(span, run.pmStart, run.pmEnd);
317
326
  const paintedText = toPaintedText(run.text);
318
- if (run.hyperlink) {
327
+ const isBookmarkTarget = run.hyperlink?.href.startsWith("#") === true;
328
+ const hyperlinkHref = resolveHyperlinkHref(run.hyperlink?.href, isBookmarkTarget);
329
+ if (run.hyperlink && hyperlinkHref !== void 0) {
319
330
  const anchor = doc.createElement("a");
320
- anchor.href = run.hyperlink.href;
331
+ anchor.href = hyperlinkHref;
321
332
  if (hyperlinkDirection || DISPLAYED_URL_PATTERN.test(paintedText.trim())) anchor.dir = LEFT_TO_RIGHT_DIRECTION;
322
- if (!run.hyperlink.href.startsWith("#")) {
333
+ if (!isBookmarkTarget) {
323
334
  anchor.target = "_blank";
324
335
  anchor.rel = "noopener noreferrer";
325
336
  }
@@ -332,12 +343,27 @@ function renderTextRun(run, doc, hyperlinkDirection) {
332
343
  span.style.color = hyperlinkColor;
333
344
  anchor.style.setProperty("--doc-run-color", hyperlinkColor);
334
345
  span.style.setProperty("--doc-run-color", hyperlinkColor);
346
+ if (hasRunBackgroundTextSurface(run)) {
347
+ setRunBackgroundTextColor(span, hyperlinkColor);
348
+ setRunBackgroundTextColor(anchor, hyperlinkColor);
349
+ }
335
350
  }
336
351
  span.append(anchor);
337
352
  } else span.textContent = paintedText;
338
353
  applyWhitespaceUnderline(span, run);
339
354
  return span;
340
355
  }
356
+ /**
357
+ * Bookmark targets (`#name`) scroll within the document and stay verbatim;
358
+ * every other target is narrowed to the protocols the painter navigates to,
359
+ * matching the image hyperlink path. `undefined` means the run gets no anchor:
360
+ * an empty `href` resolves to the current document, so a rejected target would
361
+ * otherwise stay navigable.
362
+ */
363
+ function resolveHyperlinkHref(href, isBookmarkTarget) {
364
+ if (href === void 0) return;
365
+ return isBookmarkTarget ? href : sanitizeExternalUrl(href);
366
+ }
341
367
  function isNoteReferenceRun(run) {
342
368
  return run.footnoteRefId !== void 0 || run.endnoteRefId !== void 0;
343
369
  }
@@ -345,11 +371,14 @@ function removeUnderlineTextDecoration(element) {
345
371
  const textDecorationLines = (element.style.textDecorationLine || "").split(/\s+/u).filter((line) => line && line !== "underline");
346
372
  element.style.textDecorationLine = textDecorationLines.join(" ");
347
373
  }
374
+ function applyContinuousUnderline(element, underline) {
375
+ removeUnderlineTextDecoration(element);
376
+ const color = typeof underline === "object" && underline.color ? underline.color : "currentColor";
377
+ element.style.boxShadow = `inset 0 -1px 0 ${color}`;
378
+ }
348
379
  function applyWhitespaceUnderline(element, run) {
349
380
  if (!run.underline || run.text.trim().length > 0) return;
350
- removeUnderlineTextDecoration(element);
351
- element.style.borderBottom = "1px solid currentColor";
352
- if (typeof run.underline === "object" && run.underline.color) element.style.borderBottomColor = run.underline.color;
381
+ applyContinuousUnderline(element, run.underline);
353
382
  }
354
383
  /**
355
384
  * Number of leader characters to fill the tab's inner span. The inner span
@@ -401,9 +430,7 @@ function canClampTabToRightEdge(alignment, hasPriorRenderedContent, hasPriorTab,
401
430
  }
402
431
  function applyTabUnderline(element, run) {
403
432
  if (!run.underline) return;
404
- removeUnderlineTextDecoration(element);
405
- element.style.borderBottom = "1px solid currentColor";
406
- if (typeof run.underline === "object" && run.underline.color) element.style.borderBottomColor = run.underline.color;
433
+ applyContinuousUnderline(element, run.underline);
407
434
  }
408
435
  /**
409
436
  * Get leader character for tab
@@ -1845,10 +1845,11 @@ function tableCellAttrsToFormatting(attrs) {
1845
1845
  if (attrs.rowspan > 1) result.vMerge = "restart";
1846
1846
  else if (result.vMerge === "restart" && !attrs._preserveVMergeRestart) delete result.vMerge;
1847
1847
  const cellWidth = attrs.width;
1848
- if (cellWidth !== void 0) result.width = {
1848
+ if (typeof cellWidth === "number") result.width = {
1849
1849
  value: cellWidth,
1850
1850
  type: attrs.widthType ?? "dxa"
1851
1851
  };
1852
+ else delete result.width;
1852
1853
  if (attrs.verticalAlign !== (orig.verticalAlign ?? void 0)) if (attrs.verticalAlign) result.verticalAlign = attrs.verticalAlign;
1853
1854
  else delete result.verticalAlign;
1854
1855
  if (backgroundChanged) result.shading = cellShadingFromAttrs(attrs);
@@ -1859,11 +1860,11 @@ function tableCellAttrsToFormatting(attrs) {
1859
1860
  return result;
1860
1861
  }
1861
1862
  const cellWidth = attrs.width;
1862
- if (!(attrs.colspan > 1 || attrs.rowspan > 1 || cellWidth !== void 0 || attrs.verticalAlign || backgroundChanged || attrs.borders || attrs.margins || attrs.textDirection)) return;
1863
+ if (!(attrs.colspan > 1 || attrs.rowspan > 1 || typeof cellWidth === "number" || attrs.verticalAlign || backgroundChanged || attrs.borders || attrs.margins || attrs.textDirection)) return;
1863
1864
  const f = {};
1864
1865
  if (attrs.colspan > 1) f.gridSpan = attrs.colspan;
1865
1866
  if (attrs.rowspan > 1) f.vMerge = "restart";
1866
- if (cellWidth !== void 0) f.width = {
1867
+ if (typeof cellWidth === "number") f.width = {
1867
1868
  value: cellWidth,
1868
1869
  type: attrs.widthType ?? "dxa"
1869
1870
  };
@@ -185,23 +185,11 @@ const createEmptyHeaderFooter = (document, position, isFirstPage) => {
185
185
  type: hdrFtrType,
186
186
  rId
187
187
  };
188
- const usedTargets = /* @__PURE__ */ new Set();
189
- for (const relationship of pkg.relationships?.values() ?? []) if (relationship.target) usedTargets.add(relationship.target);
190
- let targetNumber = 1;
191
- while (usedTargets.has(`${position}${targetNumber}.xml`)) targetNumber++;
192
- const relationshipType = position === "header" ? "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" : "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
193
- const relationships = new Map(pkg.relationships);
194
- relationships.set(rId, {
195
- id: rId,
196
- type: relationshipType,
197
- target: `${position}${targetNumber}.xml`
198
- });
199
188
  return {
200
189
  ...document,
201
190
  package: {
202
191
  ...pkg,
203
192
  [mapKey]: newMap,
204
- relationships,
205
193
  document: {
206
194
  ...pkg.document,
207
195
  finalSectionProperties: {
@@ -8,11 +8,20 @@
8
8
  * back to the raw TIFF data URL, which won't render in browsers but is
9
9
  * fine for headless round-trips.
10
10
  */
11
+ /**
12
+ * Budget shared by every TIFF in one package. The per-image cap bounds a single
13
+ * decode; without a running total a package of many large TIFFs still adds up.
14
+ * Callers pass what is left of the budget and subtract the pixels each
15
+ * conversion reports.
16
+ */
17
+ declare const MAX_PACKAGE_TIFF_PIXELS = 256000000;
11
18
  declare function isTiffMimeType(mimeType: string): boolean;
12
19
  type ConvertedTiff = {
13
20
  dataUrl: string;
14
21
  data: ArrayBuffer;
22
+ /** Pixels decoded, so callers can draw down a package-wide budget. */
23
+ pixels: number;
15
24
  };
16
- declare function convertTiffToPngDataUrl(tiffData: ArrayBuffer): Promise<ConvertedTiff | null>;
25
+ declare function convertTiffToPngDataUrl(tiffData: ArrayBuffer, maxPixels?: number): Promise<ConvertedTiff | null>;
17
26
  //#endregion
18
- export { ConvertedTiff, convertTiffToPngDataUrl, isTiffMimeType };
27
+ export { ConvertedTiff, MAX_PACKAGE_TIFF_PIXELS, convertTiffToPngDataUrl, isTiffMimeType };
@@ -16,11 +16,18 @@
16
16
  * RGBA buffer, which would otherwise hang or OOM the tab.
17
17
  */
18
18
  const MAX_TIFF_PIXELS = 64e6;
19
+ /**
20
+ * Budget shared by every TIFF in one package. The per-image cap bounds a single
21
+ * decode; without a running total a package of many large TIFFs still adds up.
22
+ * Callers pass what is left of the budget and subtract the pixels each
23
+ * conversion reports.
24
+ */
25
+ const MAX_PACKAGE_TIFF_PIXELS = 256e6;
19
26
  function isTiffMimeType(mimeType) {
20
27
  const lower = mimeType.toLowerCase();
21
28
  return lower === "image/tiff" || lower === "image/tif";
22
29
  }
23
- async function convertTiffToPngDataUrl(tiffData) {
30
+ async function convertTiffToPngDataUrl(tiffData, maxPixels = MAX_TIFF_PIXELS) {
24
31
  if (typeof document === "undefined" || typeof document.createElement !== "function") return null;
25
32
  try {
26
33
  const UTIF = await import("utif2");
@@ -29,7 +36,7 @@ async function convertTiffToPngDataUrl(tiffData) {
29
36
  const declaredWidth = readTiffTagNumber(firstImage["t256"]);
30
37
  const declaredHeight = readTiffTagNumber(firstImage["t257"]);
31
38
  if (!declaredWidth || !declaredHeight) return null;
32
- if (declaredWidth * declaredHeight > MAX_TIFF_PIXELS) return null;
39
+ if (declaredWidth * declaredHeight > Math.min(maxPixels, MAX_TIFF_PIXELS)) return null;
33
40
  UTIF.decodeImage(tiffData, firstImage);
34
41
  const rgba = UTIF.toRGBA8(firstImage);
35
42
  if (rgba.length === 0) return null;
@@ -50,7 +57,8 @@ async function convertTiffToPngDataUrl(tiffData) {
50
57
  if (!data) return null;
51
58
  return {
52
59
  dataUrl,
53
- data
60
+ data,
61
+ pixels: width * height
54
62
  };
55
63
  } catch {
56
64
  return null;
@@ -80,4 +88,4 @@ function dataUrlToArrayBuffer(dataUrl) {
80
88
  }
81
89
  }
82
90
  //#endregion
83
- export { convertTiffToPngDataUrl, isTiffMimeType };
91
+ export { MAX_PACKAGE_TIFF_PIXELS, convertTiffToPngDataUrl, isTiffMimeType };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
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",