@casualoffice/sheets 0.9.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.
Files changed (92) hide show
  1. package/LICENSE +200 -0
  2. package/dist/embed/embed-runtime.js +537 -0
  3. package/dist/embed/embed.html +29 -0
  4. package/dist/embed/parser.worker.js +48474 -0
  5. package/dist/embed.cjs +225 -0
  6. package/dist/embed.cjs.map +1 -0
  7. package/dist/embed.d.cts +100 -0
  8. package/dist/embed.d.ts +100 -0
  9. package/dist/embed.js +204 -0
  10. package/dist/embed.js.map +1 -0
  11. package/dist/index.cjs +1549 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.cts +9 -0
  14. package/dist/index.d.ts +9 -0
  15. package/dist/index.js +1530 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/parser.worker.cjs +48469 -0
  18. package/dist/parser.worker.cjs.map +1 -0
  19. package/dist/parser.worker.js +48474 -0
  20. package/dist/parser.worker.js.map +1 -0
  21. package/dist/protocol--KyBQUjU.d.cts +171 -0
  22. package/dist/protocol-cEzy7S0i.d.ts +171 -0
  23. package/dist/sheets.cjs +677 -0
  24. package/dist/sheets.cjs.map +1 -0
  25. package/dist/sheets.d.cts +177 -0
  26. package/dist/sheets.d.ts +177 -0
  27. package/dist/sheets.js +658 -0
  28. package/dist/sheets.js.map +1 -0
  29. package/dist/signing.cjs +706 -0
  30. package/dist/signing.cjs.map +1 -0
  31. package/dist/signing.d.cts +141 -0
  32. package/dist/signing.d.ts +141 -0
  33. package/dist/signing.js +683 -0
  34. package/dist/signing.js.map +1 -0
  35. package/dist/styles.cjs +10 -0
  36. package/dist/styles.cjs.map +1 -0
  37. package/dist/styles.d.cts +2 -0
  38. package/dist/styles.d.ts +2 -0
  39. package/dist/styles.js +8 -0
  40. package/dist/styles.js.map +1 -0
  41. package/dist/types-s_O0u6Cg.d.cts +90 -0
  42. package/dist/types-s_O0u6Cg.d.ts +90 -0
  43. package/dist/univer.cjs +220 -0
  44. package/dist/univer.cjs.map +1 -0
  45. package/dist/univer.d.cts +60 -0
  46. package/dist/univer.d.ts +60 -0
  47. package/dist/univer.js +187 -0
  48. package/dist/univer.js.map +1 -0
  49. package/dist/xlsx.cjs +3388 -0
  50. package/dist/xlsx.cjs.map +1 -0
  51. package/dist/xlsx.d.cts +383 -0
  52. package/dist/xlsx.d.ts +383 -0
  53. package/dist/xlsx.js +3383 -0
  54. package/dist/xlsx.js.map +1 -0
  55. package/package.json +293 -0
  56. package/src/embed/EmbedHostTransport.ts +226 -0
  57. package/src/embed/EmbedTransport.ts +323 -0
  58. package/src/embed/EmbedTransport.unit.test.ts +161 -0
  59. package/src/embed/index.ts +40 -0
  60. package/src/embed/protocol.ts +258 -0
  61. package/src/embed-runtime/embed.html +29 -0
  62. package/src/embed-runtime/index.tsx +440 -0
  63. package/src/index.ts +16 -0
  64. package/src/sheets/CasualSheets.tsx +319 -0
  65. package/src/sheets/CasualSheetsIframe.tsx +220 -0
  66. package/src/sheets/api.ts +108 -0
  67. package/src/sheets/index.ts +11 -0
  68. package/src/signing/SigningPane.tsx +374 -0
  69. package/src/signing/SigningProvider.tsx +126 -0
  70. package/src/signing/captures.tsx +316 -0
  71. package/src/signing/controller.ts +151 -0
  72. package/src/signing/controller.unit.test.ts +133 -0
  73. package/src/signing/index.ts +44 -0
  74. package/src/signing/types.ts +89 -0
  75. package/src/styles.ts +16 -0
  76. package/src/univer/index.ts +17 -0
  77. package/src/univer/lazy-plugins.ts +280 -0
  78. package/src/xlsx/_perf.ts +14 -0
  79. package/src/xlsx/_snapshot-constants.ts +15 -0
  80. package/src/xlsx/comments-resource.ts +209 -0
  81. package/src/xlsx/constants.ts +9 -0
  82. package/src/xlsx/data-validation-resource.ts +219 -0
  83. package/src/xlsx/import.ts +35 -0
  84. package/src/xlsx/index.ts +40 -0
  85. package/src/xlsx/page-setup-resource.ts +205 -0
  86. package/src/xlsx/parse-impl.ts +418 -0
  87. package/src/xlsx/parse-in-worker.ts +82 -0
  88. package/src/xlsx/parser.worker.ts +39 -0
  89. package/src/xlsx/passthrough-resource.ts +175 -0
  90. package/src/xlsx/pivot-passthrough.ts +359 -0
  91. package/src/xlsx/style-mapping.ts +171 -0
  92. package/src/xlsx/tables-resource.ts +211 -0
