@rowsncolumns/toolkit 24.4.1 → 24.4.3

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.
Files changed (131) hide show
  1. package/dist/cjs/excel-parser/cfb-decryptor.d.ts +6 -0
  2. package/dist/cjs/excel-parser/cfb-decryptor.d.ts.map +1 -1
  3. package/dist/cjs/excel-parser/cfb-decryptor.js +6 -0
  4. package/dist/cjs/excel-parser/helpers.d.ts +9 -1
  5. package/dist/cjs/excel-parser/helpers.d.ts.map +1 -1
  6. package/dist/cjs/excel-parser/helpers.js +37 -0
  7. package/dist/cjs/excel-parser/interface.d.ts +10 -1
  8. package/dist/cjs/excel-parser/interface.d.ts.map +1 -1
  9. package/dist/cjs/excel-parser/plugins/ods/index.d.ts +19 -0
  10. package/dist/cjs/excel-parser/plugins/ods/index.d.ts.map +1 -1
  11. package/dist/cjs/excel-parser/plugins/ods/index.js +676 -304
  12. package/dist/cjs/excel-parser/plugins/ods/styles.d.ts +43 -1
  13. package/dist/cjs/excel-parser/plugins/ods/styles.d.ts.map +1 -1
  14. package/dist/cjs/excel-parser/plugins/ods/styles.js +343 -45
  15. package/dist/cjs/excel-parser/plugins/xls/index.d.ts +11 -3
  16. package/dist/cjs/excel-parser/plugins/xls/index.d.ts.map +1 -1
  17. package/dist/cjs/excel-parser/plugins/xls/index.js +55 -6
  18. package/dist/cjs/excel-parser/plugins/xlsb/index.d.ts +4 -0
  19. package/dist/cjs/excel-parser/plugins/xlsb/index.d.ts.map +1 -1
  20. package/dist/cjs/excel-parser/plugins/xlsb/index.js +21 -9
  21. package/dist/cjs/excel-parser/plugins/xlsx/charts.d.ts +10 -0
  22. package/dist/cjs/excel-parser/plugins/xlsx/charts.d.ts.map +1 -1
  23. package/dist/cjs/excel-parser/plugins/xlsx/charts.js +68 -9
  24. package/dist/cjs/excel-parser/plugins/xlsx/drawing.d.ts +28 -1
  25. package/dist/cjs/excel-parser/plugins/xlsx/drawing.d.ts.map +1 -1
  26. package/dist/cjs/excel-parser/plugins/xlsx/drawing.js +155 -20
  27. package/dist/cjs/excel-parser/plugins/xlsx/index.d.ts +26 -1
  28. package/dist/cjs/excel-parser/plugins/xlsx/index.d.ts.map +1 -1
  29. package/dist/cjs/excel-parser/plugins/xlsx/index.js +567 -234
  30. package/dist/cjs/excel-parser/plugins/xlsx/pivots.d.ts.map +1 -1
  31. package/dist/cjs/excel-parser/plugins/xlsx/pivots.js +125 -37
  32. package/dist/cjs/excel-parser/plugins/xlsx/sheet-views.d.ts +3 -0
  33. package/dist/cjs/excel-parser/plugins/xlsx/sheet-views.d.ts.map +1 -1
  34. package/dist/cjs/excel-parser/plugins/xlsx/sheet-views.js +4 -1
  35. package/dist/cjs/excel-parser/plugins/xlsx/styles.d.ts.map +1 -1
  36. package/dist/cjs/excel-parser/plugins/xlsx/styles.js +12 -2
  37. package/dist/cjs/excel-parser/registry.d.ts.map +1 -1
  38. package/dist/cjs/excel-parser/registry.js +2 -0
  39. package/dist/cjs/excel-parser/types.d.ts +7 -1
  40. package/dist/cjs/excel-parser/types.d.ts.map +1 -1
  41. package/dist/cjs/excel-parser/workbook.d.ts +1 -0
  42. package/dist/cjs/excel-parser/workbook.d.ts.map +1 -1
  43. package/dist/cjs/excel-parser/workbook.js +4 -0
  44. package/dist/cjs/excel-parser/worker.js +3 -1
  45. package/dist/cjs/exporter/ods/ods-export.d.ts.map +1 -1
  46. package/dist/cjs/exporter/ods/ods-export.js +432 -175
  47. package/dist/cjs/exporter/xlsx/chart-builder.d.ts.map +1 -1
  48. package/dist/cjs/exporter/xlsx/chart-builder.js +210 -6
  49. package/dist/cjs/exporter/xlsx/pivot-builder.d.ts +8 -0
  50. package/dist/cjs/exporter/xlsx/pivot-builder.d.ts.map +1 -1
  51. package/dist/cjs/exporter/xlsx/pivot-builder.js +141 -42
  52. package/dist/cjs/exporter/xlsx/style-builder.js +9 -3
  53. package/dist/cjs/exporter/xlsx/threaded-comments-builder.d.ts +7 -1
  54. package/dist/cjs/exporter/xlsx/threaded-comments-builder.d.ts.map +1 -1
  55. package/dist/cjs/exporter/xlsx/threaded-comments-builder.js +15 -3
  56. package/dist/cjs/exporter/xlsx/xlsx-export.d.ts +11 -2
  57. package/dist/cjs/exporter/xlsx/xlsx-export.d.ts.map +1 -1
  58. package/dist/cjs/exporter/xlsx/xlsx-export.js +678 -222
  59. package/dist/cjs/helpers/implicit-intersection.d.ts +16 -0
  60. package/dist/cjs/helpers/implicit-intersection.d.ts.map +1 -0
  61. package/dist/cjs/helpers/implicit-intersection.js +197 -0
  62. package/dist/cjs/print-settings.d.ts +105 -0
  63. package/dist/cjs/print-settings.d.ts.map +1 -0
  64. package/dist/cjs/print-settings.js +494 -0
  65. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  66. package/dist/esm/excel-parser/cfb-decryptor.d.ts +6 -0
  67. package/dist/esm/excel-parser/cfb-decryptor.d.ts.map +1 -1
  68. package/dist/esm/excel-parser/cfb-decryptor.js +6 -0
  69. package/dist/esm/excel-parser/helpers.d.ts +9 -1
  70. package/dist/esm/excel-parser/helpers.d.ts.map +1 -1
  71. package/dist/esm/excel-parser/helpers.js +34 -0
  72. package/dist/esm/excel-parser/interface.d.ts +10 -1
  73. package/dist/esm/excel-parser/interface.d.ts.map +1 -1
  74. package/dist/esm/excel-parser/plugins/ods/index.d.ts +19 -0
  75. package/dist/esm/excel-parser/plugins/ods/index.d.ts.map +1 -1
  76. package/dist/esm/excel-parser/plugins/ods/index.js +347 -33
  77. package/dist/esm/excel-parser/plugins/ods/styles.d.ts +43 -1
  78. package/dist/esm/excel-parser/plugins/ods/styles.d.ts.map +1 -1
  79. package/dist/esm/excel-parser/plugins/ods/styles.js +276 -2
  80. package/dist/esm/excel-parser/plugins/xls/index.d.ts +11 -3
  81. package/dist/esm/excel-parser/plugins/xls/index.d.ts.map +1 -1
  82. package/dist/esm/excel-parser/plugins/xls/index.js +37 -5
  83. package/dist/esm/excel-parser/plugins/xlsb/index.d.ts +4 -0
  84. package/dist/esm/excel-parser/plugins/xlsb/index.d.ts.map +1 -1
  85. package/dist/esm/excel-parser/plugins/xlsb/index.js +20 -6
  86. package/dist/esm/excel-parser/plugins/xlsx/charts.d.ts +10 -0
  87. package/dist/esm/excel-parser/plugins/xlsx/charts.d.ts.map +1 -1
  88. package/dist/esm/excel-parser/plugins/xlsx/charts.js +69 -6
  89. package/dist/esm/excel-parser/plugins/xlsx/drawing.d.ts +28 -1
  90. package/dist/esm/excel-parser/plugins/xlsx/drawing.d.ts.map +1 -1
  91. package/dist/esm/excel-parser/plugins/xlsx/drawing.js +99 -0
  92. package/dist/esm/excel-parser/plugins/xlsx/index.d.ts +26 -1
  93. package/dist/esm/excel-parser/plugins/xlsx/index.d.ts.map +1 -1
  94. package/dist/esm/excel-parser/plugins/xlsx/index.js +362 -26
  95. package/dist/esm/excel-parser/plugins/xlsx/pivots.d.ts.map +1 -1
  96. package/dist/esm/excel-parser/plugins/xlsx/pivots.js +86 -6
  97. package/dist/esm/excel-parser/plugins/xlsx/sheet-views.d.ts +3 -0
  98. package/dist/esm/excel-parser/plugins/xlsx/sheet-views.d.ts.map +1 -1
  99. package/dist/esm/excel-parser/plugins/xlsx/sheet-views.js +2 -0
  100. package/dist/esm/excel-parser/plugins/xlsx/styles.d.ts.map +1 -1
  101. package/dist/esm/excel-parser/plugins/xlsx/styles.js +12 -2
  102. package/dist/esm/excel-parser/registry.d.ts.map +1 -1
  103. package/dist/esm/excel-parser/registry.js +2 -0
  104. package/dist/esm/excel-parser/types.d.ts +7 -1
  105. package/dist/esm/excel-parser/types.d.ts.map +1 -1
  106. package/dist/esm/excel-parser/workbook.d.ts +1 -0
  107. package/dist/esm/excel-parser/workbook.d.ts.map +1 -1
  108. package/dist/esm/excel-parser/workbook.js +3 -0
  109. package/dist/esm/excel-parser/worker.js +2 -0
  110. package/dist/esm/exporter/ods/ods-export.d.ts.map +1 -1
  111. package/dist/esm/exporter/ods/ods-export.js +306 -35
  112. package/dist/esm/exporter/xlsx/chart-builder.d.ts.map +1 -1
  113. package/dist/esm/exporter/xlsx/chart-builder.js +161 -0
  114. package/dist/esm/exporter/xlsx/pivot-builder.d.ts +8 -0
  115. package/dist/esm/exporter/xlsx/pivot-builder.d.ts.map +1 -1
  116. package/dist/esm/exporter/xlsx/pivot-builder.js +104 -19
  117. package/dist/esm/exporter/xlsx/style-builder.js +9 -3
  118. package/dist/esm/exporter/xlsx/threaded-comments-builder.d.ts +7 -1
  119. package/dist/esm/exporter/xlsx/threaded-comments-builder.d.ts.map +1 -1
  120. package/dist/esm/exporter/xlsx/threaded-comments-builder.js +15 -3
  121. package/dist/esm/exporter/xlsx/xlsx-export.d.ts +11 -2
  122. package/dist/esm/exporter/xlsx/xlsx-export.d.ts.map +1 -1
  123. package/dist/esm/exporter/xlsx/xlsx-export.js +449 -47
  124. package/dist/esm/helpers/implicit-intersection.d.ts +16 -0
  125. package/dist/esm/helpers/implicit-intersection.d.ts.map +1 -0
  126. package/dist/esm/helpers/implicit-intersection.js +184 -0
  127. package/dist/esm/print-settings.d.ts +105 -0
  128. package/dist/esm/print-settings.d.ts.map +1 -0
  129. package/dist/esm/print-settings.js +422 -0
  130. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  131. package/package.json +3 -3
