@quario/xlsx 0.6.0 → 0.7.1

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/CHANGELOG.md CHANGED
@@ -1,15 +1,39 @@
1
- # Changelog
1
+ # @quario/xlsx
2
2
 
3
- All notable changes to @quario/xlsx are documented in this file.
3
+ ## 0.7.1
4
4
 
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+ ### Patch Changes
7
6
 
8
- ## [Unreleased]
7
+ - **Every published README says where the documentation is.** Each package now
8
+ carries a Documentation section pointing at the reference, at the report schema
9
+ that normatively specifies what a report may declare, and at the package's own
10
+ API. The paragraphs that used to end on an unstated contract — the event
11
+ stream's field semantics, the style vocabulary, page columns, the Content
12
+ Security Policy a fragment with images needs, the formula mangling, and each
13
+ target's own contract — link the page that states it. Every link is an absolute
14
+ URL, so it resolves from the npm package page as readily as from an installed
15
+ copy.
16
+ - Updated dependencies
17
+ - quario@0.8.0
9
18
 
10
- ## [0.6.0] - 2026-09-07
19
+ ## 0.7.0
11
20
 
12
- ### Added
21
+ ### Minor Changes
22
+
23
+ - **Styled runs become rich text.** A cell of more than one styled run is written
24
+ as rich text, each run's font merged over the cell's — so bold, italic,
25
+ underline, strikethrough, size, face and colour survive into the grid. A
26
+ run-level `background` is not read: a spreadsheet's rich-text runs carry fonts
27
+ only, and filling the cell would colour text nobody asked to colour. A cell of
28
+ exactly one run stays a typed cell, so a single styled amount is still a
29
+ number.
30
+ - **A run inside a sentence presents its value.** A run holding one
31
+ interpolation under a `format` shows the same decimals here as on the page,
32
+ where a mixed cell used to show the bare value.
33
+
34
+ ## 0.6.0
35
+
36
+ ### Minor Changes
13
37
 
14
38
  - **A date cell's number format follows the `form` its report declares.** The
15
39
  four forms map to `dd/mm/yy`, `dd mmm yyyy`, `dd mmmm yyyy` and
@@ -17,26 +41,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
17
41
  weekday name is spelled in stays the reader's, because that is what a
18
42
  spreadsheet application supplies and pinning it would stop the file reading
19
43
  naturally for whoever opens it.
20
-
21
44
  - **A declared digit count builds the pattern.** A `number` cell asking for
22
45
  three digits writes `#,##0.000`, and a `currency` cell asking for none writes
23
46
  `"EUR"#,##0` — the same count the page presents, taken off the declaration
24
47
  the engine resolved rather than computed here.
25
-
26
48
  - **A cell's own `currency` code picks the number format**, ahead of the
27
49
  instance's default: `"JPY"#,##0` where the row says `JPY`, since the code
28
50
  decides the digits too. The cell stays a number either way. A code the engine
29
51
  could not accept writes no number format at all, rather than labelling the
30
52
  cell in the instance's currency.
31
-
32
- ### Changed
33
-
34
53
  - **A `date` cell with no declared form now writes `dd mmm yyyy`, not
35
54
  `yyyy-mm-dd`.** A bare `date` means the `medium` form throughout, so the grid
36
55
  agrees with the page. A report that wants the ISO shape has no form for it;
37
56
  the nearest is `{ "kind": "date", "form": "short" }`, which writes
38
57
  `dd/mm/yy`. The cell is still a real typed date either way.
39
-
40
58
  - **A cell's number format now comes from the engine's fraction-digit count
41
59
  rather than from four patterns this target held of its own.** The visible
42
60
  change is `currency`: the pattern followed the currency's own minor units,
@@ -45,7 +63,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
45
63
  (`#,##0.00`), `percent` (`0.00%`) and `date` (`yyyy-mm-dd`) are unchanged in
46
64
  a worksheet; what changed for those is that HTML and PDF now show the same
47
65
  decimals the grid always did, instead of their own.
48
-
49
66
  - A `currency` code that is not a readable currency no longer produces a number
50
67
  format at all. The cell keeps its value; before, the code was pasted straight
51
68
  into a pattern while every other target fell back to the unformatted value.
@@ -54,39 +71,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
54
71
  position still come from the application that opens the file, which is what
55
72
  lets one workbook read naturally wherever it is opened.
56
73
 
