werkmap 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,10 +6,10 @@ Writing is the whole surface, so the package stays small enough to audit. The co
6
6
 
7
7
  - **Byte-identical output.** Nothing consults a clock, a locale or a random source, and the ZIP epoch is pinned, so two renders of the same report are the same bytes and a build that caches by content hash keeps working.
8
8
  - **Foreign readers accept it.** The suite loads every workbook it writes back through ExcelJS, an independent implementation — so the tests prove a real reader opens the file, not just that the bytes look plausible.
9
- - **Zero dependencies.** 5.7 kB minified and brotlied, for the whole writer.
9
+ - **Zero dependencies.** 7.0 kB minified and brotlied, for the whole writer.
10
10
  - **Strict CSP, including in the browser.** No `unsafe-eval` and no `unsafe-inline`. A Chromium page under `default-src 'none'; script-src 'self'` writes a workbook and reports any violation back, and the Node suite runs on `--disallow-code-generation-from-strings`.
11
11
  - **Write-only, deliberately.** No reader, no formula engine, no chart support — see [Is werkmap the right tool?](#is-werkmap-the-right-tool) before you install it.
12
- - **Hardened.** 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
@@ -141,6 +141,26 @@ One merged range across `width` columns of `row`, starting at the 1-based column
141
141
 
142
142
  Freeze the top `rows` rows. `0` clears.
143
143
 
144
+ ### `sheet.widths(list)`
145
+
146
+ Column widths by position: the first entry is column A, and `null` leaves a column unset so a reader keeps its own default for it.
147
+
148
+ The unit is the format's own — **a count of characters of the workbook's default font**, which is the number Excel's column-width box shows. A width is above 0 and at most 255. Pixels would read more naturally beside `place`, but converting them runs through the default font's maximum digit width, and any cell here may name a font of its own, so that constant would be wrong for most workbooks. The awkward unit invents no number.
149
+
150
+ The list **replaces** rather than merging, and an empty list clears — a hole in a positional list cannot mean both "leave this one alone" and "clear it". Call it before or after the rows; a width never widens the sheet, because `dimension` describes the cells that were written and sizing a column writes no cell. A worksheet that never calls this carries **no `cols` element at all**.
151
+
152
+ What is written is the number you gave, verbatim. Excel may report a slightly different one after a round trip, because it re-derives a width from the default font's digit width — that is the reader's arithmetic, not this writer's.
153
+
154
+ There is no `hidden`, no outline level and no per-column style: a zero width is the back door to a hidden column, so `0` throws and points at `null`.
155
+
156
+ ### `sheet.filter(range)`
157
+
158
+ Put an autofilter over `{ top, left, bottom, right }` — 1-based, and inclusive on all four sides. It adds the dropdown controls and **hides nothing**: every row you wrote is still in the document, and what the reader does with the control is theirs.
159
+
160
+ A worksheet takes one, so a second call **replaces** the first, and `null` clears it. The range must already have been written — its bottom row and its rightmost column both, the way a merge's row must — because a filter over cells nobody wrote is a caller's mistake rather than an empty range. Both are checked when you call, so write the rows first. A worksheet that never calls this carries **no `autoFilter` element at all**.
161
+
162
+ Excel also records an autofilter as a sheet-scoped `_xlnm._FilterDatabase` defined name, and this writes none. That is measured rather than assumed, in both readers: Excel opens a file carrying only the element, with no repair prompt, and reports the sheet's autofilter as on; LibreOffice opens it, keeps the filter, and writes that name itself on save. The name is a reader's bookkeeping rather than something a file owes.
163
+
144
164
  ### `sheet.print(setup)`
145
165
 
146
166
  How this worksheet prints: `{ margin, size, orientation, fit, titles }`, every key optional. `margin` is all four page margins in points, `size` one of `letter`, `tabloid`, `legal`, `A3`, `A4`, `A5`, `orientation` either `portrait` or `landscape`, and `fit: true` scales the sheet to one page wide and as many pages tall as it takes.
package/lib/index.d.ts CHANGED
@@ -91,6 +91,14 @@ export interface PrintSetup {
91
91
  titles?: number;
92
92
  }
93
93
 
94
+ /** The rectangle an autofilter covers. 1-based, and inclusive on all four sides. */
95
+ export interface FilterRange {
96
+ top: number;
97
+ left: number;
98
+ bottom: number;
99
+ right: number;
100
+ }
101
+
94
102
  export interface Sheet {
95
103
  /** This sheet's name. */
96
104
  readonly name: string;
@@ -106,12 +114,30 @@ export interface Sheet {
106
114
  merge(row: number, at: number, width: number): void;
107
115
  /** Freeze the top `count` rows. `0` clears. */
108
116
  freeze(count: number): void;
117
+ /**
118
+ * Column widths by position: the first entry is column A, and `null` leaves
119
+ * a column unset so a reader keeps its own default for it.
120
+ *
121
+ * The unit is the format's own — a count of characters of the workbook's
122
+ * default font, the number Excel's column-width box shows. A width is above
123
+ * 0 and at most 255.
124
+ *
125
+ * Replaces rather than merges, and an empty list clears.
126
+ */
127
+ widths(list: readonly (number | null)[]): void;
109
128
  /**
110
129
  * How this worksheet prints. A worksheet that never calls this carries no
111
130
  * print setup at all, so a reader applies its own defaults. Calls merge, so
112
131
  * two calls naming different keys both take effect.
113
132
  */
114
133
  print(setup: PrintSetup): void;
134
+ /**
135
+ * Put an autofilter over a range, 1-based and inclusive on all four sides.
136
+ * A worksheet takes one, so this replaces rather than merges, and `null`
137
+ * clears it. The range's bottom row and rightmost column must already have
138
+ * been written, checked when you call.
139
+ */
140
+ filter(range: FilterRange | null): void;
115
141
  /** Float a picture over the sheet, anchored to one cell. */
116
142
  place(id: number, at: Placement): void;
117
143
  }
package/lib/index.js CHANGED
@@ -1,18 +1,7 @@
1
1
  // werkmap — a write-only OOXML (.xlsx) writer.
2
2
  //
3
- // One entry point, `workbook`, returning a 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,6 +721,12 @@ const NAME_LIMIT = 31;
746
721
  const MAX_ROW = 1_048_576;
747
722
  const MAX_COLUMN = 16_384;
748
723
 
724
+ // Excel's ceiling for a column width, in the unit the format states it: a
725
+ // count of characters of the default font, the number Excel's own width box
726
+ // shows. Not pixels: that conversion runs through the default font's maximum
727
+ // digit width, and any cell here may name a font of its own.
728
+ const MAX_WIDTH = 255;
729
+
749
730
  // EMU per CSS pixel at 96 dpi.
750
731
  const EMU = 9525;
751
732
 
@@ -838,6 +819,64 @@ const rangeRef = (range) =>
838
819
  const overlaps = (a, b) =>
839
820
  a.top <= b.bottom && a.bottom >= b.top && a.left <= b.right && a.right >= b.left;
840
821
 
822
+ /**
823
+ * A `<col>` per column that was given a width, in column order, and nothing
824
+ * at all when none was. One element apiece rather than a `min`/`max` run: a
825
+ * run may never span a column left unset, and the sparse case breaks that
826
+ * first, for bytes on one of the smallest parts here.
827
+ *
828
+ * `customWidth` is not optional. Without it a reader treats the width as one
829
+ * it derived, and is free to compute its own instead.
830
+ *
831
+ * @param {ReadonlyArray<number | null | undefined>} columns
832
+ */
833
+ const colsXml = (columns) => {
834
+ const body = columns
835
+ .map((width, index) =>
836
+ absent(width)
837
+ ? ""
838
+ : `<col min="${index + 1}" max="${index + 1}" width="${number(width)}" customWidth="1"/>`,
839
+ )
840
+ .join("");
841
+ return body === "" ? "" : `<cols>${body}</cols>`;
842
+ };
843
+
844
+ // One rule of a filter's range, stated the way a caller reads it.
845
+ /** @param {boolean} ok @param {string} why */
846
+ const requireFilterRule = (ok, why) => {
847
+ if (!ok) throw RangeError(`filter: ${why}`);
848
+ };
849
+
850
+ // A range may only cover cells that were written. `merge` and `filter` both
851
+ // ask it, of a row and of a column.
852
+ /** @param {number} at @param {number} written @param {string} what @param {string} where */
853
+ const requireWritten = (at, written, what, where) => {
854
+ if (at > written)
855
+ throw RangeError(`${where}: ${what} ${at} does not exist yet (the sheet has ${written})`);
856
+ };
857
+
858
+ /**
859
+ * The rectangle an autofilter covers, 1-based and inclusive on all four
860
+ * sides. A range whose bottom row was never written is a caller's bug, so
861
+ * this takes the row count to check it against.
862
+ *
863
+ * @param {unknown} range
864
+ * @param {number} rows how many rows the sheet holds
865
+ * @param {number} columns how many columns it has written
866
+ */
867
+ const requireFilter = (range, rows, columns) => {
868
+ const rect = requireRecord(range, "filter", "{ top, left, bottom, right }");
869
+ const top = requireIndex(rect.top, MAX_ROW, "filter: top");
870
+ const left = requireIndex(rect.left, MAX_COLUMN, "filter: left");
871
+ const bottom = requireIndex(rect.bottom, MAX_ROW, "filter: bottom");
872
+ const right = requireIndex(rect.right, MAX_COLUMN, "filter: right");
873
+ requireFilterRule(bottom >= top, `bottom ${bottom} is above top ${top}`);
874
+ requireFilterRule(right >= left, `right ${right} is left of left ${left}`);
875
+ requireWritten(bottom, rows, "row", "filter");
876
+ requireWritten(right, columns, "column", "filter");
877
+ return { top, left, bottom, right };
878
+ };
879
+
841
880
  /** @param {ReadonlyArray<Range>} merges */
842
881
  const mergesXml = (merges) =>
843
882
  merges.length === 0
@@ -860,6 +899,23 @@ const mergeEnd = (width, at) => {
860
899
  return right;
861
900
  };
862
901
 
902
+ /**
903
+ * One entry of a width list. Absent is how a caller leaves a column alone, so
904
+ * it is the one value that checks nothing.
905
+ *
906
+ * @param {unknown} width
907
+ * @param {number} index 0-based, so the message can name the column
908
+ */
909
+ const checkWidth = (width, index) => {
910
+ if (absent(width)) return;
911
+ const chars = requireNumber(width, `widths: column ${index + 1}`);
912
+ if (chars > 0 && chars <= MAX_WIDTH) return;
913
+ throw RangeError(
914
+ `widths: column ${index + 1} expected a width above 0 and at most ${MAX_WIDTH}, got ${chars}` +
915
+ ` -- null leaves a column unset`,
916
+ );
917
+ };
918
+
863
919
  /** @param {unknown} margin */
864
920
  const requireMargin = (margin) => {
865
921
  const points = requireNumber(margin, "print: margin");
@@ -874,9 +930,8 @@ const requirePaper = (size) => {
874
930
  );
875
931
  };
876
932
 
877
- // Zero clears, 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.
933
+ // Zero clears, as it does for a freeze, so a caller that set a count has a way
934
+ // back. Not `requireIndex`: its message names 1 as the floor, and here it is 0.
880
935
  /** @param {unknown} rows */
881
936
  const requireTitles = (rows) => {
882
937
  if (rows === 0) return;
@@ -898,12 +953,10 @@ const requireSize = (width, height) => {
898
953
  throw RangeError(`place: expected a positive size, got ${width} by ${height}`);
899
954
  };
900
955
 
901
- // Every print key this surface checks, and the check it gets. 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.
956
+ // Every print key this surface checks, and the check it gets. Key order is
957
+ // the order a caller hears about a mistake. A table rather than a run of
958
+ // guards: a guard apiece puts `print` over the cyclomatic ceiling. `fit` is
959
+ // absent because only `true` writes anything.
907
960
  const PRINT_CHECKS = {
908
961
  margin: requireMargin,
909
962
  size: requirePaper,
@@ -929,6 +982,14 @@ const worksheet = (name, styles, sst, knows) => {
929
982
  const pictures = [];
930
983
  let frozen = 0;
931
984
  let widest = 1;
985
+ // This sheet's `autoFilter` element, or the empty string for a sheet that
986
+ // asked for none. Rendered where it is set, the way a row is.
987
+ let filtered = "";
988
+ // Widths by position, the first entry being column A. Replaced wholesale by
989
+ // `widths`, never merged: a hole in a positional list cannot mean both
990
+ // "leave this one alone" and "clear it".
991
+ /** @type {ReadonlyArray<number | null | undefined>} */
992
+ let columns = [];
932
993
  // What `print` was told, or null. Nothing reaches the file until a caller
933
994
  // asks: a reader's own print defaults are better than this writer guessing.
934
995
  /** @type {{ margin?: number, size?: string, orientation?: string, fit?: boolean, titles?: number } | null} */
@@ -977,8 +1038,7 @@ const worksheet = (name, styles, sst, knows) => {
977
1038
  merge(row, at, width) {
978
1039
  requireIndex(row, MAX_ROW, "merge: row");
979
1040
  requireIndex(at, MAX_COLUMN, "merge: at");
980
- if (row > rows.length)
981
- throw RangeError(`merge: row ${row} does not exist yet (the sheet has ${rows.length})`);
1041
+ requireWritten(row, rows.length, "row", "merge");
982
1042
  const range = { top: row, left: at, bottom: row, right: mergeEnd(width, at) };
983
1043
  const clash = merges.find((other) => overlaps(range, other));
984
1044
  if (clash !== undefined)
@@ -989,9 +1049,8 @@ const worksheet = (name, styles, sst, knows) => {
989
1049
 
990
1050
  /**
991
1051
  * How this worksheet prints. Every key is optional, and a worksheet that
992
- * never calls this carries no print setup at all a reader's own defaults
993
- * are better than a guess, and writing one would put this writer's opinion
994
- * in every file.
1052
+ * never calls this carries no print setup at all: a reader's own defaults
1053
+ * beat a guess.
995
1054
  *
996
1055
  * @param {{ margin?: number, size?: string, orientation?: string, fit?: boolean, titles?: number }} setup
997
1056
  */
@@ -1015,6 +1074,43 @@ const worksheet = (name, styles, sst, knows) => {
1015
1074
  frozen = count;
1016
1075
  },
1017
1076
 
1077
+ /**
1078
+ * Column widths by position, the first entry being column A. `null` leaves
1079
+ * a column unset, so a reader keeps its own default for it.
1080
+ *
1081
+ * Replaces rather than merges, and an empty list clears. A width never
1082
+ * widens the sheet: `dimension` describes the cells that were written, and
1083
+ * sizing a column writes no cell.
1084
+ *
1085
+ * @param {ReadonlyArray<unknown>} list
1086
+ */
1087
+ widths(list) {
1088
+ if (!Array.isArray(list))
1089
+ throw TypeError(`widths: expected an array of widths, got ${JSON.stringify(list)}`);
1090
+ if (list.length > MAX_COLUMN)
1091
+ throw RangeError(`widths: a sheet holds at most ${MAX_COLUMN} columns`);
1092
+ list.forEach(checkWidth);
1093
+ columns = list.slice();
1094
+ },
1095
+
1096
+ /**
1097
+ * Put an autofilter over a range, 1-based and inclusive. A worksheet takes
1098
+ * one, so this replaces rather than merges, and `null` clears it.
1099
+ *
1100
+ * @param {unknown} range
1101
+ */
1102
+ filter(range) {
1103
+ // Excel also records an autofilter as a sheet-scoped
1104
+ // `_xlnm._FilterDatabase` defined name; this writes none. Measured in
1105
+ // both readers: Excel opens such a file with no repair prompt and
1106
+ // reports the filter as on, and LibreOffice writes the name itself on
1107
+ // save. The name is a reader's bookkeeping.
1108
+ filtered =
1109
+ range === null
1110
+ ? ""
1111
+ : `<autoFilter ref="${rangeRef(requireFilter(range, rows.length, widest))}"/>`;
1112
+ },
1113
+
1018
1114
  /**
1019
1115
  * @param {unknown} id
1020
1116
  * @param {{ row: number, col?: number, width: number, height: number }} at
@@ -1049,7 +1145,9 @@ const worksheet = (name, styles, sst, knows) => {
1049
1145
  properties +
1050
1146
  `<dimension ref="${dimension}"/>` +
1051
1147
  `<sheetViews>${paneXml(frozen)}</sheetViews>` +
1148
+ colsXml(columns) +
1052
1149
  `<sheetData>${rows.join("")}</sheetData>` +
1150
+ filtered +
1053
1151
  mergesXml(merges) +
1054
1152
  printXml(printing) +
1055
1153
  (drawing === null ? "" : `<drawing r:id="rId${drawing}"/>`) +
@@ -1060,10 +1158,9 @@ const worksheet = (name, styles, sst, knows) => {
1060
1158
  };
1061
1159
 
1062
1160
  /**
1063
- * The drawing part for one sheet: a `oneCellAnchor` per placement, anchored 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.
1161
+ * The drawing part for one sheet: a `oneCellAnchor` per placement, anchored
1162
+ * to the top-left of its cell. The XML counts rows and columns from zero and
1163
+ * the subtraction happens here, so the surface stays 1-based throughout.
1067
1164
  *
1068
1165
  * @param {ReturnType<typeof worksheet>["pictures"]} pictures
1069
1166
  * @param {ReadonlyArray<number>} media the media index each picture points at
@@ -1202,10 +1299,9 @@ const coreXml = (meta) =>
1202
1299
  const titlesRef = (name, rows) => `${esc(`'${name.replace(/'/g, "''")}'`)}!$1:$${rows}`;
1203
1300
 
1204
1301
  /**
1205
- * The built-in name that 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.
1302
+ * The built-in name that repeats the top rows of a sheet on every printed
1303
+ * page. `localSheetId`, the sheet's 0-based position in `<sheets>`, is what
1304
+ * scopes the name to that worksheet rather than to the whole workbook.
1209
1305
  *
1210
1306
  * @param {ReadonlyArray<{ name: string, titles: number }>} sheets
1211
1307
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "werkmap",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Tiny, CSP-safe OOXML spreadsheet writer. Write-only, zero dependencies, byte-identical output.",
5
5
  "keywords": [
6
6
  "csp",