@@ -3,16 +3,18 @@ import { zipSync } from "fflate";
3
3
  import { omitInvalidDataValidationError } from "../invalid-data-validation";
4
4
  import { buildAutoFilterConditionXml } from "../../autofilter-condition";
5
5
  import { applyPowerQueryParts } from "./power-query-builder";
6
+ import { encodeImplicitIntersection } from "../../helpers/implicit-intersection";
6
7
  import { qualifyFutureFunctions } from "./future-functions";
7
8
  import { PERSON_CONTENT_TYPE, PERSON_REL_TYPE, PersonRegistry, THREADED_COMMENT_REL_TYPE, THREADED_COMMENTS_CONTENT_TYPE, ThreadedCommentsCollector, } from "./threaded-comments-builder";
8
9
  import { CFBEncryptor } from "../../excel-parser/cfb-encryptor";
9
10
  import { columnPixelToChars, imageUrlToBase64 } from "../../helpers";
11
+ import { buildPrintAreaFormula, buildPrintTitlesFormula, isBuiltinPrintName, isHeaderFooterEmpty, PRINT_AREA_NAME, PRINT_TITLES_NAME, } from "../../print-settings";
10
12
  import { generateThemeXml } from "../../helpers/theme-xml-generator";
11
13
  import theme1Xml from "../../theme1";
12
14
  import { tokenize as defaultTokenizer } from "@rowsncolumns/calculator";
13
15
  import { CellXfsRegistry, deriveEffectiveNumberFormatFromFormula, } from "@rowsncolumns/spreadsheet-state";
14
16
  import { buildChartXml } from "./chart-builder";
15
- import { buildPivotCacheDefinitionXml, buildPivotTableRelsXml, buildPivotTableXml, } from "./pivot-builder";
17
+ import { buildPivotCacheDefinitionXml, buildPivotTableRelsXml, buildPivotTableXml, calculatedFieldReferences, } from "./pivot-builder";
16
18
  import { buildSlicerCacheXml, buildSlicerXml } from "./slicer-builder";
17
19
  import { buildSparklineGroupsXml } from "./sparkline-builder";
18
20
  import { StyleBuilder } from "./style-builder";
@@ -21,7 +23,7 @@ const isStyleReference = (value) => !!value && typeof value === "object" && "sid
21
23
  const DEFAULT_COLUMN_WIDTH_PX = 100;
22
24
  const DEFAULT_ROW_HEIGHT_PX = 21;
23
25
  const pixelToPoints = (px) => Math.max(1, Number(((px * 72) / 96).toFixed(2)));