57
- ### Fixed
58
-
59
74
  - A `format` kind reaching this target from an `=` expression is now looked up
60
75
  as an own key. Before, a kind resolving to the name of a built-in object
61
76
  member threw out of the render (`valueOf`, `hasOwnProperty`) or wrote
62
77
  `[object Undefined]` into the cell's number format (`toString`). A literal
63
78
  was never affected: it is checked against the four kinds.
64
-
65
- ### Fixed
66
-
67
79
  - **An image whose size cannot be read now names the item that asked for it.**
68
80
  The failure said only that the size could not be read from the bytes, so a
69
81
  report with two pictures gave no way to tell which one was bad. The message
70
82
  now begins with the item's `source` path, as every other render error does
71
83
  and as it does from every other target.
72
84
 
73
- ## [0.5.0] - 2026-09-05
85
+ ## 0.5.0
74
86
 
75
- ### Added
87
+ ### Minor Changes
76
88
 
77
89
  - **Chrome merges, data does not.** A cell spanning several columns in the
78
90
  header row or a total row is written into the first column it covers, the
79
91
  rest of the range is written empty, and the range is merged. Nothing in the
80
92
  data region merges, so sort, filter and column selection keep working over
81
93
  it.
82
-
83
94
  - **`valign`** writes the cell's vertical alignment for table cells and split
84
95
  slots. Undeclared writes nothing, so the spreadsheet application keeps its
85
96
  own default.
86
97
 
87
- ## [0.4.0] - 2026-09-03
98
+ ## 0.4.0
88
99
 
89
- ### Changed
100
+ ### Minor Changes
90
101
 
91
102
  - **A date string under `format: "date"` now reaches the grid as a date.**
92
103
  The cell carries a real date value under the `yyyy-mm-dd` number format
@@ -94,83 +105,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
94
105
  cell declaring the kind is affected; a string in a form the engine does not
95
106
  read stays text. See the `quario` changelog for the forms.
96
107
 
97
- ## [0.3.0] - 2026-09-02
108
+ ## 0.3.0
98
109
 
99
- ### Added
110
+ ### Minor Changes
100
111
 
101
112
  - **`format` maps to a number format; the cell stays typed.** `number` is
102
113
  `#,##0.00`, `percent` `0.00%`, `date` `yyyy-mm-dd`, `currency` the
103
114
  instance currency code as `"USD"#,##0.00`. A kind on the wrong type
104
115
  contributes nothing.
105
-
106
116
  - **Cell borders map; padding and flow spacing do not.** `solid` is exceljs
107
117
  `thin`; `dashed` and `dotted` keep their names. A header-row, row, or
108
118
  total-row box fans onto that row's cells; a cell that named any of a
109
119
  side's three keys owns that side whole. Padding is unread: a worksheet
110
120
  cell has no inset. Flow spacing is unread: a grid has no flow.
111
-
112
121
  - **`page.margin` and report-header `height` are unread.** A grid has no
113
122
  page top to pin from.
114
-
115
- ### Changed
116
-
117
123
  - **A table total emits N rows.** Each `total-row` is one worksheet row, as
118
124
  each data row is.
119
-
120
125
  - **A cell whose display contains a newline wraps.** `wrapText` is this
121
126
  target's mapping of a literal newline as a line break, so `"one\ntwo"` is
122
127
  two lines in the grid. Single-line cells are unchanged.
123
128
 
124
- ## [0.2.0] - 2026-09-01
129
+ ## 0.2.0
125
130
 
126
- ### Added
131
+ ### Minor Changes
127
132
 
128
133
  - **The report default reaches every cell.** A report's top-level `style`
129
134
  replaces this target's baseline, so a document declaring a face and size gets
130
135
  them in cells that declare nothing of their own. It is the layer under the
131
136
  band-role defaults — a report declaring `size: 12` still writes its report
132
137
  header at 14 — and under each cell's own style.
133
-
134
138
  - **`uppercase` is accepted and deliberately not read.** A spreadsheet font
135
139
  has no text-transform, and writing capitals into the cell instead would turn
136
140
  presentation into data — the cell would stop round-tripping and would sort
137
141
  differently. The cell keeps the text you wrote; every other declaration on
138
142
  it still applies. Same posture as the column widths this target withdrew.
139
-
140
143
  - **A split is one row, its slots the cells across it.** The grid's own
141
144
  reading of values placed beside each other. Slot `width` shares go unread,
142
145
  on exactly the ground the table's column widths do — a worksheet's columns
