werkmap 0.3.0 → 0.4.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.
- package/README.md +23 -3
- package/lib/index.d.ts +26 -0
- package/lib/index.js +161 -65
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,10 +6,10 @@ Writing is the whole surface, so the package stays small enough to audit. The co
|
|
|
6
6
|
|
|
7
7
|
- **Byte-identical output.** Nothing consults a clock, a locale or a random source, and the ZIP epoch is pinned, so two renders of the same report are the same bytes and a build that caches by content hash keeps working.
|
|
8
8
|
- **Foreign readers accept it.** The suite loads every workbook it writes back through ExcelJS, an independent implementation — so the tests prove a real reader opens the file, not just that the bytes look plausible.
|
|
9
|
-
- **Zero dependencies.**
|
|
9
|
+
- **Zero dependencies.** 7.0 kB minified and brotlied, for the whole writer.
|
|
10
10
|
- **Strict CSP, including in the browser.** No `unsafe-eval` and no `unsafe-inline`. A Chromium page under `default-src 'none'; script-src 'self'` writes a workbook and reports any violation back, and the Node suite runs on `--disallow-code-generation-from-strings`.
|
|
11
11
|
- **Write-only, deliberately.** No reader, no formula engine, no chart support — see [Is werkmap the right tool?](#is-werkmap-the-right-tool) before you install it.
|
|
12
|
-
- **Hardened.**
|
|
12
|
+
- **Hardened.** 72 tests at 100% branch coverage.
|
|
13
13
|
|
|
14
14
|
```js
|
|
15
15
|
import { workbook } from "werkmap";
|
|
@@ -89,7 +89,7 @@ werkmap writes the slice of the format a report needs, and refuses the rest.
|
|
|
89
89
|
|
|
90
90
|
- You need to read, edit or convert an existing workbook.
|
|
91
91
|
- You need formulas, charts, pivot tables, conditional formatting, data validation, hyperlinks, comments, sheet protection, or a page header and footer.
|
|
92
|
-
- You need
|
|
92
|
+
- You need row heights. A reader sizes rows from its own defaults. Column widths it does write — see `sheet.widths`.
|
|
93
93
|
- You have more rows than fit in memory. There is no streaming, row-at-a-time output.
|
|
94
94
|
|
|
95
95
|
## API
|
|
@@ -141,6 +141,26 @@ One merged range across `width` columns of `row`, starting at the 1-based column
|
|
|
141
141
|
|
|
142
142
|
Freeze the top `rows` rows. `0` clears.
|
|
143
143
|
|
|
144
|
+
### `sheet.widths(list)`
|
|
145
|
+
|
|
146
|
+
Column widths by position: the first entry is column A, and `null` leaves a column unset so a reader keeps its own default for it.
|
|
147
|
+
|
|
148
|
+
The unit is the format's own — **a count of characters of the workbook's default font**, which is the number Excel's column-width box shows. A width is above 0 and at most 255. Pixels would read more naturally beside `place`, but converting them runs through the default font's maximum digit width, and any cell here may name a font of its own, so that constant would be wrong for most workbooks. The awkward unit invents no number.
|
|
149
|
+
|
|
150
|
+
The list **replaces** rather than merging, and an empty list clears — a hole in a positional list cannot mean both "leave this one alone" and "clear it". Call it before or after the rows; a width never widens the sheet, because `dimension` describes the cells that were written and sizing a column writes no cell. A worksheet that never calls this carries **no `cols` element at all**.
|
|
151
|
+
|
|
152
|
+
What is written is the number you gave, verbatim. Excel may report a slightly different one after a round trip, because it re-derives a width from the default font's digit width — that is the reader's arithmetic, not this writer's.
|
|
153
|
+
|
|
154
|
+
There is no `hidden`, no outline level and no per-column style: a zero width is the back door to a hidden column, so `0` throws and points at `null`.
|
|
155
|
+
|
|
156
|
+
### `sheet.filter(range)`
|
|
157
|
+
|
|
158
|
+
Put an autofilter over `{ top, left, bottom, right }` — 1-based, and inclusive on all four sides. It adds the dropdown controls and **hides nothing**: every row you wrote is still in the document, and what the reader does with the control is theirs.
|
|
159
|
+
|
|
160
|
+
A worksheet takes one, so a second call **replaces** the first, and `null` clears it. The range must already have been written — its bottom row and its rightmost column both, the way a merge's row must — because a filter over cells nobody wrote is a caller's mistake rather than an empty range. Both are checked when you call, so write the rows first. A worksheet that never calls this carries **no `autoFilter` element at all**.
|
|
161
|
+
|
|
162
|
+
Excel also records an autofilter as a sheet-scoped `_xlnm._FilterDatabase` defined name, and this writes none. That is measured rather than assumed, in both readers: Excel opens a file carrying only the element, with no repair prompt, and reports the sheet's autofilter as on; LibreOffice opens it, keeps the filter, and writes that name itself on save. The name is a reader's bookkeeping rather than something a file owes.
|
|
163
|
+
|
|
144
164
|
### `sheet.print(setup)`
|
|
145
165
|
|
|
146
166
|
How this worksheet prints: `{ margin, size, orientation, fit, titles }`, every key optional. `margin` is all four page margins in points, `size` one of `letter`, `tabloid`, `legal`, `A3`, `A4`, `A5`, `orientation` either `portrait` or `landscape`, and `fit: true` scales the sheet to one page wide and as many pages tall as it takes.
|
package/lib/index.d.ts
CHANGED
|
@@ -91,6 +91,14 @@ export interface PrintSetup {
|
|
|
91
91
|
titles?: number;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** The rectangle an autofilter covers. 1-based, and inclusive on all four sides. */
|
|
95
|
+
export interface FilterRange {
|
|
96
|
+
top: number;
|
|
97
|
+
left: number;
|
|
98
|
+
bottom: number;
|
|
99
|
+
right: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
94
102
|
export interface Sheet {
|
|
95
103
|
/** This sheet's name. */
|
|
96
104
|
readonly name: string;
|
|
@@ -106,12 +114,30 @@ export interface Sheet {
|
|
|
106
114
|
merge(row: number, at: number, width: number): void;
|
|
107
115
|
/** Freeze the top `count` rows. `0` clears. */
|
|
108
116
|
freeze(count: number): void;
|
|
117
|
+
/**
|
|
118
|
+
* Column widths by position: the first entry is column A, and `null` leaves
|
|
119
|
+
* a column unset so a reader keeps its own default for it.
|
|
120
|
+
*
|
|
121
|
+
* The unit is the format's own — a count of characters of the workbook's
|
|
122
|
+
* default font, the number Excel's column-width box shows. A width is above
|
|
123
|
+
* 0 and at most 255.
|
|
124
|
+
*
|
|
125
|
+
* Replaces rather than merges, and an empty list clears.
|
|
126
|
+
*/
|
|
127
|
+
widths(list: readonly (number | null)[]): void;
|
|
109
128
|
/**
|
|
110
129
|
* How this worksheet prints. A worksheet that never calls this carries no
|
|
111
130
|
* print setup at all, so a reader applies its own defaults. Calls merge, so
|
|
112
131
|
* two calls naming different keys both take effect.
|
|
113
132
|
*/
|
|
114
133
|
print(setup: PrintSetup): void;
|
|
134
|
+
/**
|
|
135
|
+
* Put an autofilter over a range, 1-based and inclusive on all four sides.
|
|
136
|
+
* A worksheet takes one, so this replaces rather than merges, and `null`
|
|
137
|
+
* clears it. The range's bottom row and rightmost column must already have
|
|
138
|
+
* been written, checked when you call.
|
|
139
|
+
*/
|
|
140
|
+
filter(range: FilterRange | null): void;
|
|
115
141
|
/** Float a picture over the sheet, anchored to one cell. */
|
|
116
142
|
place(id: number, at: Placement): void;
|
|
117
143
|
}
|
package/lib/index.js
CHANGED
|
@@ -1,18 +1,7 @@
|
|
|
1
1
|
// werkmap — a write-only OOXML (.xlsx) writer.
|
|
2
2
|
//
|
|
3
|
-
// One entry point, `workbook`, returning a
|
|
4
|
-
//
|
|
5
|
-
// string into code: the package runs under a Content Security Policy that
|
|
6
|
-
// permits no string-to-code execution, and its suite runs Node with
|
|
7
|
-
// `--disallow-code-generation-from-strings`. The source itself is scanned for
|
|
8
|
-
// the two constructs, so the words naming them appear nowhere below.
|
|
9
|
-
//
|
|
10
|
-
// Two promises shape almost every decision below. The same calls produce the
|
|
11
|
-
// same bytes on the same runtime, so nothing consults a clock, a locale or a
|
|
12
|
-
// random source, and every table is written in a fixed order. And every
|
|
13
|
-
// element is emitted in the Open XML SDK's own child sequence, which costs
|
|
14
|
-
// nothing here — the parts are assembled as strings in one pass — and removes
|
|
15
|
-
// the ordering risk in readers whose tolerance nobody has measured.
|
|
3
|
+
// One entry point, `workbook`, returning a mutable builder whose only terminal
|
|
4
|
+
// is `bytes()`. Nothing here reads a .xlsx file.
|
|
16
5
|
|
|
17
6
|
// ---------------------------------------------------------------- ZIP ----
|
|
18
7
|
|
|
@@ -150,10 +139,8 @@ const REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships
|
|
|
150
139
|
const CT = "application/vnd.openxmlformats-officedocument";
|
|
151
140
|
|
|
152
141
|
// What XML 1.0 cannot carry at all: the C0 range except TAB, LF and CR, and
|
|
153
|
-
// unpaired surrogates.
|
|
154
|
-
//
|
|
155
|
-
// a whole document over one stray byte in a customer's name is the wrong
|
|
156
|
-
// trade — this is the one place leniency belongs.
|
|
142
|
+
// unpaired surrogates. Stripped rather than thrown over — the one place
|
|
143
|
+
// leniency belongs.
|
|
157
144
|
const FORBIDDEN =
|
|
158
145
|
// This expression exists to find exactly the control characters the rule
|
|
159
146
|
// below warns about, which is the one case where it is wrong.
|
|
@@ -175,9 +162,8 @@ const esc = (text) =>
|
|
|
175
162
|
.replace(/>/g, ">")
|
|
176
163
|
.replace(/"/g, """);
|
|
177
164
|
|
|
178
|
-
// `xml:space="preserve"` goes on every `<t
|
|
179
|
-
//
|
|
180
|
-
// trust than a predicate over whitespace.
|
|
165
|
+
// `xml:space="preserve"` goes on every `<t>`, not only where the text needs
|
|
166
|
+
// it: one rule is easier to trust than a predicate over whitespace.
|
|
181
167
|
/** @param {string} text */
|
|
182
168
|
const textNode = (text) => `<t xml:space="preserve">${esc(text)}</t>`;
|
|
183
169
|
|
|
@@ -282,14 +268,10 @@ const richKey = (runs, where) => {
|
|
|
282
268
|
|
|
283
269
|
// ------------------------------------------------------------- values ----
|
|
284
270
|
|
|
285
|
-
// Excel's 1900 date system counts from an epoch of 1899-12-30,
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
// The phantom day is real for serial 60 and nothing else. Excel shows 60 as
|
|
290
|
-
// 1900-02-29, a day that never happened, so every date before 1900-03-01 sits
|
|
291
|
-
// one lower than the 1899-12-30 epoch alone would put it. A writer that skips
|
|
292
|
-
// this arithmetic moves 1900-02-28 onto the phantom day.
|
|
271
|
+
// Excel's 1900 date system counts from an epoch of 1899-12-30, in UTC. The
|
|
272
|
+
// phantom 1900-02-29 is serial 60, so every date before 1900-03-01 sits one
|
|
273
|
+
// lower than that epoch alone would put it: skip this arithmetic and
|
|
274
|
+
// 1900-02-28 lands on the phantom day.
|
|
293
275
|
const EPOCH_OFFSET = 25569;
|
|
294
276
|
const DAY = 86_400_000;
|
|
295
277
|
const PHANTOM = 61;
|
|
@@ -376,8 +358,7 @@ const FIRST_CUSTOM = 164;
|
|
|
376
358
|
|
|
377
359
|
/**
|
|
378
360
|
* Interning by a canonical key, preserving first-seen order. Every style
|
|
379
|
-
* table
|
|
380
|
-
* fills, borders and formats collapse to one entry each.
|
|
361
|
+
* table is one of these.
|
|
381
362
|
*/
|
|
382
363
|
/**
|
|
383
364
|
* @template T
|
|
@@ -395,8 +376,7 @@ const table = (initial) => {
|
|
|
395
376
|
return {
|
|
396
377
|
items,
|
|
397
378
|
/**
|
|
398
|
-
* Arrow rather than a method, so `strings` below can pass it on by name
|
|
399
|
-
* it closes over the table and never touches `this`.
|
|
379
|
+
* Arrow rather than a method, so `strings` below can pass it on by name.
|
|
400
380
|
* @param {string} key
|
|
401
381
|
* @param {() => T} make
|
|
402
382
|
*/
|
|
@@ -543,10 +523,9 @@ const xfXml = (xf) => {
|
|
|
543
523
|
};
|
|
544
524
|
|
|
545
525
|
/**
|
|
546
|
-
* The whole of `styles.xml
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
* because readers index fills by position.
|
|
526
|
+
* The whole of `styles.xml`. Entry 0 of each table is the default every
|
|
527
|
+
* unstyled cell points at, and the two fills OOXML reserves (`none`,
|
|
528
|
+
* `gray125`) are always written, because readers index fills by position.
|
|
550
529
|
*/
|
|
551
530
|
const stylesheet = () => {
|
|
552
531
|
const numFmts = new Map();
|
|
@@ -656,10 +635,7 @@ const stylesheet = () => {
|
|
|
656
635
|
|
|
657
636
|
// ------------------------------------------------------ shared strings ----
|
|
658
637
|
|
|
659
|
-
/**
|
|
660
|
-
* Text is always interned here rather than written inline. It is what Excel
|
|
661
|
-
* itself writes, and a report's repeated labels are what it shrinks.
|
|
662
|
-
*/
|
|
638
|
+
/** Text is always interned here rather than written inline. */
|
|
663
639
|
const strings = () => {
|
|
664
640
|
// The same interning the style tables use: one `si` per distinct piece of
|
|
665
641
|
// text, in first-seen order.
|
|
@@ -703,9 +679,8 @@ const FURNITURE = 0.3;
|
|
|
703
679
|
|
|
704
680
|
/**
|
|
705
681
|
* `pageMargins` and `pageSetup`, or nothing at all. `pageMargins` has no
|
|
706
|
-
* optional attributes, so
|
|
707
|
-
*
|
|
708
|
-
* footer.
|
|
682
|
+
* optional attributes, so any margin means writing all six; the two this
|
|
683
|
+
* surface does not take keep Excel's own header and footer gap.
|
|
709
684
|
*
|
|
710
685
|
* @param {{ margin?: number, size?: string, orientation?: string, fit?: boolean, titles?: number } | null} setup
|
|
711
686
|
*/
|
|
@@ -746,6 +721,12 @@ const NAME_LIMIT = 31;
|
|
|
746
721
|
const MAX_ROW = 1_048_576;
|
|
747
722
|
const MAX_COLUMN = 16_384;
|
|
748
723
|
|
|
724
|
+
// Excel's ceiling for a column width, in the unit the format states it: a
|
|
725
|
+
// count of characters of the default font, the number Excel's own width box
|
|
726
|
+
// shows. Not pixels: that conversion runs through the default font's maximum
|
|
727
|
+
// digit width, and any cell here may name a font of its own.
|
|
728
|
+
const MAX_WIDTH = 255;
|
|
729
|
+
|
|
749
730
|
// EMU per CSS pixel at 96 dpi.
|
|
750
731
|
const EMU = 9525;
|
|
751
732
|
|
|
@@ -838,6 +819,64 @@ const rangeRef = (range) =>
|
|
|
838
819
|
const overlaps = (a, b) =>
|
|
839
820
|
a.top <= b.bottom && a.bottom >= b.top && a.left <= b.right && a.right >= b.left;
|
|
840
821
|
|
|
822
|
+
/**
|
|
823
|
+
* A `<col>` per column that was given a width, in column order, and nothing
|
|
824
|
+
* at all when none was. One element apiece rather than a `min`/`max` run: a
|
|
825
|
+
* run may never span a column left unset, and the sparse case breaks that
|
|
826
|
+
* first, for bytes on one of the smallest parts here.
|
|
827
|
+
*
|
|
828
|
+
* `customWidth` is not optional. Without it a reader treats the width as one
|
|
829
|
+
* it derived, and is free to compute its own instead.
|
|
830
|
+
*
|
|
831
|
+
* @param {ReadonlyArray<number | null | undefined>} columns
|
|
832
|
+
*/
|
|
833
|
+
const colsXml = (columns) => {
|
|
834
|
+
const body = columns
|
|
835
|
+
.map((width, index) =>
|
|
836
|
+
absent(width)
|
|
837
|
+
? ""
|
|
838
|
+
: `<col min="${index + 1}" max="${index + 1}" width="${number(width)}" customWidth="1"/>`,
|
|
839
|
+
)
|
|
840
|
+
.join("");
|
|
841
|
+
return body === "" ? "" : `<cols>${body}</cols>`;
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
// One rule of a filter's range, stated the way a caller reads it.
|
|
845
|
+
/** @param {boolean} ok @param {string} why */
|
|
846
|
+
const requireFilterRule = (ok, why) => {
|
|
847
|
+
if (!ok) throw RangeError(`filter: ${why}`);
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
// A range may only cover cells that were written. `merge` and `filter` both
|
|
851
|
+
// ask it, of a row and of a column.
|
|
852
|
+
/** @param {number} at @param {number} written @param {string} what @param {string} where */
|
|
853
|
+
const requireWritten = (at, written, what, where) => {
|
|
854
|
+
if (at > written)
|
|
855
|
+
throw RangeError(`${where}: ${what} ${at} does not exist yet (the sheet has ${written})`);
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* The rectangle an autofilter covers, 1-based and inclusive on all four
|
|
860
|
+
* sides. A range whose bottom row was never written is a caller's bug, so
|
|
861
|
+
* this takes the row count to check it against.
|
|
862
|
+
*
|
|
863
|
+
* @param {unknown} range
|
|
864
|
+
* @param {number} rows how many rows the sheet holds
|
|
865
|
+
* @param {number} columns how many columns it has written
|
|
866
|
+
*/
|
|
867
|
+
const requireFilter = (range, rows, columns) => {
|
|
868
|
+
const rect = requireRecord(range, "filter", "{ top, left, bottom, right }");
|
|
869
|
+
const top = requireIndex(rect.top, MAX_ROW, "filter: top");
|
|
870
|
+
const left = requireIndex(rect.left, MAX_COLUMN, "filter: left");
|
|
871
|
+
const bottom = requireIndex(rect.bottom, MAX_ROW, "filter: bottom");
|
|
872
|
+
const right = requireIndex(rect.right, MAX_COLUMN, "filter: right");
|
|
873
|
+
requireFilterRule(bottom >= top, `bottom ${bottom} is above top ${top}`);
|
|
874
|
+
requireFilterRule(right >= left, `right ${right} is left of left ${left}`);
|
|
875
|
+
requireWritten(bottom, rows, "row", "filter");
|
|
876
|
+
requireWritten(right, columns, "column", "filter");
|
|
877
|
+
return { top, left, bottom, right };
|
|
878
|
+
};
|
|
879
|
+
|
|
841
880
|
/** @param {ReadonlyArray<Range>} merges */
|
|
842
881
|
const mergesXml = (merges) =>
|
|
843
882
|
merges.length === 0
|
|
@@ -860,6 +899,23 @@ const mergeEnd = (width, at) => {
|
|
|
860
899
|
return right;
|
|
861
900
|
};
|
|
862
901
|
|
|
902
|
+
/**
|
|
903
|
+
* One entry of a width list. Absent is how a caller leaves a column alone, so
|
|
904
|
+
* it is the one value that checks nothing.
|
|
905
|
+
*
|
|
906
|
+
* @param {unknown} width
|
|
907
|
+
* @param {number} index 0-based, so the message can name the column
|
|
908
|
+
*/
|
|
909
|
+
const checkWidth = (width, index) => {
|
|
910
|
+
if (absent(width)) return;
|
|
911
|
+
const chars = requireNumber(width, `widths: column ${index + 1}`);
|
|
912
|
+
if (chars > 0 && chars <= MAX_WIDTH) return;
|
|
913
|
+
throw RangeError(
|
|
914
|
+
`widths: column ${index + 1} expected a width above 0 and at most ${MAX_WIDTH}, got ${chars}` +
|
|
915
|
+
` -- null leaves a column unset`,
|
|
916
|
+
);
|
|
917
|
+
};
|
|
918
|
+
|
|
863
919
|
/** @param {unknown} margin */
|
|
864
920
|
const requireMargin = (margin) => {
|
|
865
921
|
const points = requireNumber(margin, "print: margin");
|
|
@@ -874,9 +930,8 @@ const requirePaper = (size) => {
|
|
|
874
930
|
);
|
|
875
931
|
};
|
|
876
932
|
|
|
877
|
-
// Zero clears,
|
|
878
|
-
//
|
|
879
|
-
// its message names 1 as the floor, and here the floor is 0.
|
|
933
|
+
// Zero clears, as it does for a freeze, so a caller that set a count has a way
|
|
934
|
+
// back. Not `requireIndex`: its message names 1 as the floor, and here it is 0.
|
|
880
935
|
/** @param {unknown} rows */
|
|
881
936
|
const requireTitles = (rows) => {
|
|
882
937
|
if (rows === 0) return;
|
|
@@ -898,12 +953,10 @@ const requireSize = (width, height) => {
|
|
|
898
953
|
throw RangeError(`place: expected a positive size, got ${width} by ${height}`);
|
|
899
954
|
};
|
|
900
955
|
|
|
901
|
-
// Every print key this surface checks, and the check it gets.
|
|
902
|
-
// order a caller hears about a mistake
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
// to; the table keeps the branch count flat as keys are added. `fit` is absent
|
|
906
|
-
// because only `true` writes anything, so no value of it is a mistake.
|
|
956
|
+
// Every print key this surface checks, and the check it gets. Key order is
|
|
957
|
+
// the order a caller hears about a mistake. A table rather than a run of
|
|
958
|
+
// guards: a guard apiece puts `print` over the cyclomatic ceiling. `fit` is
|
|
959
|
+
// absent because only `true` writes anything.
|
|
907
960
|
const PRINT_CHECKS = {
|
|
908
961
|
margin: requireMargin,
|
|
909
962
|
size: requirePaper,
|
|
@@ -929,6 +982,14 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
929
982
|
const pictures = [];
|
|
930
983
|
let frozen = 0;
|
|
931
984
|
let widest = 1;
|
|
985
|
+
// This sheet's `autoFilter` element, or the empty string for a sheet that
|
|
986
|
+
// asked for none. Rendered where it is set, the way a row is.
|
|
987
|
+
let filtered = "";
|
|
988
|
+
// Widths by position, the first entry being column A. Replaced wholesale by
|
|
989
|
+
// `widths`, never merged: a hole in a positional list cannot mean both
|
|
990
|
+
// "leave this one alone" and "clear it".
|
|
991
|
+
/** @type {ReadonlyArray<number | null | undefined>} */
|
|
992
|
+
let columns = [];
|
|
932
993
|
// What `print` was told, or null. Nothing reaches the file until a caller
|
|
933
994
|
// asks: a reader's own print defaults are better than this writer guessing.
|
|
934
995
|
/** @type {{ margin?: number, size?: string, orientation?: string, fit?: boolean, titles?: number } | null} */
|
|
@@ -977,8 +1038,7 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
977
1038
|
merge(row, at, width) {
|
|
978
1039
|
requireIndex(row, MAX_ROW, "merge: row");
|
|
979
1040
|
requireIndex(at, MAX_COLUMN, "merge: at");
|
|
980
|
-
|
|
981
|
-
throw RangeError(`merge: row ${row} does not exist yet (the sheet has ${rows.length})`);
|
|
1041
|
+
requireWritten(row, rows.length, "row", "merge");
|
|
982
1042
|
const range = { top: row, left: at, bottom: row, right: mergeEnd(width, at) };
|
|
983
1043
|
const clash = merges.find((other) => overlaps(range, other));
|
|
984
1044
|
if (clash !== undefined)
|
|
@@ -989,9 +1049,8 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
989
1049
|
|
|
990
1050
|
/**
|
|
991
1051
|
* How this worksheet prints. Every key is optional, and a worksheet that
|
|
992
|
-
* never calls this carries no print setup at all
|
|
993
|
-
*
|
|
994
|
-
* in every file.
|
|
1052
|
+
* never calls this carries no print setup at all: a reader's own defaults
|
|
1053
|
+
* beat a guess.
|
|
995
1054
|
*
|
|
996
1055
|
* @param {{ margin?: number, size?: string, orientation?: string, fit?: boolean, titles?: number }} setup
|
|
997
1056
|
*/
|
|
@@ -1015,6 +1074,43 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
1015
1074
|
frozen = count;
|
|
1016
1075
|
},
|
|
1017
1076
|
|
|
1077
|
+
/**
|
|
1078
|
+
* Column widths by position, the first entry being column A. `null` leaves
|
|
1079
|
+
* a column unset, so a reader keeps its own default for it.
|
|
1080
|
+
*
|
|
1081
|
+
* Replaces rather than merges, and an empty list clears. A width never
|
|
1082
|
+
* widens the sheet: `dimension` describes the cells that were written, and
|
|
1083
|
+
* sizing a column writes no cell.
|
|
1084
|
+
*
|
|
1085
|
+
* @param {ReadonlyArray<unknown>} list
|
|
1086
|
+
*/
|
|
1087
|
+
widths(list) {
|
|
1088
|
+
if (!Array.isArray(list))
|
|
1089
|
+
throw TypeError(`widths: expected an array of widths, got ${JSON.stringify(list)}`);
|
|
1090
|
+
if (list.length > MAX_COLUMN)
|
|
1091
|
+
throw RangeError(`widths: a sheet holds at most ${MAX_COLUMN} columns`);
|
|
1092
|
+
list.forEach(checkWidth);
|
|
1093
|
+
columns = list.slice();
|
|
1094
|
+
},
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Put an autofilter over a range, 1-based and inclusive. A worksheet takes
|
|
1098
|
+
* one, so this replaces rather than merges, and `null` clears it.
|
|
1099
|
+
*
|
|
1100
|
+
* @param {unknown} range
|
|
1101
|
+
*/
|
|
1102
|
+
filter(range) {
|
|
1103
|
+
// Excel also records an autofilter as a sheet-scoped
|
|
1104
|
+
// `_xlnm._FilterDatabase` defined name; this writes none. Measured in
|
|
1105
|
+
// both readers: Excel opens such a file with no repair prompt and
|
|
1106
|
+
// reports the filter as on, and LibreOffice writes the name itself on
|
|
1107
|
+
// save. The name is a reader's bookkeeping.
|
|
1108
|
+
filtered =
|
|
1109
|
+
range === null
|
|
1110
|
+
? ""
|
|
1111
|
+
: `<autoFilter ref="${rangeRef(requireFilter(range, rows.length, widest))}"/>`;
|
|
1112
|
+
},
|
|
1113
|
+
|
|
1018
1114
|
/**
|
|
1019
1115
|
* @param {unknown} id
|
|
1020
1116
|
* @param {{ row: number, col?: number, width: number, height: number }} at
|
|
@@ -1049,7 +1145,9 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
1049
1145
|
properties +
|
|
1050
1146
|
`<dimension ref="${dimension}"/>` +
|
|
1051
1147
|
`<sheetViews>${paneXml(frozen)}</sheetViews>` +
|
|
1148
|
+
colsXml(columns) +
|
|
1052
1149
|
`<sheetData>${rows.join("")}</sheetData>` +
|
|
1150
|
+
filtered +
|
|
1053
1151
|
mergesXml(merges) +
|
|
1054
1152
|
printXml(printing) +
|
|
1055
1153
|
(drawing === null ? "" : `<drawing r:id="rId${drawing}"/>`) +
|
|
@@ -1060,10 +1158,9 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
1060
1158
|
};
|
|
1061
1159
|
|
|
1062
1160
|
/**
|
|
1063
|
-
* The drawing part for one sheet: a `oneCellAnchor` per placement, anchored
|
|
1064
|
-
* the top-left of its cell
|
|
1065
|
-
*
|
|
1066
|
-
* stays 1-based throughout.
|
|
1161
|
+
* The drawing part for one sheet: a `oneCellAnchor` per placement, anchored
|
|
1162
|
+
* to the top-left of its cell. The XML counts rows and columns from zero and
|
|
1163
|
+
* the subtraction happens here, so the surface stays 1-based throughout.
|
|
1067
1164
|
*
|
|
1068
1165
|
* @param {ReturnType<typeof worksheet>["pictures"]} pictures
|
|
1069
1166
|
* @param {ReadonlyArray<number>} media the media index each picture points at
|
|
@@ -1202,10 +1299,9 @@ const coreXml = (meta) =>
|
|
|
1202
1299
|
const titlesRef = (name, rows) => `${esc(`'${name.replace(/'/g, "''")}'`)}!$1:$${rows}`;
|
|
1203
1300
|
|
|
1204
1301
|
/**
|
|
1205
|
-
* The built-in name that
|
|
1206
|
-
* page. `localSheetId
|
|
1207
|
-
*
|
|
1208
|
-
* make one range serve them all.
|
|
1302
|
+
* The built-in name that repeats the top rows of a sheet on every printed
|
|
1303
|
+
* page. `localSheetId`, the sheet's 0-based position in `<sheets>`, is what
|
|
1304
|
+
* scopes the name to that worksheet rather than to the whole workbook.
|
|
1209
1305
|
*
|
|
1210
1306
|
* @param {ReadonlyArray<{ name: string, titles: number }>} sheets
|
|
1211
1307
|
*/
|