@stll/folio-core 0.27.0 → 0.28.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.
@@ -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: {