143
146
  are global to the sheet. The split's own style is what its slots sit under,
144
147
  layering the way a table row's does, and a slot that renders nothing writes
145
148
  an empty cell so the cells either side keep their columns.
146
-
147
- ### Changed
148
-
149
149
  - **Every cell is written at 10 points unless something declares otherwise.**
150
150
  The size was previously left to the writer's own default of 11. Now that a
151
151
  report can declare its own size, this target and the PDF carrying different
152
152
  numbers for one declaration is exactly what the targets' agreement rule
153
153
  forbids. A worksheet's row height follows its font size, so existing unstyled
154
154
  sheets come out slightly tighter.
155
-
156
155
  - **Display-text `Date`s join as ISO 8601 UTC.** A `Date` inside a mixed
157
156
  cell (literal text plus interpolation) joined through `String(date)`, which
158
157
  bakes the host's timezone and locale into the worksheet. It now joins
159
158
  through the engine's shared display rule as `toISOString()` text. Typed
160
159
  cells are untouched: a cell that is one bare `Date` interpolation still
161
160
  writes a native date.
162
-
163
- ### Fixed
164
-
165
161
  - **Cells carry their font face explicitly.** Text declaring no `family` used
166
162
  to inherit whatever the workbook writer defaulted to, which agreed with the
167
163
  other targets only by coincidence. It now writes the same face that
168
164
  `family: "sans"` resolves to, so the baseline is a rule rather than a
169
165
  property of the library underneath.
170
166
 
171
- ## [0.1.0] - 2026-08-27
167
+ ## 0.1.0
172
168
 
173
- ### Added
169
+ ### Minor Changes
174
170
 
175
171
  - **A workbook from the same compiled report.** Bands become rows; a bare
176
172
  interpolation that is a number, boolean, or Date stays that type. The first