@@ -0,0 +1,219 @@
1
+ import type ExcelJS from 'exceljs';
2
+ import type { IRange, IWorkbookData } from '@univerjs/core';
3
+
4
+ /**
5
+ * xlsx-native `worksheet.dataValidations` ⇄ Univer
6
+ * `SHEET_DATA_VALIDATION_PLUGIN` resource bridge.
7
+ *
8
+ * Data-validation rules live on the worksheet in xlsx (`xl/worksheets/
9
+ * sheetN.xml` `<dataValidations>` element). Univer's data-validation
10
+ * plugin keeps them in a resource keyed by sheet id under the name
11
+ * below — same registration pattern as defined-names and
12
+ * thread-comments. We mirror in both directions so:
13
+ *
14
+ * - a file authored in real Excel keeps its list / whole / date /
15
+ * decimal constraints when opened here (parser fall-back)
16
+ * - our save round-trip preserves the rule even when the exporter
17
+ * is called without the live `extras` from the running app — eg.
18
+ * the audit harness drives `parse → snapshot → export` only.
19
+ *
20
+ * Resource name matches Univer's plugin (`vendor/univer/packages/
21
+ * data-validation/src/controllers/dv-resource.controller.ts:23`) so
22
+ * the rules live-load into the plugin on re-open instead of just
23
+ * sitting in our sidecar — bonus on top of the round-trip.
24
+ */
25
+
26
+ export const DATA_VALIDATION_RESOURCE = 'SHEET_DATA_VALIDATION_PLUGIN';
27
+
28
+ export type SynthDvRule = {
29
+ uid: string;
30
+ type: string; // 'list' | 'whole' | 'decimal' | 'date' | 'time' | 'textLength' | 'custom' | 'any'
31
+ ranges: IRange[];
32
+ formula1?: string;
33
+ formula2?: string;
34
+ operator?: string;
35
+ allowBlank?: boolean;
36
+ error?: string;
37
+ errorTitle?: string;
38
+ showErrorMessage?: boolean;
39
+ errorStyle?: string;
40
+ prompt?: string;
41
+ promptTitle?: string;
42
+ showInputMessage?: boolean;
43
+ };
44
+
45
+ function lettersToCol(letters: string): number {
46
+ let col = 0;
47
+ for (let i = 0; i < letters.length; i++) col = col * 26 + (letters.charCodeAt(i) - 64);
48
+ return col - 1;
49
+ }
50
+
51
+ function colToLetters(n: number): string {
52
+ let out = '';
53
+ let v = n;
54
+ while (v >= 0) {
55
+ out = String.fromCharCode(65 + (v % 26)) + out;
56
+ v = Math.floor(v / 26) - 1;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * Parse a single ExcelJS range token (`'D2:D4'` or `'$A$1'`) into an
63
+ * IRange. Returns null on malformed input — callers should skip.
64
+ */
65
+ function rangeStrToIRange(s: string): IRange | null {
66
+ const part = s.includes('!') ? (s.split('!').pop() ?? s) : s;
67
+ const m = /^\$?([A-Z]+)\$?(\d+)(?::\$?([A-Z]+)\$?(\d+))?$/.exec(part.trim());
68
+ if (!m) return null;
69
+ const c0 = lettersToCol(m[1]);
70
+ const r0 = Number(m[2]) - 1;
71
+ const c1 = m[3] ? lettersToCol(m[3]) : c0;
72
+ const r1 = m[4] ? Number(m[4]) - 1 : r0;
73
+ return { startRow: r0, endRow: r1, startColumn: c0, endColumn: c1 };
74
+ }
75
+
76
+ function iRangeToStr(r: IRange): string {
77
+ const a = `${colToLetters(r.startColumn)}${r.startRow + 1}`;
78
+ const b = `${colToLetters(r.endColumn)}${r.endRow + 1}`;
79
+ return a === b ? a : `${a}:${b}`;
80
+ }
81
+
82
+ /**
83
+ * Walk every worksheet and lift its `dataValidations.model` into the
84
+ * synthesised plugin shape. ExcelJS stores rules keyed by a range
85
+ * string that may be space-separated when a single rule covers
86
+ * multiple ranges; we expand those into the `ranges` array.
87
+ */
88
+ export function readDataValidationFromXlsx(
89
+ wb: ExcelJS.Workbook,
90
+ sheetIdForExcel: (excelId: number) => string,
91
+ ): Record<string, SynthDvRule[]> {
92
+ const out: Record<string, SynthDvRule[]> = {};
93
+ let seq = 0;
94
+ for (const ws of wb.worksheets) {
95
+ const dv = (ws as unknown as { dataValidations?: { model?: Record<string, unknown> } })
96
+ .dataValidations;
97
+ const model = dv?.model;
98
+ if (!model || typeof model !== 'object') continue;
99
+
100
+ const rules: SynthDvRule[] = [];
101
+ for (const [rangeStr, specUnknown] of Object.entries(model)) {
102
+ const spec = specUnknown as Record<string, unknown>;
103
+ if (!spec || typeof spec !== 'object') continue;
104
+ const ranges: IRange[] = [];
105
+ // ExcelJS keys can be `'D2:D4'`, `'D2:D4 F2:F4'`, or comma-joined.
106
+ for (const piece of rangeStr.split(/[\s,]+/)) {
107
+ const r = rangeStrToIRange(piece);
108
+ if (r) ranges.push(r);
109
+ }
110
+ if (ranges.length === 0) continue;
111
+
112
+ const formulae = Array.isArray(spec.formulae) ? (spec.formulae as unknown[]) : undefined;
113
+ const rule: SynthDvRule = {
114
+ uid: `dv-${seq++}`,
115
+ type: typeof spec.type === 'string' ? spec.type : 'any',
116
+ ranges,
117
+ ...(typeof formulae?.[0] === 'string' ? { formula1: formulae[0] } : {}),
118
+ ...(typeof formulae?.[1] === 'string' ? { formula2: formulae[1] } : {}),
119
+ ...(typeof spec.operator === 'string' ? { operator: spec.operator } : {}),
120
+ ...(typeof spec.allowBlank === 'boolean' ? { allowBlank: spec.allowBlank } : {}),
121
+ ...(typeof spec.error === 'string' ? { error: spec.error } : {}),
122
+ ...(typeof spec.errorTitle === 'string' ? { errorTitle: spec.errorTitle } : {}),
123
+ ...(typeof spec.showErrorMessage === 'boolean'
124
+ ? { showErrorMessage: spec.showErrorMessage }
125
+ : {}),
126
+ ...(typeof spec.errorStyle === 'string' ? { errorStyle: spec.errorStyle } : {}),
127
+ ...(typeof spec.prompt === 'string' ? { prompt: spec.prompt } : {}),
128
+ ...(typeof spec.promptTitle === 'string' ? { promptTitle: spec.promptTitle } : {}),
129
+ ...(typeof spec.showInputMessage === 'boolean'
130
+ ? { showInputMessage: spec.showInputMessage }
131
+ : {}),
132
+ };
133
+ rules.push(rule);
134
+ }
135
+ if (rules.length > 0) out[sheetIdForExcel(ws.id)] = rules;
136
+ }
137
+ return out;
138
+ }
139
+
140
+ /**
141
+ * Merge a synthesised DV map into the workbook's `resources` array.
142
+ * Skipped when the workbook already carries the plugin resource (from
143
+ * our hidden sidecar) — that one came through Univer's model and has
144
+ * the full rule shape; xlsx-derived rules are a strict subset.
145
+ */
146
+ export function mergeDataValidationIntoResources(
147
+ resources: IWorkbookData['resources'],
148
+ payload: Record<string, SynthDvRule[]>,
149
+ ): IWorkbookData['resources'] {
150
+ if (Object.keys(payload).length === 0) return resources;
151
+ const existing = resources?.find((r) => r.name === DATA_VALIDATION_RESOURCE);
152
+ if (existing) return resources;
153
+ const next = [...(resources ?? [])];
154
+ next.push({ name: DATA_VALIDATION_RESOURCE, data: JSON.stringify(payload) });
155
+ return next;
156
+ }
157
+
158
+ /** Read the DV resource off a snapshot. Tolerant of older / missing /
159
+ * malformed payloads. */
160
+ export function readDataValidationFromSnapshot(
161
+ data: IWorkbookData,
162
+ ): Record<string, SynthDvRule[]> {
163
+ const entry = data.resources?.find((r) => r.name === DATA_VALIDATION_RESOURCE);
164
+ if (!entry?.data) return {};
165
+ try {
166
+ const parsed = JSON.parse(entry.data) as Record<string, unknown>;
167
+ if (!parsed || typeof parsed !== 'object') return {};
168
+ const out: Record<string, SynthDvRule[]> = {};
169
+ for (const [sheetId, value] of Object.entries(parsed)) {
170
+ if (!Array.isArray(value)) continue;
171
+ const bucket: SynthDvRule[] = [];
172
+ for (const r of value) {
173
+ if (!r || typeof r !== 'object') continue;
174
+ const obj = r as Record<string, unknown>;
175
+ if (!Array.isArray(obj.ranges) || typeof obj.type !== 'string') continue;
176
+ bucket.push(r as SynthDvRule);
177
+ }
178
+ if (bucket.length > 0) out[sheetId] = bucket;
179
+ }
180
+ return out;
181
+ } catch {
182
+ return {};
183
+ }
184
+ }
185
+
186
+ /** Apply a per-sheet rule set onto an ExcelJS worksheet. */
187
+ export function applyDataValidationToXlsxWorksheet(
188
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
189
+ ws: any,
190
+ rules: SynthDvRule[],
191
+ ): void {
192
+ if (!rules?.length) return;
193
+ for (const rule of rules) {
194
+ const formulae: string[] = [];
195
+ if (rule.formula1 !== undefined) formulae.push(rule.formula1);
196
+ if (rule.formula2 !== undefined) formulae.push(rule.formula2);
197
+ const spec: Record<string, unknown> = {
198
+ type: rule.type,
199
+ ...(formulae.length ? { formulae } : {}),
200
+ ...(rule.operator ? { operator: rule.operator } : {}),
201
+ ...(rule.allowBlank !== undefined ? { allowBlank: rule.allowBlank } : {}),
202
+ ...(rule.error !== undefined ? { error: rule.error } : {}),
203
+ ...(rule.errorTitle !== undefined ? { errorTitle: rule.errorTitle } : {}),
204
+ ...(rule.showErrorMessage !== undefined ? { showErrorMessage: rule.showErrorMessage } : {}),
205
+ ...(rule.errorStyle !== undefined ? { errorStyle: rule.errorStyle } : {}),
206
+ ...(rule.prompt !== undefined ? { prompt: rule.prompt } : {}),
207
+ ...(rule.promptTitle !== undefined ? { promptTitle: rule.promptTitle } : {}),
208
+ ...(rule.showInputMessage !== undefined ? { showInputMessage: rule.showInputMessage } : {}),
209
+ };
210
+ for (const range of rule.ranges) {
211
+ try {
212
+ ws.dataValidations.add(iRangeToStr(range), spec);
213
+ } catch {
214
+ // Bad ranges (eg. references into a now-deleted sheet) shouldn't
215
+ // kill the export. Drop the entry quietly; the rest still ship.
216
+ }
217
+ }
218
+ }
219
+ }
@@ -0,0 +1,35 @@
1
+ import type { IWorkbookData } from '@univerjs/core';
2
+ import { timeItAsync } from './_perf';
3
+ import { parseXlsxInWorker } from './parse-in-worker';
4
+
5
+ /**
6
+ * Public entry point for xlsx import. The actual ExcelJS work lives in a
7
+ * Web Worker (`parser.worker.ts` → `parse-impl.ts`) so the main thread
8
+ * stays responsive while a multi-MB workbook is being parsed. This file
9
+ * stays type-only on the main bundle — ExcelJS doesn't get pulled in
10
+ * here.
11
+ *
12
+ * Fidelity scope (MVP):
13
+ * - Values + formulas (cell.value / cell.formula)
14
+ * - Font (family, size, bold, italic, underline, color)
15
+ * - Fill (solid background)
16
+ * - Alignment (horizontal, vertical, wrap)
17
+ * - Number format
18
+ * - Borders (thin, per side, color preserved)
19
+ * - Merges
20
+ * - Sheet order + names
21
+ *
22
+ * Accepts loss: charts, drawings, pivots, validation, conditional formatting,
23
+ * data tables, comments, hyperlinks, advanced borders (dashed/double), themes.
24
+ */
25
+
26
+ /**
27
+ * Workbook data ready to mount. Stage 5 of the pipeline folded
28
+ * hyperlinks into `cell.p.body.customRanges` inline, so no more
29
+ * `__pendingHyperlinks` side-channel — the snapshot is self-contained.
30
+ */
31
+ export type ImportedWorkbook = IWorkbookData;
32
+
33
+ export async function xlsxToWorkbookData(buffer: ArrayBuffer): Promise<ImportedWorkbook> {
34
+ return timeItAsync('parse-xlsx', () => parseXlsxInWorker(buffer));
35
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * xlsx — `.xlsx` ↔ Univer IWorkbookData converters.
3
+ *
4
+ * Phase-A surface (this release): IMPORT only.
5
+ *
6
+ * import { xlsxToWorkbookData } from '@casualoffice/sheets/xlsx';
7
+ *
8
+ * The parser runs in a Web Worker so multi-MB workbooks don't block
9
+ * the main thread; bundler must support the `new Worker(new URL(...),
10
+ * import.meta.url)` pattern (Vite, esbuild's bundler, modern webpack
11
+ * with worker-plugin).
12
+ *
13
+ * Fidelity scope:
14
+ * - Values + formulas
15
+ * - Font (family, size, bold, italic, underline, color)
16
+ * - Fill (solid background)
17
+ * - Alignment (horizontal, vertical, wrap)
18
+ * - Number format
19
+ * - Borders (thin, per side, color preserved)
20
+ * - Merges
21
+ * - Sheet order + names
22
+ * - Tables, comments, data validation, page setup, named ranges (resources)
23
+ *
24
+ * Accepts loss: charts, drawings, pivots, sparklines, advanced borders
25
+ * (dashed/double), themes.
26
+ *
27
+ * The shared utilities below (style mappers + resource readers) are
28
+ * exposed for hosts that ship their own xlsx export path and want to
29
+ * stay in lockstep with this importer's shape. Casual Sheets' own
30
+ * apps/web uses them for that reason. Other consumers can ignore.
31
+ */
32
+
33
+ export { xlsxToWorkbookData, type ImportedWorkbook } from './import';
34
+ export * from './style-mapping';
35
+ export * from './constants';
36
+ export * from './comments-resource';
37
+ export * from './page-setup-resource';
38
+ export * from './data-validation-resource';
39
+ export * from './tables-resource';
40
+ export * from './passthrough-resource';
@@ -0,0 +1,205 @@
1
+ import type ExcelJS from 'exceljs';
2
+ import type { IWorkbookData } from '@univerjs/core';
3
+
4
+ /**
5
+ * Passthrough preservation for xlsx-native page-setup chrome —
6
+ * orientation, paper size, margins, header/footer text. Univer
7
+ * doesn't model any of this; the print dialog uses a per-user
8
+ * localStorage slot. Without a passthrough, opening an xlsx that
9
+ * was authored to print landscape and saving it again silently
10
+ * resets to portrait.
11
+ *
12
+ * The round-trip stays inside our own resource sidecar
13
+ * (`__casual_sheets_page_setup__`) — the data never reaches Univer
14
+ * for editing; the parser stashes it on `IWorkbookData.resources`
15
+ * and the exporter reads it back and applies to ExcelJS's native
16
+ * `worksheet.pageSetup` + `worksheet.headerFooter`.
17
+ *
18
+ * If/when we add an in-app Page Setup editor, this resource is the
19
+ * authoritative store — the localStorage fallback is per-user and
20
+ * can't survive Save → Open in another tab.
21
+ */
22
+
23
+ export const PAGE_SETUP_RESOURCE = '__casual_sheets_page_setup__';
24
+
25
+ export type SheetPageSetupV1 = {
26
+ orientation?: 'landscape' | 'portrait';
27
+ paperSize?: number;
28
+ fitToPage?: boolean;
29
+ fitToWidth?: number;
30
+ fitToHeight?: number;
31
+ scale?: number;
32
+ printArea?: string;
33
+ margins?: {
34
+ top?: number;
35
+ bottom?: number;
36
+ left?: number;
37
+ right?: number;
38
+ header?: number;
39
+ footer?: number;
40
+ };
41
+ headerFooter?: {
42
+ oddHeader?: string;
43
+ oddFooter?: string;
44
+ evenHeader?: string;
45
+ evenFooter?: string;
46
+ differentFirst?: boolean;
47
+ differentOddEven?: boolean;
48
+ };
49
+ };
50
+
51
+ type Payload = {
52
+ v: 1;
53
+ sheets: Record<string, SheetPageSetupV1>;
54
+ };
55
+
56
+ function isLandscape(o: unknown): o is 'landscape' {
57
+ return o === 'landscape';
58
+ }
59
+
60
+ // ExcelJS materialises a default page-setup + margins object on every
61
+ // worksheet during load, even when the source xlsx never authored one.
62
+ // Capturing those defaults would write a `__casual_sheets_page_setup__`
63
+ // resource to every workbook that opens here — visible noise in
64
+ // snapshot diffs and a failing assertion in `xlsx-hyperlinks.spec.ts`'s
65
+ // resources round-trip probe. Drop them so the sidecar only appears
66
+ // when the user (or the source xlsx) authored a non-default value.
67
+ const PAGE_SETUP_DEFAULTS = {
68
+ orientation: 'portrait' as const,
69
+ fitToPage: false,
70
+ fitToWidth: 1,
71
+ fitToHeight: 1,
72
+ scale: 100,
73
+ };
74
+ const MARGIN_DEFAULTS = {
75
+ top: 0.75,
76
+ bottom: 0.75,
77
+ left: 0.7,
78
+ right: 0.7,
79
+ header: 0.3,
80
+ footer: 0.3,
81
+ };
82
+
83
+ /**
84
+ * Walk the xlsx worksheets and pull each one's page-setup + header
85
+ * /footer into a sidecar payload. Empty entries are dropped so the
86
+ * resource isn't written for files that didn't carry any chrome.
87
+ */
88
+ export function readPageSetupFromXlsx(
89
+ wb: ExcelJS.Workbook,
90
+ sheetIdForExcel: (excelId: number) => string,
91
+ ): Record<string, SheetPageSetupV1> {
92
+ const out: Record<string, SheetPageSetupV1> = {};
93
+ for (const ws of wb.worksheets) {
94
+ const ps = (ws as unknown as { pageSetup?: Record<string, unknown> }).pageSetup;
95
+ const hf = (ws as unknown as { headerFooter?: Record<string, unknown> }).headerFooter;
96
+ const entry: SheetPageSetupV1 = {};
97
+
98
+ if (ps && typeof ps === 'object') {
99
+ // Only `landscape` matters — `portrait` is the xlsx default and
100
+ // would otherwise tag every workbook with a sidecar.
101
+ if (isLandscape(ps.orientation)) entry.orientation = 'landscape';
102
+ if (typeof ps.paperSize === 'number') entry.paperSize = ps.paperSize;
103
+ if (ps.fitToPage === true) entry.fitToPage = true;
104
+ if (typeof ps.fitToWidth === 'number' && ps.fitToWidth !== PAGE_SETUP_DEFAULTS.fitToWidth) {
105
+ entry.fitToWidth = ps.fitToWidth as number;
106
+ }
107
+ if (typeof ps.fitToHeight === 'number' && ps.fitToHeight !== PAGE_SETUP_DEFAULTS.fitToHeight) {
108
+ entry.fitToHeight = ps.fitToHeight as number;
109
+ }
110
+ if (typeof ps.scale === 'number' && ps.scale !== PAGE_SETUP_DEFAULTS.scale) {
111
+ entry.scale = ps.scale as number;
112
+ }
113
+ if (typeof ps.printArea === 'string') entry.printArea = ps.printArea as string;
114
+ const margins = ps.margins as Record<string, unknown> | undefined;
115
+ if (margins && typeof margins === 'object') {
116
+ const m: SheetPageSetupV1['margins'] = {};
117
+ for (const k of ['top', 'bottom', 'left', 'right', 'header', 'footer'] as const) {
118
+ const v = margins[k];
119
+ if (typeof v === 'number' && v !== MARGIN_DEFAULTS[k]) m[k] = v;
120
+ }
121
+ if (Object.keys(m).length > 0) entry.margins = m;
122
+ }
123
+ }
124
+
125
+ if (hf && typeof hf === 'object') {
126
+ const headerFooter: SheetPageSetupV1['headerFooter'] = {};
127
+ for (const k of ['oddHeader', 'oddFooter', 'evenHeader', 'evenFooter'] as const) {
128
+ const v = hf[k];
129
+ if (typeof v === 'string' && v.length > 0) headerFooter[k] = v as string;
130
+ }
131
+ if (hf.differentFirst === true) headerFooter.differentFirst = true;
132
+ if (hf.differentOddEven === true) headerFooter.differentOddEven = true;
133
+ if (Object.keys(headerFooter).length > 0) entry.headerFooter = headerFooter;
134
+ }
135
+
136
+ if (Object.keys(entry).length > 0) {
137
+ out[sheetIdForExcel(ws.id)] = entry;
138
+ }
139
+ }
140
+ return out;
141
+ }
142
+
143
+ /** Merge a synthesised page-setup map into the snapshot resources. */
144
+ export function mergePageSetupIntoResources(
145
+ resources: IWorkbookData['resources'],
146
+ payload: Record<string, SheetPageSetupV1>,
147
+ ): IWorkbookData['resources'] {
148
+ if (Object.keys(payload).length === 0) return resources;
149
+ const existing = resources?.find((r) => r.name === PAGE_SETUP_RESOURCE);
150
+ if (existing) return resources;
151
+ const body: Payload = { v: 1, sheets: payload };
152
+ const next = [...(resources ?? [])];
153
+ next.push({ name: PAGE_SETUP_RESOURCE, data: JSON.stringify(body) });
154
+ return next;
155
+ }
156
+
157
+ /** Read the page-setup resource off a snapshot. Tolerant of older /
158
+ * missing / malformed payloads. */
159
+ export function readPageSetupFromSnapshot(
160
+ data: IWorkbookData,
161
+ ): Record<string, SheetPageSetupV1> {
162
+ const entry = data.resources?.find((r) => r.name === PAGE_SETUP_RESOURCE);
163
+ if (!entry?.data) return {};
164
+ try {
165
+ const parsed = JSON.parse(entry.data) as Partial<Payload>;
166
+ if (parsed?.v !== 1 || !parsed.sheets) return {};
167
+ return parsed.sheets;
168
+ } catch {
169
+ return {};
170
+ }
171
+ }
172
+
173
+ /** Apply a per-sheet entry onto an ExcelJS worksheet. */
174
+ export function applyPageSetupToXlsxWorksheet(
175
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
176
+ ws: any,
177
+ entry: SheetPageSetupV1,
178
+ ): void {
179
+ if (!entry) return;
180
+ ws.pageSetup = ws.pageSetup ?? {};
181
+ if (entry.orientation) ws.pageSetup.orientation = entry.orientation;
182
+ if (typeof entry.paperSize === 'number') ws.pageSetup.paperSize = entry.paperSize;
183
+ if (typeof entry.fitToPage === 'boolean') ws.pageSetup.fitToPage = entry.fitToPage;
184
+ if (typeof entry.fitToWidth === 'number') ws.pageSetup.fitToWidth = entry.fitToWidth;
185
+ if (typeof entry.fitToHeight === 'number') ws.pageSetup.fitToHeight = entry.fitToHeight;
186
+ if (typeof entry.scale === 'number') ws.pageSetup.scale = entry.scale;
187
+ if (typeof entry.printArea === 'string') ws.pageSetup.printArea = entry.printArea;
188
+ if (entry.margins) {
189
+ ws.pageSetup.margins = { ...(ws.pageSetup.margins ?? {}), ...entry.margins };
190
+ }
191
+
192
+ if (entry.headerFooter) {
193
+ ws.headerFooter = ws.headerFooter ?? {};
194
+ for (const k of ['oddHeader', 'oddFooter', 'evenHeader', 'evenFooter'] as const) {
195
+ const v = entry.headerFooter[k];
196
+ if (typeof v === 'string') ws.headerFooter[k] = v;
197
+ }
198
+ if (typeof entry.headerFooter.differentFirst === 'boolean') {
199
+ ws.headerFooter.differentFirst = entry.headerFooter.differentFirst;
200
+ }
201
+ if (typeof entry.headerFooter.differentOddEven === 'boolean') {
202
+ ws.headerFooter.differentOddEven = entry.headerFooter.differentOddEven;
203
+ }
204
+ }
205
+ }