werkmap 0.3.0 → 0.5.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 +29 -5
- package/lib/index.d.ts +39 -2
- package/lib/index.js +207 -68
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# werkmap
|
|
2
2
|
|
|
3
|
-
A tiny spreadsheet writer for JavaScript. It writes an `.xlsx` file — rows, typed cells, styles, merged ranges, a frozen header, floating images — and nothing else. It does not read one. _Werkmap_ is Dutch for a workbook, which is the one thing this package makes.
|
|
3
|
+
A tiny spreadsheet writer for JavaScript. It writes an `.xlsx` file — rows, typed cells, styles, merged ranges, a frozen header, outline levels, floating images — and nothing else. It does not read one. _Werkmap_ is Dutch for a workbook, which is the one thing this package makes.
|
|
4
4
|
|
|
5
5
|
Writing is the whole surface, so the package stays small enough to audit. The container is written against the ZIP specification over `CompressionStream`, every part is a string, and no value ever turns into code — so it runs unchanged under a Content Security Policy with no `unsafe-*` of any kind, which is what a spreadsheet export in the browser usually cannot do.
|
|
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.1 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
|
|
@@ -114,10 +114,14 @@ Any number of sheets, in call order. Throws on a name a reader refuses: empty, o
|
|
|
114
114
|
|
|
115
115
|
The finished package. This does not seal the document: call it as often as you like, and the same call sequence yields the same bytes.
|
|
116
116
|
|
|
117
|
-
### `sheet.row(cells) -> number`
|
|
117
|
+
### `sheet.row(cells, options?) -> number`
|
|
118
118
|
|
|
119
119
|
`cells` is an array; the returned number is the row's 1-based position, which `merge`, `freeze` and `place` take.
|
|
120
120
|
|
|
121
|
+
`options.level` is the row's **outline level**, an integer from 0 to 7. A reader draws the levels as collapsible groups in its left margin — the way Excel's own Group command does — and reads the summary row as the one **below** each group, which is where a total row sits. `0`, the default, is a row outside any group, and a row given no options is at 0. Nothing is hidden or collapsed: every row you wrote is in the document and open, and what the reader does with the controls is theirs.
|
|
122
|
+
|
|
123
|
+
A sheet that outlines any row also states the workbook's default row height, 15 points, because the format requires it beside the deepest level. A sheet with no outline states neither, and a reader keeps its own default. That is the one place this writer names a row height, and it names the default rather than one of its own.
|
|
124
|
+
|
|
121
125
|
Each element is `{ value, style }`, or `null` for an empty unstyled cell. `{ value: null, style }` is an empty **styled** cell, which is what a merged span's remaining columns need.
|
|
122
126
|
|
|
123
127
|
`value` is one of:
|
|
@@ -141,6 +145,26 @@ One merged range across `width` columns of `row`, starting at the 1-based column
|
|
|
141
145
|
|
|
142
146
|
Freeze the top `rows` rows. `0` clears.
|
|
143
147
|
|
|
148
|
+
### `sheet.widths(list)`
|
|
149
|
+
|
|
150
|
+
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.
|
|
151
|
+
|
|
152
|
+
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.
|
|
153
|
+
|
|
154
|
+
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**.
|
|
155
|
+
|
|
156
|
+
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.
|
|
157
|
+
|
|
158
|
+
There is no `hidden`, no column 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`.
|
|
159
|
+
|
|
160
|
+
### `sheet.filter(range)`
|
|
161
|
+
|
|
162
|
+
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.
|
|
163
|
+
|
|
164
|
+
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**.
|
|
165
|
+
|
|
166
|
+
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.
|
|
167
|
+
|
|
144
168
|
### `sheet.print(setup)`
|
|
145
169
|
|
|
146
170
|
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,14 +91,33 @@ 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
|
+
|
|
102
|
+
/** What a row says about itself beyond its cells. */
|
|
103
|
+
export interface RowOptions {
|
|
104
|
+
/**
|
|
105
|
+
* The row's outline level, an integer from 0 to 7. A reader draws the
|
|
106
|
+
* levels as collapsible groups in its margin, with the summary row read as
|
|
107
|
+
* the one below the group. `0`, the default, is a row outside any group.
|
|
108
|
+
*/
|
|
109
|
+
level?: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
export interface Sheet {
|
|
95
113
|
/** This sheet's name. */
|
|
96
114
|
readonly name: string;
|
|
97
115
|
/**
|
|
98
116
|
* Append a row. `null` is an empty unstyled cell. Returns the row's 1-based
|
|
99
|
-
* position, which `merge` and `place` take.
|
|
117
|
+
* position, which `merge` and `place` take. `options.level` puts the row at
|
|
118
|
+
* an outline level.
|
|
100
119
|
*/
|
|
101
|
-
row(cells: readonly (Cell | null)[]): number;
|
|
120
|
+
row(cells: readonly (Cell | null)[], options?: RowOptions): number;
|
|
102
121
|
/**
|
|
103
122
|
* Merge `width` columns of `row`, starting at the 1-based column `at`.
|
|
104
123
|
* Throws on a width below 2, an overlap, or a row that does not exist yet.
|
|
@@ -106,12 +125,30 @@ export interface Sheet {
|
|
|
106
125
|
merge(row: number, at: number, width: number): void;
|
|
107
126
|
/** Freeze the top `count` rows. `0` clears. */
|
|
108
127
|
freeze(count: number): void;
|
|
128
|
+
/**
|
|
129
|
+
* Column widths by position: the first entry is column A, and `null` leaves
|
|
130
|
+
* a column unset so a reader keeps its own default for it.
|
|
131
|
+
*
|
|
132
|
+
* The unit is the format's own — a count of characters of the workbook's
|
|
133
|
+
* default font, the number Excel's column-width box shows. A width is above
|
|
134
|
+
* 0 and at most 255.
|
|
135
|
+
*
|
|
136
|
+
* Replaces rather than merges, and an empty list clears.
|
|
137
|
+
*/
|
|
138
|
+
widths(list: readonly (number | null)[]): void;
|
|
109
139
|
/**
|
|
110
140
|
* How this worksheet prints. A worksheet that never calls this carries no
|
|
111
141
|
* print setup at all, so a reader applies its own defaults. Calls merge, so
|
|
112
142
|
* two calls naming different keys both take effect.
|
|
113
143
|
*/
|
|
114
144
|
print(setup: PrintSetup): void;
|
|
145
|
+
/**
|
|
146
|
+
* Put an autofilter over a range, 1-based and inclusive on all four sides.
|
|
147
|
+
* A worksheet takes one, so this replaces rather than merges, and `null`
|
|
148
|
+
* clears it. The range's bottom row and rightmost column must already have
|
|
149
|
+
* been written, checked when you call.
|
|
150
|
+
*/
|
|
151
|
+
filter(range: FilterRange | null): void;
|
|
115
152
|
/** Float a picture over the sheet, anchored to one cell. */
|
|
116
153
|
place(id: number, at: Placement): void;
|
|
117
154
|
}
|
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,9 +721,50 @@ 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
|
|
|
733
|
+
// Excel's ceiling for a row outline level.
|
|
734
|
+
const MAX_LEVEL = 7;
|
|
735
|
+
|
|
736
|
+
/** @param {unknown} level */
|
|
737
|
+
const isLevel = (level) => level === 0 || isIndex(level, MAX_LEVEL);
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* A row's outline level, or 0 for a row that said nothing.
|
|
741
|
+
* @param {unknown} options
|
|
742
|
+
*/
|
|
743
|
+
const requireLevel = (options) => {
|
|
744
|
+
const level = requireRecord(options ?? {}, "row: options", "{ level }").level ?? 0;
|
|
745
|
+
if (!isLevel(level))
|
|
746
|
+
throw RangeError(
|
|
747
|
+
`row: level: expected an integer between 0 and ${MAX_LEVEL}, got ${JSON.stringify(level)}`,
|
|
748
|
+
);
|
|
749
|
+
return /** @type {number} */ (level);
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* A row's opening tag: the level rides along only where there is one.
|
|
754
|
+
* @param {number} at
|
|
755
|
+
* @param {number} level
|
|
756
|
+
*/
|
|
757
|
+
const rowOpen = (at, level) => `<row r="${at}"${level === 0 ? "" : ` outlineLevel="${level}"`}>`;
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* The sheet's `sheetFormatPr`, or nothing for a sheet that outlines no row.
|
|
761
|
+
* The format requires a default row height beside the level count; 15 is
|
|
762
|
+
* Excel's own for the default 11-point font.
|
|
763
|
+
* @param {number} deepest
|
|
764
|
+
*/
|
|
765
|
+
const formatXml = (deepest) =>
|
|
766
|
+
deepest === 0 ? "" : `<sheetFormatPr defaultRowHeight="15" outlineLevelRow="${deepest}"/>`;
|
|
767
|
+
|
|
752
768
|
/**
|
|
753
769
|
* A `Date` as the serial Excel stores. An invalid one has no serial to give.
|
|
754
770
|
* @param {Date} date
|
|
@@ -838,6 +854,64 @@ const rangeRef = (range) =>
|
|
|
838
854
|
const overlaps = (a, b) =>
|
|
839
855
|
a.top <= b.bottom && a.bottom >= b.top && a.left <= b.right && a.right >= b.left;
|
|
840
856
|
|
|
857
|
+
/**
|
|
858
|
+
* A `<col>` per column that was given a width, in column order, and nothing
|
|
859
|
+
* at all when none was. One element apiece rather than a `min`/`max` run: a
|
|
860
|
+
* run may never span a column left unset, and the sparse case breaks that
|
|
861
|
+
* first, for bytes on one of the smallest parts here.
|
|
862
|
+
*
|
|
863
|
+
* `customWidth` is not optional. Without it a reader treats the width as one
|
|
864
|
+
* it derived, and is free to compute its own instead.
|
|
865
|
+
*
|
|
866
|
+
* @param {ReadonlyArray<number | null | undefined>} columns
|
|
867
|
+
*/
|
|
868
|
+
const colsXml = (columns) => {
|
|
869
|
+
const body = columns
|
|
870
|
+
.map((width, index) =>
|
|
871
|
+
absent(width)
|
|
872
|
+
? ""
|
|
873
|
+
: `<col min="${index + 1}" max="${index + 1}" width="${number(width)}" customWidth="1"/>`,
|
|
874
|
+
)
|
|
875
|
+
.join("");
|
|
876
|
+
return body === "" ? "" : `<cols>${body}</cols>`;
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
// One rule of a filter's range, stated the way a caller reads it.
|
|
880
|
+
/** @param {boolean} ok @param {string} why */
|
|
881
|
+
const requireFilterRule = (ok, why) => {
|
|
882
|
+
if (!ok) throw RangeError(`filter: ${why}`);
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
// A range may only cover cells that were written. `merge` and `filter` both
|
|
886
|
+
// ask it, of a row and of a column.
|
|
887
|
+
/** @param {number} at @param {number} written @param {string} what @param {string} where */
|
|
888
|
+
const requireWritten = (at, written, what, where) => {
|
|
889
|
+
if (at > written)
|
|
890
|
+
throw RangeError(`${where}: ${what} ${at} does not exist yet (the sheet has ${written})`);
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* The rectangle an autofilter covers, 1-based and inclusive on all four
|
|
895
|
+
* sides. A range whose bottom row was never written is a caller's bug, so
|
|
896
|
+
* this takes the row count to check it against.
|
|
897
|
+
*
|
|
898
|
+
* @param {unknown} range
|
|
899
|
+
* @param {number} rows how many rows the sheet holds
|
|
900
|
+
* @param {number} columns how many columns it has written
|
|
901
|
+
*/
|
|
902
|
+
const requireFilter = (range, rows, columns) => {
|
|
903
|
+
const rect = requireRecord(range, "filter", "{ top, left, bottom, right }");
|
|
904
|
+
const top = requireIndex(rect.top, MAX_ROW, "filter: top");
|
|
905
|
+
const left = requireIndex(rect.left, MAX_COLUMN, "filter: left");
|
|
906
|
+
const bottom = requireIndex(rect.bottom, MAX_ROW, "filter: bottom");
|
|
907
|
+
const right = requireIndex(rect.right, MAX_COLUMN, "filter: right");
|
|
908
|
+
requireFilterRule(bottom >= top, `bottom ${bottom} is above top ${top}`);
|
|
909
|
+
requireFilterRule(right >= left, `right ${right} is left of left ${left}`);
|
|
910
|
+
requireWritten(bottom, rows, "row", "filter");
|
|
911
|
+
requireWritten(right, columns, "column", "filter");
|
|
912
|
+
return { top, left, bottom, right };
|
|
913
|
+
};
|
|
914
|
+
|
|
841
915
|
/** @param {ReadonlyArray<Range>} merges */
|
|
842
916
|
const mergesXml = (merges) =>
|
|
843
917
|
merges.length === 0
|
|
@@ -860,6 +934,23 @@ const mergeEnd = (width, at) => {
|
|
|
860
934
|
return right;
|
|
861
935
|
};
|
|
862
936
|
|
|
937
|
+
/**
|
|
938
|
+
* One entry of a width list. Absent is how a caller leaves a column alone, so
|
|
939
|
+
* it is the one value that checks nothing.
|
|
940
|
+
*
|
|
941
|
+
* @param {unknown} width
|
|
942
|
+
* @param {number} index 0-based, so the message can name the column
|
|
943
|
+
*/
|
|
944
|
+
const checkWidth = (width, index) => {
|
|
945
|
+
if (absent(width)) return;
|
|
946
|
+
const chars = requireNumber(width, `widths: column ${index + 1}`);
|
|
947
|
+
if (chars > 0 && chars <= MAX_WIDTH) return;
|
|
948
|
+
throw RangeError(
|
|
949
|
+
`widths: column ${index + 1} expected a width above 0 and at most ${MAX_WIDTH}, got ${chars}` +
|
|
950
|
+
` -- null leaves a column unset`,
|
|
951
|
+
);
|
|
952
|
+
};
|
|
953
|
+
|
|
863
954
|
/** @param {unknown} margin */
|
|
864
955
|
const requireMargin = (margin) => {
|
|
865
956
|
const points = requireNumber(margin, "print: margin");
|
|
@@ -874,9 +965,8 @@ const requirePaper = (size) => {
|
|
|
874
965
|
);
|
|
875
966
|
};
|
|
876
967
|
|
|
877
|
-
// Zero clears,
|
|
878
|
-
//
|
|
879
|
-
// its message names 1 as the floor, and here the floor is 0.
|
|
968
|
+
// Zero clears, as it does for a freeze, so a caller that set a count has a way
|
|
969
|
+
// back. Not `requireIndex`: its message names 1 as the floor, and here it is 0.
|
|
880
970
|
/** @param {unknown} rows */
|
|
881
971
|
const requireTitles = (rows) => {
|
|
882
972
|
if (rows === 0) return;
|
|
@@ -898,12 +988,10 @@ const requireSize = (width, height) => {
|
|
|
898
988
|
throw RangeError(`place: expected a positive size, got ${width} by ${height}`);
|
|
899
989
|
};
|
|
900
990
|
|
|
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.
|
|
991
|
+
// Every print key this surface checks, and the check it gets. Key order is
|
|
992
|
+
// the order a caller hears about a mistake. A table rather than a run of
|
|
993
|
+
// guards: a guard apiece puts `print` over the cyclomatic ceiling. `fit` is
|
|
994
|
+
// absent because only `true` writes anything.
|
|
907
995
|
const PRINT_CHECKS = {
|
|
908
996
|
margin: requireMargin,
|
|
909
997
|
size: requirePaper,
|
|
@@ -929,6 +1017,16 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
929
1017
|
const pictures = [];
|
|
930
1018
|
let frozen = 0;
|
|
931
1019
|
let widest = 1;
|
|
1020
|
+
// The deepest outline level any row carries; 0 writes no `sheetFormatPr`.
|
|
1021
|
+
let deepest = 0;
|
|
1022
|
+
// This sheet's `autoFilter` element, or the empty string for a sheet that
|
|
1023
|
+
// asked for none. Rendered where it is set, the way a row is.
|
|
1024
|
+
let filtered = "";
|
|
1025
|
+
// Widths by position, the first entry being column A. Replaced wholesale by
|
|
1026
|
+
// `widths`, never merged: a hole in a positional list cannot mean both
|
|
1027
|
+
// "leave this one alone" and "clear it".
|
|
1028
|
+
/** @type {ReadonlyArray<number | null | undefined>} */
|
|
1029
|
+
let columns = [];
|
|
932
1030
|
// What `print` was told, or null. Nothing reaches the file until a caller
|
|
933
1031
|
// asks: a reader's own print defaults are better than this writer guessing.
|
|
934
1032
|
/** @type {{ margin?: number, size?: string, orientation?: string, fit?: boolean, titles?: number } | null} */
|
|
@@ -954,18 +1052,23 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
954
1052
|
return {
|
|
955
1053
|
name,
|
|
956
1054
|
|
|
957
|
-
/**
|
|
958
|
-
|
|
1055
|
+
/**
|
|
1056
|
+
* @param {ReadonlyArray<unknown>} cells
|
|
1057
|
+
* @param {unknown} [options]
|
|
1058
|
+
*/
|
|
1059
|
+
row(cells, options) {
|
|
959
1060
|
if (!Array.isArray(cells))
|
|
960
1061
|
throw TypeError(`row: expected an array of cells, got ${JSON.stringify(cells)}`);
|
|
961
1062
|
const at = rows.length + 1;
|
|
962
1063
|
if (cells.length > MAX_COLUMN)
|
|
963
1064
|
throw RangeError(`row: a sheet holds at most ${MAX_COLUMN} columns`);
|
|
1065
|
+
const level = requireLevel(options);
|
|
1066
|
+
deepest = Math.max(deepest, level);
|
|
964
1067
|
|
|
965
1068
|
let body = "";
|
|
966
1069
|
for (let index = 0; index < cells.length; index++) body += cellAt(cells[index], index, at);
|
|
967
1070
|
widest = Math.max(widest, cells.length);
|
|
968
|
-
rows.push(
|
|
1071
|
+
rows.push(rowOpen(at, level) + body + "</row>");
|
|
969
1072
|
return at;
|
|
970
1073
|
},
|
|
971
1074
|
|
|
@@ -977,8 +1080,7 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
977
1080
|
merge(row, at, width) {
|
|
978
1081
|
requireIndex(row, MAX_ROW, "merge: row");
|
|
979
1082
|
requireIndex(at, MAX_COLUMN, "merge: at");
|
|
980
|
-
|
|
981
|
-
throw RangeError(`merge: row ${row} does not exist yet (the sheet has ${rows.length})`);
|
|
1083
|
+
requireWritten(row, rows.length, "row", "merge");
|
|
982
1084
|
const range = { top: row, left: at, bottom: row, right: mergeEnd(width, at) };
|
|
983
1085
|
const clash = merges.find((other) => overlaps(range, other));
|
|
984
1086
|
if (clash !== undefined)
|
|
@@ -989,9 +1091,8 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
989
1091
|
|
|
990
1092
|
/**
|
|
991
1093
|
* 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.
|
|
1094
|
+
* never calls this carries no print setup at all: a reader's own defaults
|
|
1095
|
+
* beat a guess.
|
|
995
1096
|
*
|
|
996
1097
|
* @param {{ margin?: number, size?: string, orientation?: string, fit?: boolean, titles?: number }} setup
|
|
997
1098
|
*/
|
|
@@ -1015,6 +1116,43 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
1015
1116
|
frozen = count;
|
|
1016
1117
|
},
|
|
1017
1118
|
|
|
1119
|
+
/**
|
|
1120
|
+
* Column widths by position, the first entry being column A. `null` leaves
|
|
1121
|
+
* a column unset, so a reader keeps its own default for it.
|
|
1122
|
+
*
|
|
1123
|
+
* Replaces rather than merges, and an empty list clears. A width never
|
|
1124
|
+
* widens the sheet: `dimension` describes the cells that were written, and
|
|
1125
|
+
* sizing a column writes no cell.
|
|
1126
|
+
*
|
|
1127
|
+
* @param {ReadonlyArray<unknown>} list
|
|
1128
|
+
*/
|
|
1129
|
+
widths(list) {
|
|
1130
|
+
if (!Array.isArray(list))
|
|
1131
|
+
throw TypeError(`widths: expected an array of widths, got ${JSON.stringify(list)}`);
|
|
1132
|
+
if (list.length > MAX_COLUMN)
|
|
1133
|
+
throw RangeError(`widths: a sheet holds at most ${MAX_COLUMN} columns`);
|
|
1134
|
+
list.forEach(checkWidth);
|
|
1135
|
+
columns = list.slice();
|
|
1136
|
+
},
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Put an autofilter over a range, 1-based and inclusive. A worksheet takes
|
|
1140
|
+
* one, so this replaces rather than merges, and `null` clears it.
|
|
1141
|
+
*
|
|
1142
|
+
* @param {unknown} range
|
|
1143
|
+
*/
|
|
1144
|
+
filter(range) {
|
|
1145
|
+
// Excel also records an autofilter as a sheet-scoped
|
|
1146
|
+
// `_xlnm._FilterDatabase` defined name; this writes none. Measured in
|
|
1147
|
+
// both readers: Excel opens such a file with no repair prompt and
|
|
1148
|
+
// reports the filter as on, and LibreOffice writes the name itself on
|
|
1149
|
+
// save. The name is a reader's bookkeeping.
|
|
1150
|
+
filtered =
|
|
1151
|
+
range === null
|
|
1152
|
+
? ""
|
|
1153
|
+
: `<autoFilter ref="${rangeRef(requireFilter(range, rows.length, widest))}"/>`;
|
|
1154
|
+
},
|
|
1155
|
+
|
|
1018
1156
|
/**
|
|
1019
1157
|
* @param {unknown} id
|
|
1020
1158
|
* @param {{ row: number, col?: number, width: number, height: number }} at
|
|
@@ -1049,7 +1187,10 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
1049
1187
|
properties +
|
|
1050
1188
|
`<dimension ref="${dimension}"/>` +
|
|
1051
1189
|
`<sheetViews>${paneXml(frozen)}</sheetViews>` +
|
|
1190
|
+
formatXml(deepest) +
|
|
1191
|
+
colsXml(columns) +
|
|
1052
1192
|
`<sheetData>${rows.join("")}</sheetData>` +
|
|
1193
|
+
filtered +
|
|
1053
1194
|
mergesXml(merges) +
|
|
1054
1195
|
printXml(printing) +
|
|
1055
1196
|
(drawing === null ? "" : `<drawing r:id="rId${drawing}"/>`) +
|
|
@@ -1060,10 +1201,9 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
1060
1201
|
};
|
|
1061
1202
|
|
|
1062
1203
|
/**
|
|
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.
|
|
1204
|
+
* The drawing part for one sheet: a `oneCellAnchor` per placement, anchored
|
|
1205
|
+
* to the top-left of its cell. The XML counts rows and columns from zero and
|
|
1206
|
+
* the subtraction happens here, so the surface stays 1-based throughout.
|
|
1067
1207
|
*
|
|
1068
1208
|
* @param {ReturnType<typeof worksheet>["pictures"]} pictures
|
|
1069
1209
|
* @param {ReadonlyArray<number>} media the media index each picture points at
|
|
@@ -1202,10 +1342,9 @@ const coreXml = (meta) =>
|
|
|
1202
1342
|
const titlesRef = (name, rows) => `${esc(`'${name.replace(/'/g, "''")}'`)}!$1:$${rows}`;
|
|
1203
1343
|
|
|
1204
1344
|
/**
|
|
1205
|
-
* The built-in name that
|
|
1206
|
-
* page. `localSheetId
|
|
1207
|
-
*
|
|
1208
|
-
* make one range serve them all.
|
|
1345
|
+
* The built-in name that repeats the top rows of a sheet on every printed
|
|
1346
|
+
* page. `localSheetId`, the sheet's 0-based position in `<sheets>`, is what
|
|
1347
|
+
* scopes the name to that worksheet rather than to the whole workbook.
|
|
1209
1348
|
*
|
|
1210
1349
|
* @param {ReadonlyArray<{ name: string, titles: number }>} sheets
|
|
1211
1350
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "werkmap",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Tiny, CSP-safe OOXML spreadsheet writer. Write-only, zero dependencies, byte-identical output.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"csp",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"size-limit": [
|
|
58
58
|
{
|
|
59
59
|
"path": "lib/index.js",
|
|
60
|
-
"limit": "7 kB"
|
|
60
|
+
"limit": "7.5 kB"
|
|
61
61
|
}
|
|
62
62
|
],
|
|
63
63
|
"engines": {
|