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 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.** 5.7 kB minified and brotlied, for the whole writer.
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.** 53 tests at 100% branch coverage.
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 column widths or row heights. A reader sizes columns from its own defaults.
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 small mutable builder whose only
4
- // terminal is `bytes()`. Nothing here reads a .xlsx file, and nothing turns a
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. These are stripped rather than thrown over. The text
154
- // reaching this writer has already passed its caller's own gates, and failing
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>` rather than only where the text
179
- // needs it: without it a reader is free to trim, and one rule is easier to
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, which absorbs
286
- // its phantom 1900-02-29. The conversion is in UTC: a reader's own timezone
287
- // is never consulted, so one `Date` is one serial everywhere.
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 in the file is one of these, which is what makes identical fonts,
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`, and the interning behind it. Entry 0 of each
547
- * table is the default every unstyled cell points at; the two fills OOXML
548
- * reserves (`none`, `gray125`) are written whether or not anything uses them,
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 asking for any margin means writing all six the
707
- * two this surface does not take keep Excel's own gap for a header and a
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, exactly as it does for a freeze, so a caller that set a count
878
- // has a way back from it. That is why this does not reach for `requireIndex`:
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. The order is the
902
- // order a caller hears about a mistake, so it is this object's key order and
903
- // not a detail. A table rather than a run of guards because a guard apiece
904
- // puts `print` over the cyclomatic ceiling this package holds every function
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
- /** @param {ReadonlyArray<unknown>} cells */
958
- row(cells) {
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(`<row r="${at}">${body}</row>`);
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
- if (row > rows.length)
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 a reader's own defaults
993
- * are better than a guess, and writing one would put this writer's opinion
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 to
1064
- * the top-left of its cell at the size the caller asked for. The XML counts
1065
- * rows and columns from zero and the subtraction happens here, so the surface
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 makes the top rows of a sheet repeat on every printed
1206
- * page. `localSheetId` is the sheet's 0-based position in `<sheets>`, which is
1207
- * what scopes the name to that one worksheet -- a workbook-scoped name would
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.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": {