werkmap 0.1.0 → 0.2.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
@@ -88,7 +88,7 @@ werkmap writes the slice of the format a report needs, and refuses the rest.
88
88
  **It does not fit when:**
89
89
 
90
90
  - You need to read, edit or convert an existing workbook.
91
- - You need formulas, charts, pivot tables, conditional formatting, data validation, hyperlinks, comments or sheet protection.
91
+ - You need formulas, charts, pivot tables, conditional formatting, data validation, hyperlinks, comments, sheet protection, or a page header and footer.
92
92
  - You need column widths or row heights. A reader sizes columns from its own defaults.
93
93
  - You have more rows than fit in memory. There is no streaming, row-at-a-time output.
94
94
 
@@ -141,6 +141,12 @@ 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.print(setup)`
145
+
146
+ How this worksheet prints: `{ margin, size, orientation, fit }`, 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.
147
+
148
+ 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.
149
+
144
150
  ### `sheet.place(id, { row, col, width, height })`
145
151
 
146
152
  One floating picture anchored to the top-left of a 1-based cell, drawn at `width` × `height` CSS pixels at 96 dpi.
@@ -173,7 +179,7 @@ There is nothing to configure. Each of these is a property of the writer rather
173
179
 
174
180
  - **Text is always interned** into the shared string table.
175
181
  - **Style tables are always interned.**
176
- - **Dates are the 1900 system**, converted in UTC. A reader's timezone is never consulted, and the phantom 1900-02-29 is accounted for, so a date before March 1900 lands on the day it names.
182
+ - **Dates are the 1900 system**, converted in UTC. A reader's timezone never enters the conversion. This writer counts Excel's phantom 1900-02-29, so a date before March 1900 lands on the day it names in Excel. ECMA-376 defines the 1900 system with that fictitious day. LibreOffice Calc omits it and shows those dates one day early. No single serial satisfies both readers, so this writer follows the specification. A date from 1900-03-01 on reads the same in both, which covers every date a report is likely to hold.
177
183
  - **A date with no format of its own** gets the short-date built-in, so it reads back as a day rather than as the number underneath it.
178
184
  - **Media deduplicates by bytes.**
179
185
  - **Every element is written in the Open XML SDK's child order.**
package/lib/index.d.ts CHANGED
@@ -75,6 +75,17 @@ export interface Placement {
75
75
  height: number;
76
76
  }
77
77
 
78
+ /** How a worksheet prints. Every key is optional. */
79
+ export interface PrintSetup {
80
+ /** All four page margins, in points. Defaults to the reader's own. */
81
+ margin?: number;
82
+ /** A paper size OOXML names. There is no arbitrary width and height. */
83
+ size?: "letter" | "tabloid" | "legal" | "A3" | "A4" | "A5";
84
+ orientation?: "portrait" | "landscape";
85
+ /** Scale the sheet to one page wide, and as many pages tall as it takes. */
86
+ fit?: boolean;
87
+ }
88
+
78
89
  export interface Sheet {
79
90
  /** This sheet's name. */
80
91
  readonly name: string;
@@ -90,6 +101,12 @@ export interface Sheet {
90
101
  merge(row: number, at: number, width: number): void;
91
102
  /** Freeze the top `count` rows. `0` clears. */
92
103
  freeze(count: number): void;
104
+ /**
105
+ * How this worksheet prints. A worksheet that never calls this carries no
106
+ * print setup at all, so a reader applies its own defaults. Calls merge, so
107
+ * two calls naming different keys both take effect.
108
+ */
109
+ print(setup: PrintSetup): void;
93
110
  /** Float a picture over the sheet, anchored to one cell. */
94
111
  place(id: number, at: Placement): void;
95
112
  }
package/lib/index.js CHANGED
@@ -26,10 +26,13 @@ for (let n = 0; n < 256; n++) {
26
26
  CRC[n] = c;
27
27
  }
28
28
 
29
+ // Indexed rather than `for...of`: this runs over every part's uncompressed
30
+ // bytes, so it scales with the whole document, and the iterator protocol costs
31
+ // about four times the arithmetic it wraps (32 ms against 9 ms over 3 MB).
29
32
  /** @param {Uint8Array} bytes */
30
33
  const crc32 = (bytes) => {
31
34
  let c = -1;
32
- for (const b of bytes) c = CRC[(c ^ b) & 0xff] ^ (c >>> 8);
35
+ for (let at = 0; at < bytes.length; at++) c = CRC[(c ^ bytes[at]) & 0xff] ^ (c >>> 8);
33
36
  return (c ^ -1) >>> 0;
34
37
  };
35
38
 
@@ -590,6 +593,55 @@ const strings = () => {
590
593
  };
591
594
  };
592
595
 
596
+ // --------------------------------------------------------- print setup ----
597
+
598
+ // The paper sizes OOXML names by number. A workbook carries a code, not a
599
+ // width and a height, so this is the whole of what a caller may ask for.
600
+ const PAPER = new Map([
601
+ ["letter", 1],
602
+ ["tabloid", 3],
603
+ ["legal", 5],
604
+ ["A3", 8],
605
+ ["A4", 9],
606
+ ["A5", 11],
607
+ ]);
608
+
609
+ const ORIENTATION = new Set(["portrait", "landscape"]);
610
+
611
+ // OOXML states margins in inches; this surface takes points, the unit a print
612
+ // margin is written in everywhere else. 72 points to the inch.
613
+ const POINTS_PER_INCH = 72;
614
+
615
+ // The gap Excel leaves for a header and a footer when a caller says nothing.
616
+ // `pageMargins` has no optional attributes, so a value is owed either way.
617
+ const FURNITURE = 0.3;
618
+
619
+ /**
620
+ * `pageMargins` and `pageSetup`, or nothing at all. `pageMargins` has no
621
+ * optional attributes, so asking for any margin means writing all six — the
622
+ * two this surface does not take keep Excel's own gap for a header and a
623
+ * footer.
624
+ *
625
+ * @param {{ margin?: number, size?: string, orientation?: string, fit?: boolean } | null} setup
626
+ */
627
+ const printXml = (setup) => {
628
+ if (setup === null) return "";
629
+ let out = "";
630
+ if (setup.margin !== undefined) {
631
+ const inches = (setup.margin / POINTS_PER_INCH).toFixed(3);
632
+ out +=
633
+ `<pageMargins left="${inches}" right="${inches}" top="${inches}" bottom="${inches}"` +
634
+ ` header="${FURNITURE}" footer="${FURNITURE}"/>`;
635
+ }
636
+ let attributes = "";
637
+ if (setup.size !== undefined) attributes += ` paperSize="${PAPER.get(setup.size)}"`;
638
+ if (setup.orientation !== undefined) attributes += ` orientation="${setup.orientation}"`;
639
+ // `fitToHeight="0"` is what makes it *width* the document fits to: one page
640
+ // across, as many down as it takes.
641
+ if (setup.fit === true) attributes += ' fitToWidth="1" fitToHeight="0"';
642
+ return attributes === "" ? out : out + `<pageSetup${attributes}/>`;
643
+ };
644
+
593
645
  // -------------------------------------------------------------- sheet ----
594
646
 
595
647
  const FORBIDDEN_IN_NAME = /[:\\/?*[\]]/;
@@ -634,6 +686,10 @@ const worksheet = (name, styles, sst, knows) => {
634
686
  const pictures = [];
635
687
  let frozen = 0;
636
688
  let widest = 1;
689
+ // What `print` was told, or null. Nothing reaches the file until a caller
690
+ // asks: a reader's own print defaults are better than this writer guessing.
691
+ /** @type {{ margin?: number, size?: string, orientation?: string, fit?: boolean } | null} */
692
+ let printing = null;
637
693
 
638
694
  /**
639
695
  * @param {unknown} value
@@ -743,6 +799,30 @@ const worksheet = (name, styles, sst, knows) => {
743
799
  if (right > widest) widest = right;
744
800
  },
745
801
 
802
+ /**
803
+ * How this worksheet prints. Every key is optional, and a worksheet that
804
+ * never calls this carries no print setup at all — a reader's own defaults
805
+ * are better than a guess, and writing one would put this writer's opinion
806
+ * in every file.
807
+ *
808
+ * @param {{ margin?: number, size?: string, orientation?: string, fit?: boolean }} setup
809
+ */
810
+ print(setup) {
811
+ if (setup === null || typeof setup !== "object")
812
+ throw TypeError(`print: expected a setup object, got ${JSON.stringify(setup)}`);
813
+ if (setup.margin !== undefined && requireNumber(setup.margin, "print: margin") < 0)
814
+ throw RangeError(`print: margin cannot be negative, got ${setup.margin}`);
815
+ if (setup.size !== undefined && !PAPER.has(setup.size))
816
+ throw RangeError(
817
+ `print: unknown paper size ${JSON.stringify(setup.size)} -- known: ${[...PAPER.keys()].join(", ")}`,
818
+ );
819
+ if (setup.orientation !== undefined && !ORIENTATION.has(setup.orientation))
820
+ throw RangeError(
821
+ `print: expected portrait or landscape, got ${JSON.stringify(setup.orientation)}`,
822
+ );
823
+ printing = { ...printing, ...setup };
824
+ },
825
+
746
826
  /** @param {number} count */
747
827
  freeze(count) {
748
828
  if (typeof count !== "number" || !Number.isInteger(count) || count < 0 || count >= MAX_ROW)
@@ -790,13 +870,20 @@ const worksheet = (name, styles, sst, knows) => {
790
870
  .join("") +
791
871
  `</mergeCells>`;
792
872
 
873
+ // `fitToPage` lives on `sheetPr`, which the schema puts before every
874
+ // other child of a worksheet.
875
+ const properties =
876
+ printing?.fit === true ? `<sheetPr><pageSetUpPr fitToPage="1"/></sheetPr>` : "";
877
+
793
878
  return (
794
879
  DECLARATION +
795
880
  `<worksheet xmlns="${NS}" xmlns:r="${NS_R}">` +
881
+ properties +
796
882
  `<dimension ref="${dimension}"/>` +
797
883
  `<sheetViews>${pane}</sheetViews>` +
798
884
  `<sheetData>${rows.join("")}</sheetData>` +
799
885
  merged +
886
+ printXml(printing) +
800
887
  (drawing === null ? "" : `<drawing r:id="rId${drawing}"/>`) +
801
888
  `</worksheet>`
802
889
  );
@@ -850,6 +937,11 @@ const CREATED = "1970-01-01T00:00:00Z";
850
937
 
851
938
  /** @param {Uint8Array} a @param {Uint8Array} b */
852
939
  const same = (a, b) => {
940
+ // The same array twice is the common case, and the one the byte loop is
941
+ // worst at: equal bytes never exit early, so it reads the whole image every
942
+ // time. A caller placing one logo on a thousand rows pays 54 ms for that,
943
+ // and nothing for this.
944
+ if (a === b) return true;
853
945
  if (a.length !== b.length) return false;
854
946
  for (let at = 0; at < a.length; at++) if (a[at] !== b[at]) return false;
855
947
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "werkmap",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Tiny, CSP-safe OOXML spreadsheet writer. Write-only, zero dependencies, byte-identical output.",
5
5
  "keywords": [
6
6
  "csp",