package/README.md CHANGED
@@ -79,7 +79,7 @@ as that order alone. Group boundaries add no rows of their own, and `break: "pag
79
79
  `reset: "page"` are ignored, as are `page.margin` and a report header's `height`.
80
80
 
81
81
  Each item takes one row with its cell in the first column, unmerged; a split takes one row with its
82
- slots as the cells across it (slot widths unread, a slot rendering nothing an empty cell), and a
82
+ slots as the cells across it (slot widths withdrawn, a slot rendering nothing an empty cell), and a
83
83
  spanning header or total cell is a merged range. Two band roles carry an
84
84
  omakase default that the author's own style always overrides: report-header items render bold at
85
85
  size 14, group-header items render bold.
@@ -109,9 +109,11 @@ on the wrong type, or a code the engine cannot read, writes no format.
109
109
  ### Images
110
110
 
111
111
  An image item is not a cell: it takes one worksheet row as its anchor and floats over the sheet as a
112
- drawing at its natural pixel size. Placement here is best-effort. `fit`, `alt`, and `style` go
113
- unread, and all of it may change without a breaking change. Bytes too short to carry a size reject
114
- the render rather than vanish.
112
+ drawing at its natural pixel size. Placement here is best-effort. `fit`, `alt`, and `style` are
113
+ unused, and all of it may change without a breaking change (see the
114
+ [support matrix](https://getquario.com/docs/reference/support-matrix/#output)). Bytes too short to
115
+ carry a size never reach this target: the engine reads every image's size and rejects them for
116
+ every target alike.
115
117
 
116
118
  ### Styles
117
119
 
@@ -136,17 +138,18 @@ The first table's header row anchors the frozen view. Everything from that row u
136
138
  while the body scrolls, the spreadsheet analogue of the PDF target's repeated headers. A report
137
139
  without a table freezes nothing. This is normative.
138
140
 
139
- ### Widths are not read
141
+ ### Widths are withdrawn
140
142
 
141
143
  Column `width` percentages size nothing here, and every worksheet column keeps the application
142
144
  default. A width is a share of the _table_ width, and a worksheet's columns are global to the sheet.
143
145
  Column A carries the flattened band item rows and the table's first column alike, so there is no
144
146
  table for a share to be a share of.
145
147
 
146
- ### Page columns are not read
148
+ ### Page columns are withdrawn
147
149
 
148
- A page column count sizes and splits nothing here, and will not change, as `break: "page"` is
149
- ignored. Page columns are strips of a page, and this target has no page to strip.
150
+ A [page column](https://getquario.com/docs/reference/support-matrix/#page-and-pagination) count
151
+ sizes and splits nothing here, and will not change, as `break: "page"` is ignored. Page columns
152
+ are strips of a page, and this target has no page to strip.
150
153
 
151
154
  ## Unlicensed marking
152
155
 
@@ -161,6 +164,17 @@ The document model is deterministic: the workbook's dates are pinned rather than
161
164
  `meta` is opt-in. The **bytes** are not. The writer stamps archive entries with the packing time.
162
165
  Compare extracted content, not digests.
163
166
 
167
+ The full contract is [The XLSX target](https://getquario.com/docs/diving-deeper/xlsx-target/),
168
+ with each declaration's fate in the
169
+ [support matrix](https://getquario.com/docs/reference/support-matrix/).
170
+
171
+ ## Documentation
172
+
173
+ [The quario documentation](https://getquario.com/docs/) is the reference.
174
+ The [report schema](https://getquario.com/docs/reference/report-schema/) is the normative
175
+ specification of what a report may declare, and
176
+ [`@quario/xlsx`](https://getquario.com/docs/reference/xlsx/) is this package's own API.
177
+
164
178
  ## License
165
179
 
166
180
  Commercial software with readable source. Free, unlimited, watermarked evaluation; per-developer
package/lib/cell.js CHANGED
@@ -2,12 +2,13 @@
2
2
  * From an event cell to a plain cell descriptor `{ value, format }`, built on
3
3
  * the engine's `typed` stream rule. `value` is a native number, boolean, or
4
4
  * Date when the cell is one bare interpolation (so numbers stay computable in
5
- * the sheet), and the display-text join otherwise. `format` is the resolved
6
- * cell-level formatting from the style module. Display text that contains a
7
- * newline gains `wrapText` so the break is visible in the grid.
5
+ * the sheet), the worksheet's rich-text shape when the cell holds more than
6
+ * one styled run, and the display-text join otherwise. `format` is the
7
+ * resolved cell-level formatting from the style module. Display text that
8
+ * contains a newline gains `wrapText` so the break is visible in the grid.
8
9
  */
9
- import { currencyOf, text, typed } from "quario";
10
- import { format as look, merge } from "./style.js";
10
+ import { currencyOf, display, format as present, styledRuns, text, typed } from "quario";
11
+ import { font, format as look, merge } from "./style.js";
11
12
 
12
13
  // The digit count is the engine's, never this target's: it rides on the
13
14
  // resolved declaration the stream carries, the same one HTML and PDF
@@ -87,14 +88,62 @@ let paintWrap = (out, shown) => {
87
88
  out.alignment = { ...out.alignment, wrapText: true };
88
89
  };
89
90
 
91
+ // One run's presented text: literals verbatim, every value through the
92
+ // declaration that run resolved. A run holding a single value token presents
93
+ // it, so a formatted amount inside a sentence reads the same here as it does
94
+ // on the page -- SCHEMA.md pins that the fraction digits are the same in every
95
+ // target that presents, and this target presents (ADR 0061). A run the engine
96
+ // withdrew the declaration from has nothing to present with and falls to
97
+ // display text, which is what the whole cell used to do.
98
+ /** @type {(tokens: any[], style: any, intl: any) => string} */
99
+ let shownText = (tokens, style, intl) => {
100
+ let out = "";
101
+ for (let token of tokens)
102
+ out +=
103
+ "literal" in token
104
+ ? token.literal
105
+ : (present(token.value, style, intl) ?? display(token.value));
106
+ return out;
107
+ };
108
+
109
+ // A cell of more than one styled run takes the worksheet's rich-text shape:
110
+ // each run's **font half** merged over the cell font, and nothing else. A
111
+ // run-level `background` is inert -- spreadsheet rich-text runs carry fonts
112
+ // only, and filling the cell would colour text the author did not name -- and
113
+ // so is a run-level number format, since a cell has one and this cell is text.
114
+ // No typed cell is lost: a cell of more than one run holds more than one token
115
+ // and was display text already.
116
+ /** @type {(cell: any, styled: any[], under: any, intl: any) => any} */
117
+ let rich = (cell, styled, under, intl) => {
118
+ let base = merge(under, cell.style);
119
+ let out = look(base);
120
+ let parts = styled.map((one) => {
121
+ let style = one.style ? merge(base, one.style) : base;
122
+ return { font: font(style), text: shownText(one.tokens, style, intl) };
123
+ });
124
+ paintWrap(out, parts.map((part) => part.text).join(""));
125
+ return { value: { richText: parts }, format: out };
126
+ };
127
+
128
+ // The style a cell of one styled run wears: that run's resolved answer, which
129
+ // already carries the cell's own declarations, or the cell's where it has no
130
+ // run at all.
131
+ /** @type {(cell: any, styled: any[]) => any} */
132
+ let soleStyle = (cell, styled) => styled[0]?.style ?? cell.style;
133
+
90
134
  /**
91
135
  * Resolve one event cell against its enclosing style (a row's style, or an
92
136
  * item's band-role default — the layer under the cell's own).
93
137
  *
138
+ * A cell of exactly one styled run wears that run's answer whole, so a single
139
+ * styled amount is still a number in the grid.
140
+ *
94
141
  * @type {(cell: { tokens: any[], style?: any }, under: any, intl?: any) => { value: any, format: any }}
95
142
  */
96
143
  let field = (cell, under, intl) => {
97
- let resolved = merge(under, cell.style);
144
+ let styled = styledRuns(cell.tokens);
145
+ if (styled.length > 1) return rich(cell, styled, under, intl);
146
+ let resolved = merge(under, soleStyle(cell, styled));
98
147
  let shown = shownOf(cell, resolved?.format);
99
148
  let out = look(resolved);
100
149
  paintFmt(out, resolved, shown, intl);
package/lib/index.js CHANGED
@@ -15,10 +15,9 @@
15
15
  * the packing time. Byte-for-byte output is a goal for the writer that will
16
16
  * replace `workbook.js`.
17
17
  */
18
- import { imageError, walk } from "quario";
18
+ import { walk } from "quario";
19
19
  import { field } from "./cell.js";
20
20
  import { merge as under } from "./style.js";
21
- import { pixels } from "./image.js";
22
21
  import { append, create, embed, freeze, mark, place, save, sheet, span } from "./workbook.js";
23
22
 
24
23
  // The options are described once, in the hand-written public declarations, and
@@ -27,17 +26,19 @@ import { append, create, embed, freeze, mark, place, save, sheet, span } from ".
27
26
 
28
27
  // Band-role omakase defaults, applied under the author's own style so a
29
28
  // declared style always wins. Only the headline roles carry one; every other
30
- // band writes plain cells. The PDF target carries the same two, byte-identical,
31
- // in its own `style.js` SCHEMA.md states the pair for both, so they move
32
- // together, and a target imports only public engine helpers so there is nowhere
33
- // to share them from.
29
+ // band writes plain cells. SCHEMA.md states the pair, and a target imports only
30
+ // public engine helpers so there is nowhere to share them from.
34
31
  //
35
32
  // `@quario/html` deliberately carries none of them: its consumer has a
36
33
  // stylesheet and the `q-*` classes are the seam, where a worksheet has neither.
37
34
  // The rule is docs/adr/0014-a-target-supplies-defaults-only-where-its-consumer-has-no-seam.md —
38
35
  // a target supplies defaults only where its consumer has no seam to supply
39
- // them. `@quario/html/style.css` and `packages/viewer/lib/style.js` supply them for
40
- // that target; keep all four in agreement.
36
+ // them.
37
+ //
38
+ // `test/omakase-defaults.test.js` is what holds the copies in agreement. This
39
+ // comment names the gate rather than listing the files, because the list is
40
+ // what rotted: it used to name `packages/viewer/lib/style.js` and a `style.js`
41
+ // of the PDF target's own, and neither file exists.
41
42
  /** @type {Record<string, any>} */
42
43
  let ROLES = {
43
44
  "report-header": { bold: true, size: 14 },
@@ -195,15 +196,14 @@ export function xlsx(options) {
195
196
  image: (event) => {
196
197
  let known = images.get(event.bytes);
197
198
  if (!known) {
198
- let size = pixels(event.bytes, event.format);
199
- // The engine vouched for the magic numbers, not for the rest of the
200
- // file. A header too short to carry a size leaves nothing to place a
201
- // drawing at, and failing beats dropping the picture in silence
202
- // (SCHEMA.md, "Image item"). Named on the item through the engine's
203
- // own mint, so this failure reads the same from here as it does from
204
- // a target that paints or embeds.
205
- if (!size) throw imageError(event.path, "could not read the image's size from its bytes");
206
- known = { ...size, id: embed(workbook, event.bytes, event.format) };
199
+ // The event states the size in pixels, which is the unit a floating
200
+ // drawing is placed in, so this target has nothing to work out: the
201
+ // engine read the header once for every target (`docs/adr/0067`).
202
+ known = {
203
+ width: event.width,
204
+ height: event.height,
205
+ id: embed(workbook, event.bytes, event.format),
206
+ };
207
207
  images.set(event.bytes, known);
208
208
  }
209
209
  if (split) {
package/lib/style.js CHANGED
@@ -22,6 +22,7 @@ let FAMILY = { sans: "Calibri", serif: "Times New Roman", mono: "Courier New" };
22
22
 
23
23
  // A declared colour as an ARGB string, or null when the value is not one.
24
24
  /** @type {(value: any) => string | null} */
25
+ // fallow-ignore-next-line code-duplication -- each target reads a declared hex into its own type; neither may import a sibling, and the engine's stream is all they share
25
26
  let argb = (value) => {
26
27
  let match = typeof value === "string" && HEX.exec(value);
27
28
  if (!match) return null;
@@ -138,4 +139,4 @@ let format = (style) => {
138
139
  return put(out, "alignment", alignment(style));
139
140
  };
140
141
 
141
- export { format, merge };
142
+ export { font, format, merge };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/xlsx",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "The spreadsheet render target for quario — in the makings, not yet released",
5
5
  "homepage": "https://getquario.com",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -42,12 +42,12 @@
42
42
  "@arethetypeswrong/cli": "^0.18.3",
43
43
  "@size-limit/preset-small-lib": "^13.0.3",
44
44
  "@types/node": "^22.20.1",
45
- "quario": "^0.6.0",
45
+ "quario": "^0.8.0",
46
46
  "size-limit": "^13.0.3",
47
47
  "typescript": "^7.0.2"
48
48
  },
49
49
  "peerDependencies": {
50
- "quario": "^0.6.0"
50
+ "quario": "^0.8.0"
51
51
  },
52
52
  "size-limit": [
53
53
  {
package/lib/image.js DELETED
@@ -1,45 +0,0 @@
1
- /**
2
- * An image's own size, read from its header. A floating drawing is placed in
3
- * pixels, and the event carries the bytes and the format the engine sniffed
4
- * but no dimensions, so the one thing this target needs to place a picture is
5
- * worked out here — from the header alone, never by decoding the image.
6
- *
7
- * The PDF target reads the same two headers for its own placement, in points.
8
- * Restated per target rather than shared, like every other coercion at a
9
- * target's edge: neither package imports the other, and the engine's stream is
10
- * all they have in common.
11
- */
12
-
13
- /** @type {(bytes: Uint8Array, at: number) => number} */
14
- let word = (bytes, at) => (bytes[at] << 8) | bytes[at + 1];
15
-
16
- // The dimensions live in the frame header, the first SOFn marker: every
17
- // code in C0..CF except the three in that range that are not frames --
18
- // DHT, the JPG extension, and DAC.
19
- /** @type {Record<number, number>} */
20
- let SKIP = { 0xc4: 1, 0xc8: 1, 0xcc: 1 };
21
- /** @type {(code: number) => any} */
22
- let sof = (code) => code >= 0xc0 && code <= 0xcf && !SKIP[code];
23
-
24
- /** @type {(bytes: Uint8Array) => { width: number, height: number }} */
25
- let jpeg = (bytes) => {
26
- for (let at = 2; at + 9 < bytes.length; at += 2 + word(bytes, at + 2)) {
27
- if (bytes[at] !== 0xff) break;
28
- if (sof(bytes[at + 1])) return { width: word(bytes, at + 7), height: word(bytes, at + 5) };
29
- }
30
- return { width: 0, height: 0 };
31
- };
32
-
33
- /**
34
- * The image's size in pixels, or null when the header does not carry one —
35
- * bytes the engine vouched the magic numbers of, truncated before the size.
36
- *
37
- * @param {Uint8Array} bytes The image file.
38
- * @param {string} format The engine's sniff: `"png"` or `"jpeg"`.
39
- * @returns {{ width: number, height: number } | null} The size, if readable.
40
- */
41
- export let pixels = (bytes, format) => {
42
- // A PNG carries the two numbers in its IHDR at a fixed offset.
43
- let size = format === "png" ? { width: word(bytes, 18), height: word(bytes, 22) } : jpeg(bytes);
44
- return size.width > 0 && size.height > 0 ? size : null;
45
- };