werkmap 0.4.0 → 0.6.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 +19 -5
- package/lib/index.d.ts +58 -2
- package/lib/index.js +233 -10
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
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.** 7.
|
|
9
|
+
- **Zero dependencies.** 7.7 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
12
|
- **Hardened.** 72 tests at 100% branch coverage.
|
|
@@ -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:
|
|
@@ -151,7 +155,7 @@ The list **replaces** rather than merging, and an empty list clears — a hole i
|
|
|
151
155
|
|
|
152
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.
|
|
153
157
|
|
|
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`.
|
|
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`.
|
|
155
159
|
|
|
156
160
|
### `sheet.filter(range)`
|
|
157
161
|
|
|
@@ -163,12 +167,22 @@ Excel also records an autofilter as a sheet-scoped `_xlnm._FilterDatabase` defin
|
|
|
163
167
|
|
|
164
168
|
### `sheet.print(setup)`
|
|
165
169
|
|
|
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.
|
|
170
|
+
How this worksheet prints: `{ margin, size, orientation, fit, titles, header, footer }`, 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.
|
|
167
171
|
|
|
168
172
|
A worksheet that never calls this carries **no print setup at all**, so a reader applies its own defaults rather than this writer's opinion. Calls merge, so two calls naming different keys both take effect. Print setup is per worksheet, which is where OOXML puts it.
|
|
169
173
|
|
|
170
174
|
`titles: n` repeats the top `n` rows at the top of every printed page — the paper counterpart of `freeze`, which keeps them in view on screen. `0` clears it, as it does for a freeze. This one is written as a `_xlnm.Print_Titles` defined name in `xl/workbook.xml` rather than in the sheet part, and it is scoped to this worksheet alone, so each sheet of a workbook repeats its own rows.
|
|
171
175
|
|
|
176
|
+
`header` and `footer` are text printed at the top and the bottom of every page. Either is one **section**, printed on the left, or the three sections the format has — `{ left, center, right }`, each optional. A section is a string, or an array of **parts** in order:
|
|
177
|
+
|
|
178
|
+
- a string, printed as it is;
|
|
179
|
+
- `{ field: "page" }` or `{ field: "pages" }`, the page number and the page count, which the reader fills in as it paginates — `["Page ", { field: "page" }, " of ", { field: "pages" }]`;
|
|
180
|
+
- `{ text, bold, size }`, text in a look: `bold: true` and a font size from 1 to 99, each holding until a later part changes it, and starting plain at every section.
|
|
181
|
+
|
|
182
|
+
`firstHeader` and `firstFooter` are the first page's, where it differs from the rest. Naming either makes the first page different, and a first-page part you do not name prints nothing on that page — so a footer that should print everywhere goes in `footer`, and only what changes on page one goes in `firstFooter`.
|
|
183
|
+
|
|
184
|
+
**Text is text**: the format has a small language of `&`-codes for dates, file names and more, and this surface exposes none of it, so an ampersand in your text prints as an ampersand and a newline prints as a line break. A reader holds at most **255 characters** per header or footer as stored — the section codes, each field's code, each look's code and the doubled ampersands count — and a longer text throws rather than being cut mid-sentence by the reader. Nothing appears on screen; a header is print furniture, and the grid is unchanged.
|
|
185
|
+
|
|
172
186
|
### `sheet.place(id, { row, col, width, height })`
|
|
173
187
|
|
|
174
188
|
One floating picture anchored to the top-left of a 1-based cell, drawn at `width` × `height` CSS pixels at 96 dpi.
|
package/lib/index.d.ts
CHANGED
|
@@ -76,6 +76,33 @@ export interface Placement {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/** How a worksheet prints. Every key is optional. */
|
|
79
|
+
/**
|
|
80
|
+
* Text for a page header or footer: a string, or parts in order, where a
|
|
81
|
+
* `{ field }` is a value the reader fills in as it paginates — `page` is the
|
|
82
|
+
* page number and `pages` the page count.
|
|
83
|
+
*/
|
|
84
|
+
/**
|
|
85
|
+
* One part of a page header or footer: text, a `{ field }` the reader fills
|
|
86
|
+
* in as it paginates (`page` is the page number, `pages` the page count), or
|
|
87
|
+
* `{ text }` in a look — bold, and a font size from 1 to 99 — that holds
|
|
88
|
+
* until a later part changes it.
|
|
89
|
+
*/
|
|
90
|
+
export type PrintPart =
|
|
91
|
+
| string
|
|
92
|
+
| { field: "page" | "pages" }
|
|
93
|
+
| { text: string; bold?: boolean; size?: number };
|
|
94
|
+
|
|
95
|
+
/** One section of a header: a string, or parts in order. */
|
|
96
|
+
export type PrintSection = string | readonly PrintPart[];
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Text for a page header or footer: one section, printed on the left, or the
|
|
100
|
+
* three sections the format has, each optional.
|
|
101
|
+
*/
|
|
102
|
+
export type PrintText =
|
|
103
|
+
| PrintSection
|
|
104
|
+
| { left?: PrintSection; center?: PrintSection; right?: PrintSection };
|
|
105
|
+
|
|
79
106
|
export interface PrintSetup {
|
|
80
107
|
/** All four page margins, in points. Defaults to the reader's own. */
|
|
81
108
|
margin?: number;
|
|
@@ -89,6 +116,24 @@ export interface PrintSetup {
|
|
|
89
116
|
* the way a `freeze` of `0` does. Scoped to this worksheet alone.
|
|
90
117
|
*/
|
|
91
118
|
titles?: number;
|
|
119
|
+
/**
|
|
120
|
+
* Text printed at the top of every page: one section on the left, or the
|
|
121
|
+
* three sections named. Text is text: an ampersand is an ampersand and a
|
|
122
|
+
* newline a line break. The format's own codes are not on this surface;
|
|
123
|
+
* the page number, the page count, bold and a size are named as parts
|
|
124
|
+
* instead. At most 255 characters as stored.
|
|
125
|
+
*/
|
|
126
|
+
header?: PrintText;
|
|
127
|
+
/** Text printed at the bottom of every page. Same rules as `header`. */
|
|
128
|
+
footer?: PrintText;
|
|
129
|
+
/**
|
|
130
|
+
* The first page's header, where it differs from the rest. Naming either
|
|
131
|
+
* first-page part makes the first page different; a part not named prints
|
|
132
|
+
* nothing there.
|
|
133
|
+
*/
|
|
134
|
+
firstHeader?: PrintText;
|
|
135
|
+
/** The first page's footer, where it differs from the rest. */
|
|
136
|
+
firstFooter?: PrintText;
|
|
92
137
|
}
|
|
93
138
|
|
|
94
139
|
/** The rectangle an autofilter covers. 1-based, and inclusive on all four sides. */
|
|
@@ -99,14 +144,25 @@ export interface FilterRange {
|
|
|
99
144
|
right: number;
|
|
100
145
|
}
|
|
101
146
|
|
|
147
|
+
/** What a row says about itself beyond its cells. */
|
|
148
|
+
export interface RowOptions {
|
|
149
|
+
/**
|
|
150
|
+
* The row's outline level, an integer from 0 to 7. A reader draws the
|
|
151
|
+
* levels as collapsible groups in its margin, with the summary row read as
|
|
152
|
+
* the one below the group. `0`, the default, is a row outside any group.
|
|
153
|
+
*/
|
|
154
|
+
level?: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
102
157
|
export interface Sheet {
|
|
103
158
|
/** This sheet's name. */
|
|
104
159
|
readonly name: string;
|
|
105
160
|
/**
|
|
106
161
|
* Append a row. `null` is an empty unstyled cell. Returns the row's 1-based
|
|
107
|
-
* position, which `merge` and `place` take.
|
|
162
|
+
* position, which `merge` and `place` take. `options.level` puts the row at
|
|
163
|
+
* an outline level.
|
|
108
164
|
*/
|
|
109
|
-
row(cells: readonly (Cell | null)[]): number;
|
|
165
|
+
row(cells: readonly (Cell | null)[], options?: RowOptions): number;
|
|
110
166
|
/**
|
|
111
167
|
* Merge `width` columns of `row`, starting at the 1-based column `at`.
|
|
112
168
|
* Throws on a width below 2, an overlap, or a row that does not exist yet.
|
package/lib/index.js
CHANGED
|
@@ -677,17 +677,184 @@ const POINTS_PER_INCH = 72;
|
|
|
677
677
|
// `pageMargins` has no optional attributes, so a value is owed either way.
|
|
678
678
|
const FURNITURE = 0.3;
|
|
679
679
|
|
|
680
|
+
// Excel's ceiling for a header or a footer, counted on the string it stores:
|
|
681
|
+
// the section code and the doubled ampersands included.
|
|
682
|
+
const MAX_FURNITURE = 255;
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* @typedef {string | { field: string } | { text: string, bold?: boolean, size?: number }} PrintPart
|
|
686
|
+
* @typedef {string | ReadonlyArray<PrintPart>} PrintSection
|
|
687
|
+
* @typedef {PrintSection | { left?: PrintSection, center?: PrintSection, right?: PrintSection }} PrintText
|
|
688
|
+
* @typedef {{ margin?: number, size?: string, orientation?: string, fit?: boolean,
|
|
689
|
+
* titles?: number, header?: PrintText, footer?: PrintText,
|
|
690
|
+
* firstHeader?: PrintText, firstFooter?: PrintText }} PrintSetup
|
|
691
|
+
*/
|
|
692
|
+
|
|
693
|
+
// The two fields a header may carry, as the format spells them: the page
|
|
694
|
+
// number and the page count, which the reader fills in as it paginates.
|
|
695
|
+
const FIELDS = new Map([
|
|
696
|
+
["page", "&P"],
|
|
697
|
+
["pages", "&N"],
|
|
698
|
+
]);
|
|
699
|
+
|
|
700
|
+
// The three sections of a header, in the order the format reads them.
|
|
701
|
+
const SECTIONS = [
|
|
702
|
+
["left", "&L"],
|
|
703
|
+
["center", "&C"],
|
|
704
|
+
["right", "&R"],
|
|
705
|
+
];
|
|
706
|
+
|
|
707
|
+
// The four parts of `headerFooter`, in the schema's order, and the setup key
|
|
708
|
+
// each is written from. `differentFirst` rides along once either first-page
|
|
709
|
+
// part is present.
|
|
710
|
+
const FURNITURE_PARTS = [
|
|
711
|
+
["header", "oddHeader"],
|
|
712
|
+
["footer", "oddFooter"],
|
|
713
|
+
["firstHeader", "firstHeader"],
|
|
714
|
+
["firstFooter", "firstFooter"],
|
|
715
|
+
];
|
|
716
|
+
|
|
717
|
+
// A header font size is written as two digits, so the format reads exactly
|
|
718
|
+
// two and the text that follows is never mistaken for more of the number.
|
|
719
|
+
const MAX_HEADER_SIZE = 99;
|
|
720
|
+
|
|
680
721
|
/**
|
|
681
|
-
* `pageMargins` and `
|
|
682
|
-
* optional attributes, so any margin means writing all
|
|
683
|
-
* surface does not take keep Excel's own header and footer
|
|
722
|
+
* `pageMargins`, `pageSetup` and `headerFooter`, or nothing at all.
|
|
723
|
+
* `pageMargins` has no optional attributes, so any margin means writing all
|
|
724
|
+
* six; the two this surface does not take keep Excel's own header and footer
|
|
725
|
+
* gap.
|
|
684
726
|
*
|
|
685
|
-
* @param {
|
|
727
|
+
* @param {PrintSetup | null} setup
|
|
686
728
|
*/
|
|
687
729
|
const printXml = (setup) => {
|
|
688
730
|
if (setup === null) return "";
|
|
689
731
|
const attributes = setupAttributes(setup);
|
|
690
|
-
return
|
|
732
|
+
return (
|
|
733
|
+
marginsXml(setup.margin) +
|
|
734
|
+
(attributes === "" ? "" : `<pageSetup${attributes}/>`) +
|
|
735
|
+
furnitureXml(setup)
|
|
736
|
+
);
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
/** Text as the format stores it: an ampersand doubled so none reads as a code.
|
|
740
|
+
* @param {string} text */
|
|
741
|
+
const plain = (text) => text.replace(/&/g, "&&");
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* A field as its code.
|
|
745
|
+
* @param {unknown} field
|
|
746
|
+
* @param {string} at
|
|
747
|
+
*/
|
|
748
|
+
const fieldCode = (field, at) => {
|
|
749
|
+
const code = FIELDS.get(/** @type {string} */ (field));
|
|
750
|
+
if (code === undefined)
|
|
751
|
+
throw RangeError(`${at}: expected a field of page or pages, got ${JSON.stringify(field)}`);
|
|
752
|
+
return code;
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* @typedef {{ bold: boolean, size: number | undefined }} Look
|
|
757
|
+
* What the section's text currently reads as. A code toggles bold and sets a
|
|
758
|
+
* size until the next code, so a part writes one only where its own look
|
|
759
|
+
* differs from what stands.
|
|
760
|
+
*/
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* @param {unknown} size
|
|
764
|
+
* @param {string} at
|
|
765
|
+
*/
|
|
766
|
+
const requireHeaderSize = (size, at) => {
|
|
767
|
+
if (!isIndex(size, MAX_HEADER_SIZE))
|
|
768
|
+
throw RangeError(
|
|
769
|
+
`${at}.size: expected an integer between 1 and ${MAX_HEADER_SIZE}, got ${JSON.stringify(size)}`,
|
|
770
|
+
);
|
|
771
|
+
return String(size).padStart(2, "0");
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* The bold code a part owes: `&B` toggles, so one is written only where the
|
|
776
|
+
* part's weight differs from what stands.
|
|
777
|
+
* @param {Record<string, unknown>} part
|
|
778
|
+
* @param {Look} look
|
|
779
|
+
*/
|
|
780
|
+
const boldCode = (part, look) => {
|
|
781
|
+
const bold = part.bold === true;
|
|
782
|
+
const toggles = bold !== look.bold;
|
|
783
|
+
look.bold = bold;
|
|
784
|
+
return toggles ? "&B" : "";
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* The size code a part owes: a size holds until the next one, so it is
|
|
789
|
+
* written only where the part names one that differs from what stands.
|
|
790
|
+
* @param {Record<string, unknown>} part
|
|
791
|
+
* @param {string} at
|
|
792
|
+
* @param {Look} look
|
|
793
|
+
*/
|
|
794
|
+
const sizeCode = (part, at, look) => {
|
|
795
|
+
const size = part.size;
|
|
796
|
+
if (size === undefined || size === look.size) return "";
|
|
797
|
+
look.size = /** @type {number} */ (size);
|
|
798
|
+
return "&" + requireHeaderSize(size, at);
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* One part of a section: text, a field as its code, or styled text with the
|
|
803
|
+
* codes that change the look before it.
|
|
804
|
+
* @param {unknown} part
|
|
805
|
+
* @param {string} at
|
|
806
|
+
* @param {Look} look
|
|
807
|
+
*/
|
|
808
|
+
const furniturePart = (part, at, look) => {
|
|
809
|
+
if (typeof part === "string") return plain(part);
|
|
810
|
+
const record = requireRecord(part, at, "text, a { field } part or a { text } part");
|
|
811
|
+
if (record.field !== undefined) return fieldCode(record.field, at);
|
|
812
|
+
const codes = boldCode(record, look) + sizeCode(record, at, look);
|
|
813
|
+
return codes + plain(requireString(record.text, `${at}.text`));
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* One section as the format stores it. A newline is a line break. The look
|
|
818
|
+
* starts plain at every section, since a section is where a code's reach ends.
|
|
819
|
+
* @param {unknown} text
|
|
820
|
+
* @param {string} at
|
|
821
|
+
*/
|
|
822
|
+
const sectionText = (text, at) => {
|
|
823
|
+
const parts = typeof text === "string" ? [text] : text;
|
|
824
|
+
if (!Array.isArray(parts))
|
|
825
|
+
throw TypeError(`${at}: expected text or an array of parts, got ${JSON.stringify(text)}`);
|
|
826
|
+
/** @type {Look} */
|
|
827
|
+
const look = { bold: false, size: undefined };
|
|
828
|
+
return parts.map((part, index) => furniturePart(part, `${at}[${index}]`, look)).join("");
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* A header or footer as the format stores it: one section on the left, or
|
|
833
|
+
* the sections a caller named, each behind its code, in the format's order.
|
|
834
|
+
* @param {unknown} text
|
|
835
|
+
* @param {string} at
|
|
836
|
+
*/
|
|
837
|
+
const furniture = (text, at) => {
|
|
838
|
+
if (typeof text === "string" || Array.isArray(text)) return "&L" + sectionText(text, at);
|
|
839
|
+
const sections = requireRecord(text, at, "text, parts or { left, center, right } sections");
|
|
840
|
+
return SECTIONS.filter(([key]) => sections[key] !== undefined)
|
|
841
|
+
.map(([key, code]) => code + sectionText(sections[key], `${at}.${key}`))
|
|
842
|
+
.join("");
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* `headerFooter`, or nothing for a worksheet that names no part of it. A
|
|
847
|
+
* first-page part makes the first page different, which the element says.
|
|
848
|
+
* @param {PrintSetup} setup
|
|
849
|
+
*/
|
|
850
|
+
const furnitureXml = (setup) => {
|
|
851
|
+
const record = /** @type {Record<string, unknown>} */ (setup);
|
|
852
|
+
const written = FURNITURE_PARTS.filter(([key]) => record[key] !== undefined).map(
|
|
853
|
+
([key, tag]) => `<${tag}>${esc(furniture(record[key], tag))}</${tag}>`,
|
|
854
|
+
);
|
|
855
|
+
if (written.length === 0) return "";
|
|
856
|
+
const first = setup.firstHeader !== undefined || setup.firstFooter !== undefined;
|
|
857
|
+
return `<headerFooter${first ? ' differentFirst="1"' : ""}>${written.join("")}</headerFooter>`;
|
|
691
858
|
};
|
|
692
859
|
|
|
693
860
|
/** @param {number | undefined} margin in points */
|
|
@@ -730,6 +897,41 @@ const MAX_WIDTH = 255;
|
|
|
730
897
|
// EMU per CSS pixel at 96 dpi.
|
|
731
898
|
const EMU = 9525;
|
|
732
899
|
|
|
900
|
+
// Excel's ceiling for a row outline level.
|
|
901
|
+
const MAX_LEVEL = 7;
|
|
902
|
+
|
|
903
|
+
/** @param {unknown} level */
|
|
904
|
+
const isLevel = (level) => level === 0 || isIndex(level, MAX_LEVEL);
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* A row's outline level, or 0 for a row that said nothing.
|
|
908
|
+
* @param {unknown} options
|
|
909
|
+
*/
|
|
910
|
+
const requireLevel = (options) => {
|
|
911
|
+
const level = requireRecord(options ?? {}, "row: options", "{ level }").level ?? 0;
|
|
912
|
+
if (!isLevel(level))
|
|
913
|
+
throw RangeError(
|
|
914
|
+
`row: level: expected an integer between 0 and ${MAX_LEVEL}, got ${JSON.stringify(level)}`,
|
|
915
|
+
);
|
|
916
|
+
return /** @type {number} */ (level);
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* A row's opening tag: the level rides along only where there is one.
|
|
921
|
+
* @param {number} at
|
|
922
|
+
* @param {number} level
|
|
923
|
+
*/
|
|
924
|
+
const rowOpen = (at, level) => `<row r="${at}"${level === 0 ? "" : ` outlineLevel="${level}"`}>`;
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* The sheet's `sheetFormatPr`, or nothing for a sheet that outlines no row.
|
|
928
|
+
* The format requires a default row height beside the level count; 15 is
|
|
929
|
+
* Excel's own for the default 11-point font.
|
|
930
|
+
* @param {number} deepest
|
|
931
|
+
*/
|
|
932
|
+
const formatXml = (deepest) =>
|
|
933
|
+
deepest === 0 ? "" : `<sheetFormatPr defaultRowHeight="15" outlineLevelRow="${deepest}"/>`;
|
|
934
|
+
|
|
733
935
|
/**
|
|
734
936
|
* A `Date` as the serial Excel stores. An invalid one has no serial to give.
|
|
735
937
|
* @param {Date} date
|
|
@@ -941,6 +1143,15 @@ const requireTitles = (rows) => {
|
|
|
941
1143
|
);
|
|
942
1144
|
};
|
|
943
1145
|
|
|
1146
|
+
/** @param {string} key @returns {(text: unknown) => void} */
|
|
1147
|
+
const requireFurniture = (key) => (text) => {
|
|
1148
|
+
const stored = furniture(text, `print: ${key}`);
|
|
1149
|
+
if (stored.length > MAX_FURNITURE)
|
|
1150
|
+
throw RangeError(
|
|
1151
|
+
`print: ${key} stores as ${stored.length} characters, and a reader holds at most ${MAX_FURNITURE}`,
|
|
1152
|
+
);
|
|
1153
|
+
};
|
|
1154
|
+
|
|
944
1155
|
/** @param {unknown} orientation */
|
|
945
1156
|
const requireOrientation = (orientation) => {
|
|
946
1157
|
if (!ORIENTATION.has(/** @type {string} */ (orientation)))
|
|
@@ -962,6 +1173,10 @@ const PRINT_CHECKS = {
|
|
|
962
1173
|
size: requirePaper,
|
|
963
1174
|
orientation: requireOrientation,
|
|
964
1175
|
titles: requireTitles,
|
|
1176
|
+
header: requireFurniture("header"),
|
|
1177
|
+
footer: requireFurniture("footer"),
|
|
1178
|
+
firstHeader: requireFurniture("firstHeader"),
|
|
1179
|
+
firstFooter: requireFurniture("firstFooter"),
|
|
965
1180
|
};
|
|
966
1181
|
|
|
967
1182
|
/**
|
|
@@ -982,6 +1197,8 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
982
1197
|
const pictures = [];
|
|
983
1198
|
let frozen = 0;
|
|
984
1199
|
let widest = 1;
|
|
1200
|
+
// The deepest outline level any row carries; 0 writes no `sheetFormatPr`.
|
|
1201
|
+
let deepest = 0;
|
|
985
1202
|
// This sheet's `autoFilter` element, or the empty string for a sheet that
|
|
986
1203
|
// asked for none. Rendered where it is set, the way a row is.
|
|
987
1204
|
let filtered = "";
|
|
@@ -992,7 +1209,7 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
992
1209
|
let columns = [];
|
|
993
1210
|
// What `print` was told, or null. Nothing reaches the file until a caller
|
|
994
1211
|
// asks: a reader's own print defaults are better than this writer guessing.
|
|
995
|
-
/** @type {
|
|
1212
|
+
/** @type {PrintSetup | null} */
|
|
996
1213
|
let printing = null;
|
|
997
1214
|
|
|
998
1215
|
/**
|
|
@@ -1015,18 +1232,23 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
1015
1232
|
return {
|
|
1016
1233
|
name,
|
|
1017
1234
|
|
|
1018
|
-
/**
|
|
1019
|
-
|
|
1235
|
+
/**
|
|
1236
|
+
* @param {ReadonlyArray<unknown>} cells
|
|
1237
|
+
* @param {unknown} [options]
|
|
1238
|
+
*/
|
|
1239
|
+
row(cells, options) {
|
|
1020
1240
|
if (!Array.isArray(cells))
|
|
1021
1241
|
throw TypeError(`row: expected an array of cells, got ${JSON.stringify(cells)}`);
|
|
1022
1242
|
const at = rows.length + 1;
|
|
1023
1243
|
if (cells.length > MAX_COLUMN)
|
|
1024
1244
|
throw RangeError(`row: a sheet holds at most ${MAX_COLUMN} columns`);
|
|
1245
|
+
const level = requireLevel(options);
|
|
1246
|
+
deepest = Math.max(deepest, level);
|
|
1025
1247
|
|
|
1026
1248
|
let body = "";
|
|
1027
1249
|
for (let index = 0; index < cells.length; index++) body += cellAt(cells[index], index, at);
|
|
1028
1250
|
widest = Math.max(widest, cells.length);
|
|
1029
|
-
rows.push(
|
|
1251
|
+
rows.push(rowOpen(at, level) + body + "</row>");
|
|
1030
1252
|
return at;
|
|
1031
1253
|
},
|
|
1032
1254
|
|
|
@@ -1052,7 +1274,7 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
1052
1274
|
* never calls this carries no print setup at all: a reader's own defaults
|
|
1053
1275
|
* beat a guess.
|
|
1054
1276
|
*
|
|
1055
|
-
* @param {
|
|
1277
|
+
* @param {PrintSetup} setup
|
|
1056
1278
|
*/
|
|
1057
1279
|
print(setup) {
|
|
1058
1280
|
const declared = requireRecord(setup, "print", "a setup object");
|
|
@@ -1145,6 +1367,7 @@ const worksheet = (name, styles, sst, knows) => {
|
|
|
1145
1367
|
properties +
|
|
1146
1368
|
`<dimension ref="${dimension}"/>` +
|
|
1147
1369
|
`<sheetViews>${paneXml(frozen)}</sheetViews>` +
|
|
1370
|
+
formatXml(deepest) +
|
|
1148
1371
|
colsXml(columns) +
|
|
1149
1372
|
`<sheetData>${rows.join("")}</sheetData>` +
|
|
1150
1373
|
filtered +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "werkmap",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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": "
|
|
60
|
+
"limit": "8 kB"
|
|
61
61
|
}
|
|
62
62
|
],
|
|
63
63
|
"engines": {
|