24
- export const createExcelFile = async ({ conditionalFormats: _conditionalFormats, dataValidations: _dataValidations, sheets, sheetData, sharedStrings: sourceSharedStrings, tables: _tables = [], cellXfs, theme, defaultFont, embeds = [], charts = [], slicers = [], pivotTables = [], externalLinks = [], namedRanges = [], namedCellStyles, customTableStyles, iterativeCalculationOptions, ignoreFormulaPatterns, citations, comments, password, locale, use1904 = false, properties, powerQuery, }) => {
26
+ export const createExcelFile = async ({ conditionalFormats: _conditionalFormats, dataValidations: _dataValidations, sheets, sheetData, sharedStrings: sourceSharedStrings, tables: _tables = [], cellXfs, theme, defaultFont, embeds = [], charts = [], slicers = [], pivotTables = [], externalLinks = [], namedRanges = [], protectedRanges = [], namedCellStyles, customTableStyles, iterativeCalculationOptions, ignoreFormulaPatterns, citations, comments, password, locale, use1904 = false, properties, powerQuery, }) => {
25
27
  const sortedSheets = sortSheetsByIndex(sheets, true);
26
28
  const sharedStrings = new SharedStrings();
27
29
  // Workbook-scoped author registry shared across every sheet's threaded
@@ -183,12 +185,26 @@ export const createExcelFile = async ({ conditionalFormats: _conditionalFormats,
183
185
  fieldIndex.set(name, fieldNameList.length);
184
186
  fieldNameList.push(name);
185
187
  };
188
+ const calculatedNames = new Set((pivot.calculatedFields ?? []).map((c) => c.name));
186
189
  for (const r of pivot.rows ?? [])
187
190
  registerField(r.field);
188
191
  for (const c of pivot.columns ?? [])
189
192
  registerField(c.field);
190
- for (const v of pivot.values ?? [])
191
- registerField(v.field);
193
+ for (const v of pivot.values ?? []) {
194
+ if (!calculatedNames.has(v.field))
195
+ registerField(v.field);
196
+ }
197
+ // Source fields a calculated field reads must exist in the cache
198
+ // for Excel to evaluate the formula; the calculated fields
199
+ // themselves come last, as Excel orders them.
200
+ for (const c of pivot.calculatedFields ?? []) {
201
+ for (const ref of calculatedFieldReferences(c.formula)) {
202
+ if (!calculatedNames.has(ref))
203
+ registerField(ref);
204
+ }
205
+ }
206
+ for (const c of pivot.calculatedFields ?? [])
207
+ registerField(c.name);
192
208
  const cacheXml = buildPivotCacheDefinitionXml(pivot, sourceSheetName, fieldNameList);
193
209
  const tableXml = buildPivotTableXml(pivot, cacheIndex, fieldIndex);
194
210
  if (!cacheXml || !tableXml)
@@ -233,6 +249,7 @@ export const createExcelFile = async ({ conditionalFormats: _conditionalFormats,
233
249
  sheetData,
234
250
  sheets: sortedSheets,
235
251
  locale,
252
+ protectedRanges,
236
253
  });
237
254
  return {
238
255
  index,
@@ -643,7 +660,7 @@ const getRowsForSheet = (sheetData, sheetId) => {
643
660
  sheetData[Number(sheetId)] ??
644
661
  []);
645
662
  };
646
- const buildWorksheetXml = ({ sheet, rows, sharedStrings, sourceSharedStrings, cellXfs, styleBuilder, tables, relationships, drawingContext, conditionalFormats, dataValidationsById, dataValidations, ignoreFormulaPatterns, citations, comments, personRegistry, slicerRelIds, sheetData, sheets, locale, }) => {
663
+ const buildWorksheetXml = ({ sheet, rows, sharedStrings, sourceSharedStrings, cellXfs, styleBuilder, tables, relationships, drawingContext, conditionalFormats, dataValidationsById, dataValidations, ignoreFormulaPatterns, citations, comments, personRegistry, slicerRelIds, sheetData, sheets, locale, protectedRanges, }) => {
647
664
  const tableNameMap = buildTableNameMap(tables);
648
665
  const bounds = {
649
666
  minRow: Number.POSITIVE_INFINITY,
@@ -813,7 +830,8 @@ const buildWorksheetXml = ({ sheet, rows, sharedStrings, sourceSharedStrings, ce
813
830
  legacyDrawingXml = `<legacyDrawing r:id="${legacyDrawingRelId}"/>`;
814
831
  }
815
832
  const sheetDataXml = `<sheetData>${rowXmlChunks.join("")}</sheetData>`;
816
- const pageMargins = DEFAULT_PAGE_MARGINS;
833
+ const protectionXml = buildSheetProtectionXml(sheet, protectedRanges);
834
+ const printSettingsXml = buildPrintSettingsXml(sheet.printSettings);
817
835
  // Build row data map for sparkline detection
818
836
  const rowDataMap = {};
819
837
  for (let rowIndex = 1; rowIndex < maxRows; rowIndex++) {
@@ -847,7 +865,7 @@ const buildWorksheetXml = ({ sheet, rows, sharedStrings, sourceSharedStrings, ce
847
865
  explicitDefaultColumnWidthPx > 0) {
848
866
  sheetFormatPrAttrs.push(`defaultColWidth="${columnPixelToChars(explicitDefaultColumnWidthPx).toFixed(2)}"`);
849
867
  }
850
- const worksheetXml = `${XML_DECLARATION}<worksheet xmlns="${WORKSHEET_NS}" xmlns:r="${REL_NS}">${sheetPrXml}<dimension ref="${dimension}"/>${sheetViews}<sheetFormatPr ${sheetFormatPrAttrs.join(" ")}/>${columnXml}${sheetDataXml}${basicFilterXml}${mergeXml}${conditionalFormattingXml}${dataValidationsXml}${hyperlinksXml}${pageMargins}${drawingXml}${legacyDrawingXml}${tablePartsXml}${extLstXml}</worksheet>`;
868
+ const worksheetXml = `${XML_DECLARATION}<worksheet xmlns="${WORKSHEET_NS}" xmlns:r="${REL_NS}">${sheetPrXml}<dimension ref="${dimension}"/>${sheetViews}<sheetFormatPr ${sheetFormatPrAttrs.join(" ")}/>${columnXml}${sheetDataXml}${protectionXml}${basicFilterXml}${mergeXml}${conditionalFormattingXml}${dataValidationsXml}${hyperlinksXml}${printSettingsXml}${drawingXml}${legacyDrawingXml}${tablePartsXml}${extLstXml}</worksheet>`;
851
869
  return {
852
870
  xml: worksheetXml,
853
871
  relationshipsXml: relationships.toXml(),
@@ -1814,7 +1832,9 @@ function rewriteStructuredReferenceFormula(formula, tableNameMap, tables, rowNum
1814
1832
  }
1815
1833
  function sanitizeFormulaText(formula) {
1816
1834
  const normalized = sanitizeXmlText(formula);
1817
- return qualifyFutureFunctions(normalized.replace(/\r\n|\r|\n/g, " "));
1835
+ // `@x` is persisted as `_xlfn.SINGLE(x)`; encoding first lets the
1836
+ // future-function pass add the prefix.
1837
+ return qualifyFutureFunctions(encodeImplicitIntersection(normalized.replace(/\r\n|\r|\n/g, " ")));
1818
1838
  }
1819
1839
  function escapeXmlBase(value) {
1820
1840
  return sanitizeXmlText(value)
@@ -1976,6 +1996,11 @@ const buildSheetViewsXml = (sheet) => {
1976
1996
  zoomScale !== 100) {
1977
1997
  attrs.push(`zoomScale="${zoomScale}"`);
1978
1998
  }
1999
+ // `rightToLeft` — only an explicit true is written (Excel's default is
2000
+ // LTR and the attribute is omitted), mirroring the Rust exporter.
2001
+ if (sheet.rightToLeft) {
2002
+ attrs.push(`rightToLeft="1"`);
2003
+ }
1979
2004
  const panes = [];
1980
2005
  const frozenRows = sheet.frozenRowCount ?? 0;
1981
2006
  const frozenColumns = sheet.frozenColumnCount ?? 0;
@@ -2230,6 +2255,57 @@ const sanitizeTableName = (name) => {
2230
2255
  }
2231
2256
  return cleaned;
2232
2257
  };
2258
+ /**
2259
+ * `SUBTOTAL()` function numbers (the 101+ "ignore hidden rows" family Excel
2260
+ * writes into totals-row cells) → `totalsRowFunction`.
2261
+ */
2262
+ const SUBTOTAL_TOTALS_ROW_FUNCTIONS = {
2263
+ "101": "average",
2264
+ "102": "countNums",
2265
+ "103": "count",
2266
+ "104": "max",
2267
+ "105": "min",
2268
+ "107": "stdDev",
2269
+ "109": "sum",
2270
+ "110": "var",
2271
+ };
2272
+ /**
2273
+ * Totals-row metadata for one `<tableColumn>`. Explicit column metadata
2274
+ * (imported from Excel, or set by the host) wins — an explicit `none` means
2275
+ * "no totals function" and is not second-guessed from the cell (the Rust
2276
+ * exporter's `totals_for_column` agrees). Otherwise it is derived from the
2277
+ * totals-row cell so tables whose totals row was added in-app (a "Total"
2278
+ * label + `=SUBTOTAL(109,[Col])` per numeric column) open in Excel with the
2279
+ * matching function selected in the totals dropdown. A formula that is not
2280
+ * a plain `SUBTOTAL(n, ref)` is left to the cell.
2281
+ */
2282
+ const resolveTableColumnTotals = (column, totalsCell) => {
2283
+ if (column?.totalsRowLabel) {
2284
+ return { totalsRowLabel: column.totalsRowLabel };
2285
+ }
2286
+ if (column?.totalsRowFunction) {
2287
+ if (column.totalsRowFunction === "none") {
2288
+ return undefined;
2289
+ }
2290
+ if (column.totalsRowFunction === "custom" && !column.totalsRowFormula) {
2291
+ return undefined;
2292
+ }
2293
+ return {
2294
+ totalsRowFunction: column.totalsRowFunction,
2295
+ totalsRowFormula: column.totalsRowFormula ?? undefined,
2296
+ };
2297
+ }
2298
+ if (!totalsCell)
2299
+ return undefined;
2300
+ const formula = getExtendedValueFormula(getCellUserEnteredValue(totalsCell));
2301
+ if (formula) {
2302
+ const match = /^=?\s*SUBTOTAL\(\s*(\d{3})\s*,[^,]*\)\s*$/i.exec(formula);
2303
+ const fn = match ? SUBTOTAL_TOTALS_ROW_FUNCTIONS[match[1]] : undefined;
2304
+ return fn ? { totalsRowFunction: fn } : undefined;
2305
+ }
2306
+ const label = getExtendedValueString(getCellUserEnteredValue(totalsCell));
2307
+ return label && label.trim() !== "" ? { totalsRowLabel: label } : undefined;
2308
+ };
2233
2309
  const buildTableXml = (table, rows, sourceSharedStrings, styleBuilder) => {
2234
2310
  const rangeRef = table.ref;
2235
2311
  const columnCount = Math.max(table.columns?.length ?? 0, (table.range.endColumnIndex ?? table.range.startColumnIndex) -
@@ -2275,6 +2351,7 @@ const buildTableXml = (table, rows, sourceSharedStrings, styleBuilder) => {
2275
2351
  // Fallback to table.columns name or generic name
2276
2352
  return table.columns?.[index]?.name ?? `Column${index + 1}`;
2277
2353
  });
2354
+ const totalsRowIndex = table.range.endRowIndex;
2278
2355
  const tableColumns = `<tableColumns count="${columnCount}">${columnNames
2279
2356
  .map((name, idx) => {
2280
2357
  const columnFormula = table.columns?.[idx]?.formula;
@@ -2284,7 +2361,22 @@ const buildTableXml = (table, rows, sourceSharedStrings, styleBuilder) => {
2284
2361
  const formulaXml = normalizedFormula
2285
2362
  ? `<calculatedColumnFormula>${escapeXmlText(stripFormula(normalizedFormula))}</calculatedColumnFormula>`
2286
2363
  : "";
2287
- return `<tableColumn id="${idx + 1}" name="${escapeXmlAttr(name)}">${formulaXml}</tableColumn>`;
2364
+ // Explicit per-column totals metadata exports whether or not the totals
2365
+ // row is shown: Excel keeps it on a table whose totals row is toggled
2366
+ // off so re-enabling restores each column's choice. Deriving it from
2367
+ // the last row only applies when that row IS the totals row.
2368
+ const totals = resolveTableColumnTotals(table.columns?.[idx], totalsRowCount > 0
2369
+ ? rows[totalsRowIndex]?.values?.[table.range.startColumnIndex + idx]
2370
+ : undefined);
2371
+ const totalsAttrs = totals?.totalsRowLabel
2372
+ ? ` totalsRowLabel="${escapeXmlAttr(totals.totalsRowLabel)}"`
2373
+ : totals?.totalsRowFunction
2374
+ ? ` totalsRowFunction="${totals.totalsRowFunction}"`
2375
+ : "";
2376
+ const totalsFormulaXml = totals?.totalsRowFunction === "custom" && totals.totalsRowFormula
2377
+ ? `<totalsRowFormula>${escapeXmlText(stripFormula(normalizeCalculatedColumnFormula(totals.totalsRowFormula, table)))}</totalsRowFormula>`
2378
+ : "";
2379
+ return `<tableColumn id="${idx + 1}" name="${escapeXmlAttr(name)}"${totalsAttrs}>${formulaXml}${totalsFormulaXml}</tableColumn>`;
2288
2380
  })
2289
2381
  .join("")}</tableColumns>`;
2290
2382
  const isBuiltInStyle = table.theme && /^TableStyle(Light|Medium|Dark)\d+$/.test(table.theme);
@@ -2332,6 +2424,106 @@ const buildWorksheetAutoFilterXml = (sheet, rows, styleBuilder) => {
2332
2424
  }
2333
2425
  return `<autoFilter ref="${ref}"/>`;
2334
2426
  };
2427
+ /** `CT_SheetProtection` boolean attributes in Excel's attribute order. */
2428
+ const SHEET_PROTECTION_FLAG_ATTRS = [
2429
+ "sheet",
2430
+ "objects",
2431
+ "scenarios",
2432
+ "formatCells",
2433
+ "formatColumns",
2434
+ "formatRows",
2435
+ "insertColumns",
2436
+ "insertRows",
2437
+ "insertHyperlinks",
2438
+ "deleteColumns",
2439
+ "deleteRows",
2440
+ "selectLockedCells",
2441
+ "sort",
2442
+ "autoFilter",
2443
+ "pivotTables",
2444
+ "selectUnlockedCells",
2445
+ ];
2446
+ /**
2447
+ * `<sheetProtection …/>` from `Sheet.sheetProtection`: the password hash first
2448
+ * (legacy `password` or the agile quartet, verbatim), then only the action
2449
+ * flags the model carries (`true` → `"1"`, `false` → `"0"`, absent → omitted so
2450
+ * Excel's schema default applies). Mirrors `rnc-export`'s `sheet_protection_xml`.
2451
+ */
2452
+ const buildSheetProtectionElementXml = (protection) => {
2453
+ const attrs = [];
2454
+ if (protection.algorithmName) {
2455
+ attrs.push(`algorithmName="${escapeXmlAttr(protection.algorithmName)}"`);
2456
+ }
2457
+ if (protection.hashValue) {
2458
+ attrs.push(`hashValue="${escapeXmlAttr(protection.hashValue)}"`);
2459
+ }
2460
+ if (protection.saltValue) {
2461
+ attrs.push(`saltValue="${escapeXmlAttr(protection.saltValue)}"`);
2462
+ }
2463
+ if (protection.spinCount != null) {
2464
+ attrs.push(`spinCount="${protection.spinCount}"`);
2465
+ }
2466
+ if (protection.password) {
2467
+ attrs.push(`password="${escapeXmlAttr(protection.password)}"`);
2468
+ }
2469
+ for (const flag of SHEET_PROTECTION_FLAG_ATTRS) {
2470
+ const value = protection[flag];
2471
+ if (typeof value === "boolean") {
2472
+ attrs.push(`${flag}="${value ? 1 : 0}"`);
2473
+ }
2474
+ }
2475
+ return attrs.length ? `<sheetProtection ${attrs.join(" ")}/>` : "";
2476
+ };
2477
+ /**
2478
+ * The sheet's protection block — `<sheetProtection>` followed by
2479
+ * `<protectedRanges>` — or `""` when the sheet is unprotected and has no
2480
+ * allow-edit ranges. Ranges missing a bound (a sheet-wide `protect-sheet`
2481
+ * entry only carries `sheetId`) have no `sqref` and are skipped; the `name`
2482
+ * is the range's id, synthesized as `Range{n}` when absent (schema requires it).
2483
+ */
2484
+ const buildSheetProtectionXml = (sheet, protectedRanges) => {
2485
+ const protectionXml = sheet.sheetProtection
2486
+ ? buildSheetProtectionElementXml(sheet.sheetProtection)
2487
+ : "";
2488
+ const rangeItems = [];
2489
+ for (const protectedRange of protectedRanges ?? []) {
2490
+ const range = protectedRange.range;
2491
+ if (range.sheetId !== sheet.sheetId)
2492
+ continue;
2493
+ if (range.startRowIndex == null ||
2494
+ range.endRowIndex == null ||
2495
+ range.startColumnIndex == null ||
2496
+ range.endColumnIndex == null) {
2497
+ continue;
2498
+ }
2499
+ // Excel writes a single cell as `D10`, a block as `A2:C5`.
2500
+ const isSingleCell = range.startRowIndex === range.endRowIndex &&
2501
+ range.startColumnIndex === range.endColumnIndex;
2502
+ const sqref = isSingleCell
2503
+ ? cellToAddress({
2504
+ rowIndex: range.startRowIndex,
2505
+ columnIndex: range.startColumnIndex,
2506
+ })
2507
+ : cellRangeToAddress(undefined, {
2508
+ startRowIndex: range.startRowIndex,
2509
+ endRowIndex: range.endRowIndex,
2510
+ startColumnIndex: range.startColumnIndex,
2511
+ endColumnIndex: range.endColumnIndex,
2512
+ });
2513
+ if (!sqref)
2514
+ continue;
2515
+ const id = protectedRange.protectedRangeId;
2516
+ const name = id != null && String(id) !== "" ? String(id) : `Range${rangeItems.length + 1}`;
2517
+ const securityDescriptor = protectedRange.securityDescriptor
2518
+ ? ` securityDescriptor="${escapeXmlAttr(protectedRange.securityDescriptor)}"`
2519
+ : "";
2520
+ rangeItems.push(`<protectedRange sqref="${escapeXmlAttr(sqref)}" name="${escapeXmlAttr(name)}"${securityDescriptor}/>`);
2521
+ }
2522
+ const rangesXml = rangeItems.length
2523
+ ? `<protectedRanges>${rangeItems.join("")}</protectedRanges>`
2524
+ : "";
2525
+ return `${protectionXml}${rangesXml}`;
2526
+ };
2335
2527
  const buildSheetPropertiesXml = (sheet) => {
2336
2528
  const tabColor = serializeColorXml(sheet.tabColor ?? undefined);
2337
2529
  // <outlinePr> mirrors the sheet's `outlinePr` setting; both attrs
@@ -2348,11 +2540,105 @@ const buildSheetPropertiesXml = (sheet) => {
2348
2540
  const outlineXml = outlineAttrs.length
2349
2541
  ? `<outlinePr ${outlineAttrs.join(" ")}/>`
2350
2542
  : "";
2351
- if (!tabColor && !outlineXml)
2543
+ // `<pageSetUpPr fitToPage="1">` is what makes Excel honour
2544
+ // `<pageSetup fitToWidth/fitToHeight>` instead of `scale`.
2545
+ const pageSetUpPrXml = sheet.printSettings?.pageSetup?.fitToPage
2546
+ ? `<pageSetUpPr fitToPage="1"/>`
2547
+ : "";
2548
+ if (!tabColor && !outlineXml && !pageSetUpPrXml)
2352
2549
  return "";
2353
- const inner = `${tabColor ? `<tabColor ${tabColor}/>` : ""}${outlineXml}`;
2550
+ const inner = `${tabColor ? `<tabColor ${tabColor}/>` : ""}${outlineXml}${pageSetUpPrXml}`;
2354
2551
  return `<sheetPr>${inner}</sheetPr>`;
2355
2552
  };
2553
+ const xmlBoolAttr = (name, value) => value == null ? "" : ` ${name}="${value ? "1" : "0"}"`;
2554
+ const xmlNumAttr = (name, value) => typeof value === "number" && Number.isFinite(value)
2555
+ ? ` ${name}="${value}"`
2556
+ : "";
2557
+ /**
2558
+ * The `CT_Worksheet` print tail — `<printOptions>`, `<pageMargins>`,
2559
+ * `<pageSetup>`, `<headerFooter>`, `<rowBreaks>`, `<colBreaks>` — in schema
2560
+ * order (after `<hyperlinks>`, before `<drawing>`). `<pageMargins>` is
2561
+ * mandatory in practice (Excel always writes it), so the default margins go
2562
+ * out when the sheet has none of its own.
2563
+ */
2564
+ const buildPrintSettingsXml = (print) => {
2565
+ const parts = [];
2566
+ const options = print?.printOptions;
2567
+ const optionAttrs = xmlBoolAttr("horizontalCentered", options?.horizontalCentered) +
2568
+ xmlBoolAttr("verticalCentered", options?.verticalCentered) +
2569
+ xmlBoolAttr("headings", options?.headings) +
2570
+ xmlBoolAttr("gridLines", options?.gridLines);
2571
+ if (optionAttrs)
2572
+ parts.push(`<printOptions${optionAttrs}/>`);
2573
+ const margins = print?.pageMargins;
2574
+ parts.push(margins &&
2575
+ Object.values(margins).some((v) => typeof v === "number")
2576
+ ? `<pageMargins left="${margins.left ?? 0.7}" right="${margins.right ?? 0.7}" top="${margins.top ?? 0.75}" bottom="${margins.bottom ?? 0.75}" header="${margins.header ?? 0.3}" footer="${margins.footer ?? 0.3}"/>`
2577
+ : DEFAULT_PAGE_MARGINS);
2578
+ const setup = print?.pageSetup;
2579
+ if (setup) {
2580
+ // Excel keeps `firstPageNumber` as a dormant value until the user picks
2581
+ // a first page number (`useFirstPageNumber="1"`); a bare number means
2582
+ // "Auto", so the flag is written only when the model has it on.
2583
+ const useFirst = setup.useFirstPageNumber === true ? true : undefined;
2584
+ const setupAttrs = xmlNumAttr("paperSize", setup.paperSize) +
2585
+ xmlNumAttr("scale", setup.scale) +
2586
+ xmlNumAttr("firstPageNumber", setup.firstPageNumber) +
2587
+ xmlNumAttr("fitToWidth", setup.fitToWidth) +
2588
+ xmlNumAttr("fitToHeight", setup.fitToHeight) +
2589
+ (setup.pageOrder ? ` pageOrder="${setup.pageOrder}"` : "") +
2590
+ (setup.orientation ? ` orientation="${setup.orientation}"` : "") +
2591
+ xmlBoolAttr("blackAndWhite", setup.blackAndWhite) +
2592
+ xmlBoolAttr("draft", setup.draft) +
2593
+ xmlBoolAttr("useFirstPageNumber", useFirst);
2594
+ if (setupAttrs)
2595
+ parts.push(`<pageSetup${setupAttrs}/>`);
2596
+ }
2597
+ const hf = print?.headerFooter;
2598
+ if (hf && !isHeaderFooterEmpty(hf)) {
2599
+ const hfAttrs = xmlBoolAttr("differentOddEven", hf.differentOddEven) +
2600
+ xmlBoolAttr("differentFirst", hf.differentFirst) +
2601
+ xmlBoolAttr("scaleWithDoc", hf.scaleWithDoc) +
2602
+ xmlBoolAttr("alignWithMargins", hf.alignWithMargins);
2603
+ const children = [
2604
+ "oddHeader",
2605
+ "oddFooter",
2606
+ "evenHeader",
2607
+ "evenFooter",
2608
+ "firstHeader",
2609
+ "firstFooter",
2610
+ ]
2611
+ .map((key) => {
2612
+ const text = hf[key];
2613
+ return typeof text === "string" && text
2614
+ ? `<${key}>${escapeXmlText(text)}</${key}>`
2615
+ : "";
2616
+ })
2617
+ .join("");
2618
+ parts.push(children
2619
+ ? `<headerFooter${hfAttrs}>${children}</headerFooter>`
2620
+ : `<headerFooter${hfAttrs}/>`);
2621
+ }
2622
+ const breaks = (ids, max) => {
2623
+ const valid = Array.from(new Set((ids ?? []).filter((id) => Number.isInteger(id) && id > 0))).sort((a, b) => a - b);
2624
+ if (!valid.length)
2625
+ return "";
2626
+ return valid
2627
+ .map((id) => `<brk id="${id}" max="${max}" man="1"/>`)
2628
+ .join("");
2629
+ };
2630
+ const rowBrks = breaks(print?.rowBreaks, 16383);
2631
+ if (rowBrks) {
2632
+ const count = rowBrks.split("<brk ").length - 1;
2633
+ parts.push(`<rowBreaks count="${count}" manualBreakCount="${count}">${rowBrks}</rowBreaks>`);
2634
+ }
2635
+ const colBrks = breaks(print?.columnBreaks, 1048575);
2636
+ if (colBrks) {
2637
+ const count = colBrks.split("<brk ").length - 1;
2638
+ parts.push(`<colBreaks count="${count}" manualBreakCount="${count}">${colBrks}</colBreaks>`);
2639
+ }
2640
+ return parts.join("");
2641
+ };
2356
2642
  const buildFilterColumnsXml = (filterSpecs, range, rows, headerRow = true, totalRow = false, styleBuilder) => {
2357
2643
  if (!filterSpecs?.length)
2358
2644
  return "";
@@ -2420,15 +2706,26 @@ const gridRangeToRef = (range) => {
2420
2706
  };
2421
2707
  const groupEmbedsBySheet = (embeds) => {
2422
2708
  const result = new Map();
2423
- for (const embed of embeds ?? []) {
2709
+ (embeds ?? []).forEach((embed, order) => {
2424
2710
  if (!embed.position?.overlayPosition)
2425
- continue;
2711
+ return;
2426
2712
  if (embed.spec?.type !== "image")
2427
- continue;
2713
+ return;
2428
2714
  const overlay = embed.position.overlayPosition;
2715
+ const imageSpec = embed.spec;
2716
+ const adjustments = {
2717
+ crop: imageSpec.crop ?? undefined,
2718
+ brightness: imageSpec.brightness ?? undefined,
2719
+ contrast: imageSpec.contrast ?? undefined,
2720
+ transparency: imageSpec.transparency ?? undefined,
2721
+ };
2429
2722
  const placement = {
2430
2723
  kind: "embed",
2431
- imageUrl: embed.spec.imageUrl,
2724
+ imageUrl: imageSpec.imageUrl,
2725
+ adjustments: Object.values(adjustments).some((v) => v !== undefined)
2726
+ ? adjustments
2727
+ : undefined,
2728
+ order,
2432
2729
  rowIndex: overlay.anchorCell.rowIndex,
2433
2730
  columnIndex: overlay.anchorCell.columnIndex,
2434
2731
  offsetX: overlay.offsetXPixels ?? 0,
@@ -2439,19 +2736,20 @@ const groupEmbedsBySheet = (embeds) => {
2439
2736
  const list = result.get(embed.position.sheetId) ?? [];
2440
2737
  list.push(placement);
2441
2738
  result.set(embed.position.sheetId, list);
2442
- }
2739
+ });
2443
2740
  return result;
2444
2741
  };
2445
2742
  const groupTextBoxesBySheet = (embeds) => {
2446
2743
  const result = new Map();
2447
- for (const embed of embeds ?? []) {
2744
+ (embeds ?? []).forEach((embed, order) => {
2448
2745
  if (!embed.position?.overlayPosition)
2449
- continue;
2746
+ return;
2450
2747
  if (embed.spec?.type !== "textbox")
2451
- continue;
2748
+ return;
2452
2749
  const overlay = embed.position.overlayPosition;
2453
2750
  const placement = {
2454
2751
  textBox: embed,
2752
+ order,
2455
2753
  rowIndex: overlay.anchorCell.rowIndex,
2456
2754
  columnIndex: overlay.anchorCell.columnIndex,
2457
2755
  offsetX: overlay.offsetXPixels ?? 0,
@@ -2462,19 +2760,20 @@ const groupTextBoxesBySheet = (embeds) => {
2462
2760
  const list = result.get(embed.position.sheetId) ?? [];
2463
2761
  list.push(placement);
2464
2762
  result.set(embed.position.sheetId, list);
2465
- }
2763
+ });
2466
2764
  return result;
2467
2765
  };
2468
2766
  const groupShapesBySheet = (embeds) => {
2469
2767
  const result = new Map();
2470
- for (const embed of embeds ?? []) {
2768
+ (embeds ?? []).forEach((embed, order) => {
2471
2769
  if (!embed.position?.overlayPosition)
2472
- continue;
2770
+ return;
2473
2771
  if (embed.spec?.type !== "shape")
2474
- continue;
2772
+ return;
2475
2773
  const overlay = embed.position.overlayPosition;
2476
2774
  const placement = {
2477
2775
  shape: embed,
2776
+ order,
2478
2777
  rowIndex: overlay.anchorCell.rowIndex,
2479
2778
  columnIndex: overlay.anchorCell.columnIndex,
2480
2779
  offsetX: overlay.offsetXPixels ?? 0,
@@ -2485,7 +2784,7 @@ const groupShapesBySheet = (embeds) => {
2485
2784
  const list = result.get(embed.position.sheetId) ?? [];
2486
2785
  list.push(placement);
2487
2786
  result.set(embed.position.sheetId, list);
2488
- }
2787
+ });
2489
2788
  return result;
2490
2789
  };
2491
2790
  const groupChartsBySheet = (charts) => {
@@ -3118,20 +3417,32 @@ const buildDrawingParts = async ({ placements, textBoxes, shapes = [], startImag
3118
3417
  }));
3119
3418
  frameId += 1;
3120
3419
  }
3420
+ // Embeds (text boxes, shapes, images) keep the host's `embeds` order in the
3421
+ // drawing part — anchor order IS Excel's z-order, so an image drawn over a
3422
+ // shape must be emitted after it regardless of kind. Placements without an
3423
+ // order (in-cell images) go last, in their own order.
3424
+ const embedAnchors = [];
3425
+ const orderOf = (order) => order ?? Number.MAX_SAFE_INTEGER;
3121
3426
  for (const placement of textBoxes) {
3122
- anchors.push(createTextBoxAnchor({
3123
- sheet,
3124
- placement,
3125
- frameId,
3126
- }));
3427
+ embedAnchors.push({
3428
+ order: orderOf(placement.order),
3429
+ xml: createTextBoxAnchor({
3430
+ sheet,
3431
+ placement,
3432
+ frameId,
3433
+ }),
3434
+ });
3127
3435
  frameId += 1;
3128
3436
  }
3129
3437
  for (const placement of shapes) {
3130
- anchors.push(createShapeAnchor({
3131
- sheet,
3132
- placement,
3133
- frameId,
3134
- }));
3438
+ embedAnchors.push({
3439
+ order: orderOf(placement.order),
3440
+ xml: createShapeAnchor({
3441
+ sheet,
3442
+ placement,
3443
+ frameId,
3444
+ }),
3445
+ });
3135
3446
  frameId += 1;
3136
3447
  }
3137
3448
  for (const placement of placements) {
@@ -3148,15 +3459,21 @@ const buildDrawingParts = async ({ placements, textBoxes, shapes = [], startImag
3148
3459
  extension: buffer.extension,
3149
3460
  });
3150
3461
  const relId = relationships.add(IMAGE_REL_TYPE, `../media/image${imageIndex}.${buffer.extension}`);
3151
- anchors.push(createTwoCellAnchor({
3152
- sheet,
3153
- placement,
3154
- relId,
3155
- pictureId,
3156
- }));
3462
+ embedAnchors.push({
3463
+ order: orderOf(placement.order),
3464
+ xml: createTwoCellAnchor({
3465
+ sheet,
3466
+ placement,
3467
+ relId,
3468
+ pictureId,
3469
+ }),
3470
+ });
3157
3471
  imageIndex += 1;
3158
3472
  pictureId += 1;
3159
3473
  }
3474
+ embedAnchors.sort((a, b) => a.order - b.order);
3475
+ for (const entry of embedAnchors)
3476
+ anchors.push(entry.xml);
3160
3477
  const drawingXml = `${XML_DECLARATION}<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="${REL_NS}">${anchors.join("")}</xdr:wsDr>`;
3161
3478
  return {
3162
3479
  xml: drawingXml,
@@ -3190,7 +3507,53 @@ const createTwoCellAnchor = ({ placement, relId, pictureId, sheet, }) => {
3190
3507
  });
3191
3508
  const fromXml = `<xdr:from><xdr:col>${fromColumn}</xdr:col><xdr:colOff>${Math.round(offsetX * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${fromRow}</xdr:row><xdr:rowOff>${Math.round(offsetY * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:from>`;
3192
3509
  const toXml = `<xdr:to><xdr:col>${toColumn.index}</xdr:col><xdr:colOff>${Math.round(toColumn.offset * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${toRow.index}</xdr:row><xdr:rowOff>${Math.round(toRow.offset * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:to>`;
3193
- return `<xdr:twoCellAnchor editAs="oneCell">${fromXml}${toXml}<xdr:pic><xdr:nvPicPr><xdr:cNvPr id="${pictureId}" name="Picture ${pictureId}"/><xdr:cNvPicPr/></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="${relId}"/><a:stretch><a:fillRect/></a:stretch></xdr:blipFill><xdr:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${Math.round(widthPx * EMUS_PER_PIXEL)}" cy="${Math.round(heightPx * EMUS_PER_PIXEL)}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr></xdr:pic><xdr:clientData/></xdr:twoCellAnchor>`;
3510
+ const { blipChildren, srcRectXml } = buildPictureAdjustmentsXml(placement.adjustments);
3511
+ const blipXml = blipChildren
3512
+ ? `<a:blip r:embed="${relId}">${blipChildren}</a:blip>`
3513
+ : `<a:blip r:embed="${relId}"/>`;
3514
+ return `<xdr:twoCellAnchor editAs="oneCell">${fromXml}${toXml}<xdr:pic><xdr:nvPicPr><xdr:cNvPr id="${pictureId}" name="Picture ${pictureId}"/><xdr:cNvPicPr/></xdr:nvPicPr><xdr:blipFill>${blipXml}${srcRectXml}<a:stretch><a:fillRect/></a:stretch></xdr:blipFill><xdr:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${Math.round(widthPx * EMUS_PER_PIXEL)}" cy="${Math.round(heightPx * EMUS_PER_PIXEL)}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr></xdr:pic><xdr:clientData/></xdr:twoCellAnchor>`;
3515
+ };
3516
+ /** Fraction (0…1) → DrawingML per-mille percent (100000 = 100%). */
3517
+ const fractionToPct = (value) => Math.round(Math.max(-1, Math.min(1, value)) * 100000);
3518
+ /**
3519
+ * `<a:blip>` children (`<a:alphaModFix>`, `<a:lum>`) and the `<a:srcRect>`
3520
+ * sibling for an image's picture-format adjustments; both empty when the
3521
+ * spec carries none.
3522
+ */
3523
+ const buildPictureAdjustmentsXml = (adjustments) => {
3524
+ if (!adjustments)
3525
+ return { blipChildren: "", srcRectXml: "" };
3526
+ let blipChildren = "";
3527
+ const { transparency, brightness, contrast, crop } = adjustments;
3528
+ if (typeof transparency === "number" && transparency > 0) {
3529
+ blipChildren += `<a:alphaModFix amt="${fractionToPct(1 - Math.min(1, transparency))}"/>`;
3530
+ }
3531
+ const hasBrightness = typeof brightness === "number" && brightness !== 0;
3532
+ const hasContrast = typeof contrast === "number" && contrast !== 0;
3533
+ if (hasBrightness || hasContrast) {
3534
+ blipChildren += `<a:lum${hasBrightness ? ` bright="${fractionToPct(brightness)}"` : ""}${hasContrast ? ` contrast="${fractionToPct(contrast)}"` : ""}/>`;
3535
+ }
3536
+ let srcRectXml = "";
3537
+ if (crop) {
3538
+ const edges = ["l", "t", "r", "b"]
3539
+ .map((edge) => {
3540
+ const value = {
3541
+ l: crop.left,
3542
+ t: crop.top,
3543
+ r: crop.right,
3544
+ b: crop.bottom,
3545
+ }[edge];
3546
+ // Negative edges are Excel's "out-crop" (padding around the
3547
+ // bitmap) and round-trip like positive ones.
3548
+ return typeof value === "number" && Number.isFinite(value) && value !== 0
3549
+ ? ` ${edge}="${fractionToPct(value)}"`
3550
+ : "";
3551
+ })
3552
+ .join("");
3553
+ if (edges)
3554
+ srcRectXml = `<a:srcRect${edges}/>`;
3555
+ }
3556
+ return { blipChildren, srcRectXml };
3194
3557
  };
3195
3558
  const createChartAnchor = ({ placement, relId, frameId, sheet, }) => {
3196
3559
  const fromRow = Math.max(0, placement.rowIndex - 1);
@@ -3857,7 +4220,10 @@ const buildWorkbookXml = (sheets, sheetRelIds, sheetNames, namedRanges, iterativ
3857
4220
  sheets.forEach((sheet, index) => {
3858
4221
  sheetNameById.set(sheet.sheetId ?? index + 1, sheetNames[index]);
3859
4222
  });
3860
- const definedNamesXml = buildDefinedNamesXml(namedRanges, sheetNameById);
4223
+ const definedNamesXml = buildDefinedNamesXml(namedRanges, sheetNameById, sheets.map((sheet, index) => ({
4224
+ sheetName: sheetNames[index] ?? `Sheet${index + 1}`,
4225
+ printSettings: sheet.printSettings,
4226
+ })));
3861
4227
  const calcPrXml = buildCalcPrXml(iterativeCalculationOptions);
3862
4228
  const slicerCachesXml = buildWorkbookSlicerCachesXml(slicerCacheRelIds);
3863
4229
  // Phase D2 — <pivotCaches> element. Each <pivotCache> pairs a
@@ -3886,12 +4252,48 @@ const buildWorkbookXml = (sheets, sheetRelIds, sheetNames, namedRanges, iterativ
3886
4252
  : `<workbookPr/>`;
3887
4253
  return `${XML_DECLARATION}<workbook xmlns="${WORKSHEET_NS}" xmlns:r="${REL_NS}">${workbookPrXml}${calcPrXml}<bookViews><workbookView activeTab="0" firstSheet="0"/></bookViews><sheets>${sheetEntries}</sheets>${definedNamesXml}${externalReferencesXml}${pivotCachesXml}${slicerCachesXml}</workbook>`;
3888
4254
  };
3889
- const buildDefinedNamesXml = (namedRanges, sheetNameById) => {
3890
- if (!namedRanges?.length)
3891
- return "";
4255
+ const buildDefinedNamesXml = (namedRanges, sheetNameById, printScopes = []) => {
3892
4256
  const entries = [];
3893
- for (const namedRange of namedRanges) {
3894
- if (!namedRange?.name)
4257
+ // The builtin print names are sheet-scoped (`localSheetId` = position in
4258
+ // `<sheets>`); they come from `sheet.printSettings`, never from
4259
+ // `namedRanges` (a global `_xlnm.Print_Area` makes Excel repair the file).
4260
+ // Documents imported before `printSettings` existed still carry the print
4261
+ // area as a `_xlnm.Print_Area` named range on the sheet; it is converted
4262
+ // to the sheet-scoped name here unless the sheet already has a print area.
4263
+ const sheetIndexByName = new Map(printScopes.map(({ sheetName }, index) => [sheetName, index]));
4264
+ const legacyPrintAreas = new Map();
4265
+ for (const namedRange of namedRanges ?? []) {
4266
+ if (namedRange?.name !== PRINT_AREA_NAME || !namedRange.range?.sheetId) {
4267
+ continue;
4268
+ }
4269
+ const sheetName = sheetNameById.get(namedRange.range.sheetId);
4270
+ const index = sheetName ? sheetIndexByName.get(sheetName) : undefined;
4271
+ if (index == null)
4272
+ continue;
4273
+ legacyPrintAreas.set(index, [
4274
+ ...(legacyPrintAreas.get(index) ?? []),
4275
+ namedRange.range,
4276
+ ]);
4277
+ }
4278
+ printScopes.forEach(({ sheetName, printSettings }, index) => {
4279
+ const printArea = printSettings?.printArea?.length
4280
+ ? printSettings.printArea
4281
+ : legacyPrintAreas.get(index);
4282
+ const area = printArea?.length
4283
+ ? buildPrintAreaFormula(sheetName, printArea)
4284
+ : undefined;
4285
+ if (area) {
4286
+ entries.push(`<definedName name="${PRINT_AREA_NAME}" localSheetId="${index}">${escapeXmlText(area)}</definedName>`);
4287
+ }
4288
+ const titles = printSettings?.printTitles
4289
+ ? buildPrintTitlesFormula(sheetName, printSettings.printTitles)
4290
+ : undefined;
4291
+ if (titles) {
4292
+ entries.push(`<definedName name="${PRINT_TITLES_NAME}" localSheetId="${index}">${escapeXmlText(titles)}</definedName>`);
4293
+ }
4294
+ });
4295
+ for (const namedRange of namedRanges ?? []) {
4296
+ if (!namedRange?.name || isBuiltinPrintName(namedRange.name))
3895
4297
  continue;
3896
4298
  const hiddenAttr = namedRange.hidden ? ` hidden="1"` : "";
3897
4299
  if (namedRange.range?.sheetId) {