@quario/viewer 0.4.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/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-05
11
+
12
+ ### Changed
13
+
14
+ - **Previewing with `fonts` now needs `fontkit`, not `@pdf-lib/fontkit`.**
15
+ Install `fontkit` instead. The viewer measures host TrueType faces through
16
+ `@quario/layout`, whose optional parser this is; the old package crashed on
17
+ any OpenType face needing a shaping state machine, so those faces now
18
+ preview where they used to throw.
19
+
20
+ - **The bar's controls are drawn icons.** The zoom trigger, the menu's check
21
+ and the error panel's dismiss are drawn from Lucide's set rather than by
22
+ hand, at one weight and one grid, and the dismiss no longer relies on the
23
+ host's font to draw a multiplication sign. They paint in `currentColor`, so
24
+ `--qv-icon` and `--qv-icon-active` still recolour them.
25
+
26
+ - **An export button names its format in words.** Each one is a download icon
27
+ with `PDF`, `XLSX` or `CSV` beside it, in place of the sheet drawing that
28
+ lettered the format inside itself. The buttons are wider; the accessible
29
+ name is unchanged.
30
+
31
+ ### Fixed
32
+
33
+ - **A zoom no longer leaves a page part-drawn.** Changing the zoom while a
34
+ page was still painting could leave that page carrying content at the old
35
+ scale on a canvas sized for the new one — the rest of it blank — and nothing
36
+ repainted it afterwards: scrolling away and back left it as it was. A page
37
+ whose paint is still in flight is now replaced rather than reused, so the
38
+ superseded paint has nowhere to land.
39
+
10
40
  ## [0.4.0] - 2026-09-03
11
41
 
12
42
  ### Added
package/README.md CHANGED
@@ -41,8 +41,11 @@ view.filename = "sales";
41
41
  ```
42
42
 
43
43
  with `<quario-viewer></quario-viewer>` in your markup, sized by your own CSS. The element is
44
- `display: block`. Importing `@quario/viewer` defines nothing: the main entry exports the
45
- `QuarioViewer` class and is side-effect-free, and `@quario/viewer/register` performs the one-line
44
+ `display: block`, and its chrome is laid out to fit a width of **320px** — a phone-width embed is
45
+ the narrowest the bar is designed for. Narrower than that and the export buttons push the zoom
46
+ control off the left of the bar, quietly: the bar does not wrap or scroll. Importing
47
+ `@quario/viewer` defines nothing: the main entry exports the `QuarioViewer` class and is
48
+ side-effect-free, and `@quario/viewer/register` performs the one-line
46
49
  `customElements.define("quario-viewer", QuarioViewer)`. A host that wants its own tag imports the
47
50
  class and defines it itself. The repository ships this wiring as a runnable page at
48
51
  `example/viewer.js`.
@@ -84,16 +87,16 @@ out.
84
87
  ```js
85
88
  view.addEventListener("rendered", () => {});
86
89
  view.addEventListener("error", ({ detail: { error, kind } }) => {});
87
- await view.renderComplete; // true when the newest render reached the sheet
90
+ await view.renderComplete; // true when the newest render landed on the sheet
88
91
  ```
89
92
 
90
- `rendered` fires each time a render reaches the sheet; `error` fires for every failure you should
93
+ `rendered` fires each time a render lands on the sheet; `error` fires for every failure you should
91
94
  know about, with `detail.kind` naming which: `"mount-render"` until a render has ever landed,
92
95
  `"update-render"` after, `"export"` for a download that could not be produced. Both events are
93
96
  non-bubbling, like `<img>`'s. Listen on the element. (Because the event is named `error`, an
94
97
  inline `onerror` attribute on the element would fire too; `window.onerror` never sees it.)
95
98
 
96
- `renderComplete` awaits the newest render settling: `true` when it reached the sheet with the pages
99
+ `renderComplete` awaits the newest render settling: `true` when it landed on the sheet with the pages
97
100
  on screen painted, `false` when it failed or there was nothing to render. It never rejects; failures
98
101
  arrive on the `error` event. `rendered` fires at that same moment. Like every outcome here, it
99
102
  answers for the newest render only. A superseded render's failure is reported to no one.
@@ -271,7 +274,7 @@ the export is, watermark and all.
271
274
 
272
275
  When a render or an export fails, the viewer says so on the **error panel**, a strip across the top
273
276
  of the sheet carrying a short label and the error's own message. It replaces rather than stacks, is
274
- dismissed by its own button, and is cleared by the next render that reaches the sheet. A successful
277
+ dismissed by its own button, and is cleared by the next render that lands on the sheet. A successful
275
278
  export leaves it up, because the panel describes what you are looking at and a download says
276
279
  nothing about that.
277
280
 
package/lib/button.js CHANGED
@@ -18,6 +18,11 @@ export let BUTTON = css`
18
18
  border-radius: 5px;
19
19
  background: transparent;
20
20
  color: var(--_icon);
21
+ /* A form control does not inherit \`font\`, so the chrome's own 13px/1.4
22
+ system-ui stops at every button unless it is restated here. The shorthand
23
+ rather than \`font-family\` alone: the UA sets the shorthand, so restating
24
+ one longhand leaves size and line-height at the UA's. */
25
+ font: inherit;
21
26
  cursor: pointer;
22
27
  }
23
28
 
@@ -63,9 +68,11 @@ let fallback = (value, otherwise) => value ?? otherwise;
63
68
  * @param {{ title: string, label?: string, disabled?: boolean, name?: string,
64
69
  * popover?: string, click?: () => void,
65
70
  * content?: import('lit').TemplateResult }} control
66
- * `label` is visible text for the controls that read as words; `content`
67
- * is the icon template the zoom trigger and the export buttons pass
68
- * instead. `name` marks an export button with the target it downloads, and
71
+ * `content` is the icon a control draws and `label` the visible text it
72
+ * reads as; an export button carries both, the zoom trigger and the panel's
73
+ * dismiss the icon alone. Neither is ever the accessible name — `title` is,
74
+ * which is why the icons are `aria-hidden`. `name` marks an export button
75
+ * with the target it downloads, and
69
76
  * `popover` names the menu this button opens — the platform then owns the
70
77
  * opening and the invoker's own `aria-expanded`, which is why such a button
71
78
  * needs no click of its own.
@@ -83,6 +90,6 @@ export let button = ({ title, label, disabled, name, popover, click, content })
83
90
  ?disabled=${disabled}
84
91
  @click=${click}
85
92
  >
86
- ${fallback(label, content)}
93
+ ${content ?? nothing}${label ?? nothing}
87
94
  </button>
88
95
  `;
package/lib/check.js CHANGED
@@ -94,8 +94,9 @@ export let name = (filename) => {
94
94
  return base;
95
95
  };
96
96
 
97
- /** @type {Record<string, string>} */
98
- let SCHEME = { light: "light", dark: "dark", auto: "light dark" };
97
+ /** The schemes the property accepts. Two are their own CSS value; `auto` is
98
+ * the one that is not, so it is the only wording written here. */
99
+ let SCHEMES = ["light", "dark", "auto"];
99
100
 
100
101
  /**
101
102
  * The used `color-scheme` from the `colorScheme` property. Light is the
@@ -108,6 +109,6 @@ let SCHEME = { light: "light", dark: "dark", auto: "light dark" };
108
109
  */
109
110
  export let scheme = (colorScheme) => {
110
111
  let pin = colorScheme ?? "light";
111
- if (!Object.hasOwn(SCHEME, pin)) fail('colorScheme: expected "light", "dark", or "auto"');
112
- return SCHEME[pin];
112
+ if (!SCHEMES.includes(pin)) fail('colorScheme: expected "light", "dark", or "auto"');
113
+ return pin === "auto" ? "light dark" : pin;
113
114
  };
package/lib/chrome.js CHANGED
@@ -166,6 +166,25 @@ export let CHROME = css`
166
166
  display: flex;
167
167
  gap: 2px;
168
168
  }
169
+
170
+ /* An export button reads as a drawn download and the format in words, so it
171
+ takes the width its text needs rather than the icon-only box. */
172
+ .qv-exports .qv-button {
173
+ gap: 5px;
174
+ width: auto;
175
+ padding: 0 9px;
176
+ }
177
+
178
+ /* The base icon size, once per package rather than once per control: an
179
+ inline <svg> carrying a viewBox and no width falls back to 300x150, and
180
+ nothing in the chrome wants that. 18px is 0.75 of Lucide's 24 grid, which
181
+ puts its stroke 2 at 1.5px beside 13px text. The narrower selectors that
182
+ drop to 14px live with the controls they belong to — the menu's tick slot
183
+ in menu.js (ADR 0044). */
184
+ .qv-viewer svg {
185
+ width: 18px;
186
+ height: 18px;
187
+ }
169
188
  `;
170
189
 
171
190
  /**
package/lib/icons.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The icons @quario/viewer draws, vendored from Lucide: one export per icon,
3
+ * finished templates that carry no size. Chrome CSS decides that — 18px by
4
+ * default, 14px in the gutter and the menu's tick slot — because `x` appears
5
+ * in two contexts at once (ADR 0044).
6
+ *
7
+ * Generated by `scripts/icons.mjs` from lucide 1.40.0. Do not edit:
8
+ * `npm run icons` rewrites it, and `npm run check` regenerates it into a
9
+ * temp directory and compares.
10
+ *
11
+ * Lucide is ISC licensed. Copyright (c) 2026 Lucide Icons and Contributors.
12
+ */
13
+ import { html, svg } from "lit";
14
+
15
+ /** Upstream's grid and stroke, written once. */
16
+ /** @type {(paths: import('lit').SVGTemplateResult) => import('lit').TemplateResult} */
17
+ let icon = (paths) =>
18
+ html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
19
+
20
+ export let check = icon(svg`<path d="M20 6 9 17l-5-5"/>`);
21
+ export let download = icon(
22
+ svg`<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>`,
23
+ );
24
+ export let search = icon(svg`<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>`);
25
+ export let x = icon(svg`<path d="M18 6 6 18"/><path d="m6 6 12 12"/>`);
package/lib/index.d.ts CHANGED
@@ -31,8 +31,8 @@ export interface ViewerErrorDetail {
31
31
  /** The caught value, exactly as thrown. */
32
32
  error: unknown;
33
33
  /**
34
- * Which failure occurred: `mount-render` until a render has ever reached
35
- * the sheet, `update-render` after, `export` for a download that could not
34
+ * Which failure occurred: `mount-render` until a render has ever landed
35
+ * on the sheet, `update-render` after, `export` for a download that could not
36
36
  * be produced.
37
37
  */
38
38
  kind: ViewerErrorKind;
@@ -96,7 +96,7 @@ export class QuarioViewer extends LitElement {
96
96
  */
97
97
  colorScheme: "light" | "dark" | "auto" | undefined;
98
98
  /**
99
- * The newest render settling: `true` when it reached the sheet with the
99
+ * The newest render settling: `true` when it landed on the sheet with the
100
100
  * pages on screen painted, `false` when it failed or there was nothing to
101
101
  * render. Never rejects — failures are the `error` event's — and like every
102
102
  * outcome here it answers for the newest render only. `rendered` fires at
@@ -128,7 +128,7 @@ export class QuarioViewer extends LitElement {
128
128
 
129
129
  /** The element's events. Both are non-bubbling, like `<img>`'s. */
130
130
  export interface QuarioViewerEventMap {
131
- /** A render reached the sheet — the mount and every update. */
131
+ /** A render landed on the sheet — the mount and every update. */
132
132
  rendered: CustomEvent<undefined>;
133
133
  /** A failure the host should know about; `detail.kind` says which. */
134
134
  error: CustomEvent<ViewerErrorDetail>;
package/lib/index.js CHANGED
@@ -33,7 +33,7 @@ import { exports, geometry, level, name, scheme } from "./check.js";
33
33
  import { MENU, zoomMenu } from "./menu.js";
34
34
  import { PANEL, label, panel } from "./panel.js";
35
35
  import { SURFACE, stage } from "./stage.js";
36
- import { EXPORTS, download, exportGroup } from "./toolbar.js";
36
+ import { EXPORTS, exportGroup, saveAs } from "./toolbar.js";
37
37
  import { wanted } from "./zoom.js";
38
38
 
39
39
  /** @type {(report: unknown, targets: unknown) => boolean} */
@@ -89,7 +89,7 @@ export class QuarioViewer extends LitElement {
89
89
  // fails and a disconnect that does not. The template interpolates its
90
90
  // element as a node, which lit-html leaves untouched across re-renders.
91
91
  #stage = stage();
92
- /** Whether a render has ever reached the sheet — the mount/update boundary. */
92
+ /** Whether a render has ever landed on the sheet — the mount/update boundary. */
93
93
  #landed = false;
94
94
  /** Whether the sheet stopped matching the properties while disconnected. */
95
95
  #stale = false;
@@ -135,7 +135,7 @@ export class QuarioViewer extends LitElement {
135
135
  // The engine takes no signal, so abandonment is the guards around this
136
136
  // body; the check only spares the swap when the answer arrives after a
137
137
  // disconnect mid-render.
138
- if (this.#drop(signal)) return this.#abandon();
138
+ if (signal.aborted && !this.isConnected) return this.#abandon();
139
139
  return { list, fonts };
140
140
  },
141
141
  onComplete: (result) => {
@@ -145,7 +145,7 @@ export class QuarioViewer extends LitElement {
145
145
  this.#painting = this.#stage.swap(result.list, result.fonts).then(() => {
146
146
  this.#landed = true;
147
147
  this.#stale = false;
148
- // A render that reached the sheet takes the panel down: the panel
148
+ // A render that landed on the sheet takes the panel down: the panel
149
149
  // says what is wrong with what the reader is looking at, and this is
150
150
  // the moment that stops being true. A successful export is not that
151
151
  // moment.
@@ -186,7 +186,7 @@ export class QuarioViewer extends LitElement {
186
186
  }
187
187
 
188
188
  /**
189
- * The newest render settling: `true` when it reached the sheet, `false`
189
+ * The newest render settling: `true` when it landed on the sheet, `false`
190
190
  * when it failed or there was nothing to render. It never rejects — a
191
191
  * failed render is handled, on the panel and through the error event — and
192
192
  * like every outcome here it answers for the newest render only.
@@ -238,7 +238,7 @@ export class QuarioViewer extends LitElement {
238
238
  })}
239
239
  </div>
240
240
  <div class="qv-body">
241
- ${this.#panel()}
241
+ ${this.#failure && !this.#dismissed ? panel(this.#failure, () => this.#dismiss()) : ""}
242
242
  ${this.#stage.element}
243
243
  </div>
244
244
  </div>
@@ -259,7 +259,9 @@ export class QuarioViewer extends LitElement {
259
259
  super.connectedCallback();
260
260
  // A fitted sheet follows the host's box; the observer is released on
261
261
  // disconnect, so reconnection takes it again.
262
- this.#watchStage();
262
+ this.#unwatch ??= this.#stage.watch(() => {
263
+ if (this.#mode === "fit" && this.#wanted() !== this.#stage.percent()) this.#apply();
264
+ });
263
265
  // Reconnect re-renders from the current properties — but only when the
264
266
  // sheet stopped matching them: a render was abandoned mid-flight, or a
265
267
  // property write landed while disconnected. The task's arguments did not
@@ -295,11 +297,6 @@ export class QuarioViewer extends LitElement {
295
297
  return true;
296
298
  }
297
299
 
298
- /** @param {AbortSignal} signal */
299
- #drop(signal) {
300
- return signal.aborted && !this.isConnected;
301
- }
302
-
303
300
  #abandon() {
304
301
  this.#stale = true;
305
302
  return null;
@@ -361,18 +358,6 @@ export class QuarioViewer extends LitElement {
361
358
  if (said(was) !== said(this.#invalid)) this.#epoch++;
362
359
  }
363
360
 
364
- #panel() {
365
- return this.#failure && !this.#dismissed ? panel(this.#failure, () => this.#dismiss()) : "";
366
- }
367
-
368
- #onResize() {
369
- if (this.#mode === "fit" && this.#wanted() !== this.#stage.percent()) this.#apply();
370
- }
371
-
372
- #watchStage() {
373
- this.#unwatch ??= this.#stage.watch(() => this.#onResize());
374
- }
375
-
376
361
  #wake() {
377
362
  if (this.#stale || this.#task.status === TaskStatus.PENDING) {
378
363
  this.#epoch++;
@@ -442,12 +427,13 @@ export class QuarioViewer extends LitElement {
442
427
  * @param {any} target
443
428
  */
444
429
  async #export(target) {
445
- let { type, label: format } = EXPORTS[target.name];
430
+ let type = EXPORTS[target.name];
431
+ let format = target.name.toUpperCase();
446
432
  this.#exporting.add(target.name);
447
433
  this.requestUpdate();
448
434
  try {
449
435
  let body = await /** @type {any} */ (this.report).render(target, this.data);
450
- this.#ifConnected(() => download(body, this.#name + "." + target.name, type));
436
+ this.#ifConnected(() => saveAs(body, this.#name + "." + target.name, type));
451
437
  } catch (error) {
452
438
  this.#ifConnected(() => this.#announce(error, "export", format));
453
439
  } finally {
package/lib/menu.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The zoom menu: the magnifier trigger in the bar and the popover it opens —
2
+ * The zoom menu: the search trigger in the bar and the popover it opens —
3
3
  * "Fit page" above a separator, then the percentages `zoom.js` offers. It is
4
4
  * the whole zoom control; the live percentage is never drawn, it is what the
5
5
  * trigger is named after ("Zoom, 62%"), so the bar carries one icon rather
@@ -29,6 +29,7 @@
29
29
  */
30
30
  import { css, html } from "lit";
31
31
  import { button } from "./button.js";
32
+ import { check, search } from "./icons.js";
32
33
  import { STEPS } from "./zoom.js";
33
34
 
34
35
  /** The popover's id, scoped to the shadow root, so two viewers never collide. */
@@ -85,12 +86,22 @@ export let MENU = css`
85
86
  }
86
87
 
87
88
  /* The check's slot is held whether or not a check is in it, so the labels
88
- line up down the menu. */
89
+ line up down the menu. 14px rather than 13: the small icon size serves this
90
+ slot and the editor's gutter alike, which is one size fewer than buying a
91
+ third for one glyph (ADR 0044). */
89
92
  .qv-tick {
90
93
  display: inline-flex;
91
94
  flex: none;
92
- width: 13px;
93
- height: 13px;
95
+ width: 14px;
96
+ height: 14px;
97
+ }
98
+
99
+ /* Narrower than the chrome's 18px base on specificity, not on where this
100
+ sheet sits in \`static styles\` — reordering that array must not silently
101
+ put the tick back to 18px. */
102
+ .qv-viewer .qv-tick svg {
103
+ width: 14px;
104
+ height: 14px;
94
105
  }
95
106
 
96
107
  .qv-separator {
@@ -100,30 +111,6 @@ export let MENU = css`
100
111
  }
101
112
  `;
102
113
 
103
- /** @type {import('lit').TemplateResult} */
104
- let magnifier = html`
105
- <svg viewBox="0 0 20 20" width="19" height="19" aria-hidden="true">
106
- <g fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">
107
- <circle cx="8.75" cy="8.75" r="5.25"></circle>
108
- <path d="M12.6 12.6 16.4 16.4"></path>
109
- </g>
110
- </svg>
111
- `;
112
-
113
- /** @type {import('lit').TemplateResult} */
114
- let tick = html`
115
- <svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
116
- <path
117
- d="M3.4 8.4 6.4 11.4 12.6 4.8"
118
- fill="none"
119
- stroke="currentColor"
120
- stroke-width="1.9"
121
- stroke-linecap="round"
122
- stroke-linejoin="round"
123
- ></path>
124
- </svg>
125
- `;
126
-
127
114
  /**
128
115
  * The button that opens this menu, found by the invoker relationship itself
129
116
  * rather than by where it sits: a wrapper added for layout, or a second
@@ -276,7 +263,7 @@ let row = (label, value, checked, choose) => html`
276
263
  data-zoom=${String(value)}
277
264
  @click=${pick(value, choose)}
278
265
  >
279
- <span class="qv-tick">${checked ? tick : ""}</span>
266
+ <span class="qv-tick">${checked ? check : ""}</span>
280
267
  ${label}
281
268
  </button>
282
269
  `;
@@ -298,7 +285,7 @@ export let zoomMenu = ({ mode, percent, choose }) => html`
298
285
  ${button({
299
286
  title: "Zoom, " + percent + "%",
300
287
  popover: ID,
301
- content: magnifier,
288
+ content: search,
302
289
  })}
303
290
  <div
304
291
  id=${ID}
package/lib/panel.js CHANGED
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { css, html } from "lit";
21
21
  import { button } from "./button.js";
22
+ import { x } from "./icons.js";
22
23
 
23
24
  export let PANEL = css`
24
25
  /* Positioned rather than laid out, for the reason the progress strip is: a
@@ -121,6 +122,6 @@ export let panel = (failure, dismiss) => html`
121
122
  <p class="qv-error-label">${failure.label}</p>
122
123
  <p class="qv-error-message">${wording(failure.error)}</p>
123
124
  </div>
124
- ${button({ title: "Dismiss", label: "×", click: dismiss })}
125
+ ${button({ title: "Dismiss", content: x, click: dismiss })}
125
126
  </div>
126
127
  `;
package/lib/stage.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * never reflows (zoom.js says why).
13
13
  *
14
14
  * Every page keeps its CSS size, but only the
15
- * [band](../../../CONTEXT.md#band) carries pixels — the pages on screen and
15
+ * [reach](../../../CONTEXT.md#reach) carries pixels — the pages on screen and
16
16
  * one viewport height either side. The extent the reader scrolls through is
17
17
  * whole and synchronous whatever is painted; what comes and goes is the
18
18
  * backing store, released by sizing a canvas to 0 × 0. A scroll or a resize
@@ -21,6 +21,28 @@
21
21
  * asked a thousand-page report for gigabytes of backing store, and past what
22
22
  * the browser would grant the pages simply came up blank.
23
23
  *
24
+ * **A canvas on the sheet is never re-pointed at a second paint.** `paint()`
25
+ * awaits before it draws, so a canvas whose paint is still in flight is
26
+ * *retired* rather than painted over or emptied: it comes off the sheet, its
27
+ * pixels go back, and a fresh one stands in its place, so the superseded
28
+ * paint draws into an element nobody is looking at. A canvas whose paint has
29
+ * settled has nothing that could land late and is re-sized or emptied in
30
+ * place as before. Without this a zoom landing mid-paint could leave a page
31
+ * carrying old-scale content on a new-scale canvas, with the memo below
32
+ * calling it painted so that nothing repainted it again (ADR 0046).
33
+ *
34
+ * Retirement is lazy, and only the paint is guarded. A scale change sizes
35
+ * every page's CSS box at once but retires a page only when the repaint loop
36
+ * reaches it, so a page further down the reach shows its old pixels stretched
37
+ * into the new box until its turn comes — the ordinary look of a zoom in
38
+ * progress, not the artefact above. The cost is that a retired page is a
39
+ * replaced `role="img"` node: assistive technology reading that page sees it
40
+ * swapped under them. Judged acceptable because retirement only happens while
41
+ * that very page is mid-repaint and about to change what it shows anyway, and
42
+ * because the alternative — a stable wrapper element per page to announce
43
+ * from — is a second element per page on a sheet ADR 0043 exists to keep
44
+ * cheap.
45
+ *
24
46
  * What the stage does not decide is which percentage to show: `fit()`
25
47
  * measures what would make one page span the width available, and `zoom.js`
26
48
  * owns the policy over that answer. What is on screen, though, is the
@@ -79,7 +101,7 @@ export let SURFACE = css`
79
101
  }
80
102
 
81
103
  /* One page. The painter fills it white; the background here is what a page
82
- outside the band shows, and what one inside it shows between being sized
104
+ outside the reach shows, and what one inside it shows between being sized
83
105
  and being painted. */
84
106
  .qv-page {
85
107
  display: block;
@@ -131,7 +153,7 @@ export let stage = () => {
131
153
 
132
154
  /**
133
155
  * What each backed page is carrying: the scale it was painted at, so a page
134
- * re-entering the band at that scale is not repainted and a scale change
156
+ * re-entering the reach at that scale is not repainted and a scale change
135
157
  * invalidates it, and the paint itself, so whoever asks second waits behind
136
158
  * the paint the first caller started rather than being told it is done.
137
159
  * Keyed by the canvas, so a swap's new pages start with nothing remembered
@@ -143,84 +165,211 @@ export let stage = () => {
143
165
  let backed = new WeakMap();
144
166
 
145
167
  /**
146
- * Give a page's pixels back. Sizing the canvas to 0 × 0 is the one idiom
147
- * that frees the store synchronously in every engine the viewer runs in;
148
- * the CSS size is untouched, so the page keeps its place in the extent and
149
- * shows the sheet's white.
168
+ * The canvases whose paint has finished. A promise's settled state is not
169
+ * synchronously observable and this is the one question the rule below
170
+ * turns on: a canvas in here has nothing left that could draw into it, so
171
+ * it can be emptied or re-sized in place; one that is backed but absent
172
+ * here is still being painted, and must be retired instead. Weak on the
173
+ * same key as `backed`, so a retired canvas takes its membership with it.
174
+ *
175
+ * Membership is per paint, not per canvas — `start` takes a canvas out
176
+ * before painting it again, or a page settled at one scale would count as
177
+ * settled the moment it began painting at the next.
178
+ *
179
+ * @type {WeakSet<HTMLCanvasElement>}
180
+ */
181
+ let settled = new WeakSet();
182
+
183
+ /** CSS pixels per point at the applied percentage. */
184
+ let ratio = () => (PX_PER_POINT * applied) / 100;
185
+
186
+ /**
187
+ * The canvas standing for page `i` right now. Read through here and never
188
+ * held: a retired page is a different element, so a canvas taken before an
189
+ * await may be off the sheet by the time it is used.
190
+ *
191
+ * @type {(i: number) => HTMLCanvasElement}
192
+ */
193
+ let pageAt = (i) => /** @type {HTMLCanvasElement} */ (sheet.children[i]);
194
+
195
+ /**
196
+ * Give a page's canvas its size on screen. The one answer to how big page
197
+ * `i` is at the applied percentage: `pageOf` builds a canvas with it and
198
+ * `sizeAll` writes it over the sheet after a scale change, so a page
199
+ * retired between the two cannot arrive sizeless and move the extent the
200
+ * reader is scrolling through.
201
+ *
202
+ * @type {(canvas: HTMLElement, i: number, px?: number) => void}
203
+ */
204
+ let sizePage = (canvas, i, px = ratio()) => {
205
+ let each = list.pages[i];
206
+ canvas.style.width = each.width * px + "px";
207
+ canvas.style.height = each.height * px + "px";
208
+ };
209
+
210
+ /**
211
+ * One page of the list as a canvas: sized on screen, carrying no pixels
212
+ * yet, and named for a screen reader. Every canvas on the sheet is built
213
+ * here — a swap's and a retirement's alike — so a replacement is the same
214
+ * element in every respect but identity.
150
215
  *
151
- * @type {(canvas: HTMLCanvasElement) => void}
216
+ * @type {(i: number) => HTMLCanvasElement}
152
217
  */
153
- let release = (canvas) => {
218
+ let pageOf = (i) => {
219
+ let canvas = document.createElement("canvas");
220
+ canvas.className = "qv-page";
221
+ // A canvas is 300 x 150 until told otherwise, and that store counts. A
222
+ // page starts with none and takes one once it is inside the reach.
223
+ canvas.width = 0;
224
+ canvas.height = 0;
225
+ canvas.setAttribute("role", "img");
226
+ canvas.setAttribute("aria-label", "Page " + (i + 1) + " of " + list.pages.length);
227
+ sizePage(canvas, i);
228
+ return canvas;
229
+ };
230
+
231
+ /**
232
+ * Take page `i`'s canvas off the sheet and stand a fresh one in its place,
233
+ * answering with the replacement. A paint still in flight holds the old
234
+ * canvas's context and draws into something nobody is looking at, which is
235
+ * how a superseded paint is stopped here — by construction, rather than by
236
+ * a check the painter would have to make above its own draw (ADR 0046).
237
+ * The retired canvas takes its entries in `backed` and `settled` with it.
238
+ *
239
+ * Its pixels go back at once, by the same 0 × 0 idiom `release` uses: the
240
+ * store is what ADR 0043 rations, and leaving it to be collected whenever
241
+ * the superseded paint lets go of the context is the timing that ADR
242
+ * refuses. `paint()` does its whole `save`/draw/`restore` after its awaits,
243
+ * so emptying the canvas between them unbalances nothing — it just leaves
244
+ * every op clipped to nothing, which spares the raster work too.
245
+ *
246
+ * @type {(i: number) => HTMLCanvasElement}
247
+ */
248
+ let retire = (i) => {
249
+ let old = pageAt(i);
250
+ let fresh = pageOf(i);
251
+ old.replaceWith(fresh);
252
+ old.width = 0;
253
+ old.height = 0;
254
+ return fresh;
255
+ };
256
+
257
+ /**
258
+ * Give page `i`'s pixels back. A page whose paint has settled is emptied in
259
+ * place: sizing the canvas to 0 × 0 is the one idiom that frees the store
260
+ * synchronously in every engine the viewer runs in, and the CSS size is
261
+ * untouched, so the page keeps its place in the extent and shows the
262
+ * sheet's white. A page still painting is retired instead — emptying it
263
+ * would leave that paint pointed at a canvas the next pass over the reach
264
+ * re-sizes and re-paints.
265
+ *
266
+ * @type {(i: number) => void}
267
+ */
268
+ let release = (i) => {
269
+ let canvas = pageAt(i);
154
270
  if (!backed.has(canvas)) return;
271
+ if (!settled.has(canvas)) return void retire(i);
155
272
  backed.delete(canvas);
156
273
  canvas.width = 0;
157
274
  canvas.height = 0;
158
275
  };
159
276
 
160
- /** The page width the fit measures against: the list's, or the geometry's
161
- * before one arrives. */
162
- let pageWidth = () => (list ? list.width : fallback);
163
-
164
- /** CSS pixels per point at the applied percentage. */
165
- let ratio = () => (PX_PER_POINT * applied) / 100;
277
+ /** Device pixels per point at the applied percentage: what a page is
278
+ * painted at, and what its backing store is sized in. */
279
+ let deviceScale = () => ratio() * (globalThis.devicePixelRatio || 1);
166
280
 
167
281
  /**
168
- * Paint one page and answer with that paint or with the paint already
169
- * under way at this scale, which is what makes scrolling back over ground
170
- * already covered free. Answering with the paint rather than with a
171
- * resolved promise is what lets two callers reach the same page and both
172
- * settle behind its pixels. A paint that failed is forgotten, so the next
173
- * pass over the band tries again instead of counting the page as painted.
282
+ * Start painting page `i`, and answer with that paint. **The one place a
283
+ * paint begins, and the one place the rule is enforced**: a canvas whose
284
+ * paint is still in flight is retired here before a second one is pointed
285
+ * at it, so no caller can reach a live canvas with a second paint by
286
+ * forgetting to ask first (ADR 0046). A paint that failed is forgotten, so
287
+ * the next pass over the reach tries again instead of counting the page as
288
+ * painted; one that settles joins `settled`.
174
289
  *
175
- * @type {(canvas: HTMLCanvasElement, i: number) => Promise<void>}
290
+ * @type {(i: number) => Promise<void>}
176
291
  */
177
- let paintPage = (canvas, i) => {
178
- let scale = ratio() * (globalThis.devicePixelRatio || 1);
179
- let carrying = backed.get(canvas);
180
- if (carrying && carrying.scale === scale) return carrying.painted;
292
+ let start = (i) => {
293
+ let canvas = pageAt(i);
294
+ if (backed.has(canvas) && !settled.has(canvas)) canvas = retire(i);
295
+ settled.delete(canvas);
296
+ let scale = deviceScale();
181
297
  let each = list.pages[i];
182
298
  canvas.width = Math.round(each.width * scale);
183
299
  canvas.height = Math.round(each.height * scale);
184
300
  let ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext("2d"));
185
- let painted = paint(ctx, each, { scale, fonts }).catch((failure) => {
186
- backed.delete(canvas);
187
- throw failure;
188
- });
301
+ let painted = paint(ctx, each, { scale, fonts }).then(
302
+ () => {
303
+ settled.add(canvas);
304
+ },
305
+ (failure) => {
306
+ backed.delete(canvas);
307
+ throw failure;
308
+ },
309
+ );
189
310
  backed.set(canvas, { scale, painted });
190
311
  return painted;
191
312
  };
192
313
 
193
- /** Bumped per repaint, so a repaint overtaken by the next stops painting. */
314
+ /**
315
+ * Paint one page and answer with that paint — or with the paint already
316
+ * under way at this scale, which is what makes scrolling back over ground
317
+ * already covered free. Answering with the paint rather than with a
318
+ * resolved promise is what lets two callers share one page's paint and both
319
+ * settle behind its pixels.
320
+ *
321
+ * Takes the index, not the canvas: the page it paints may be retired out
322
+ * from under a caller, so a caller that handed one in would be left holding
323
+ * an element that is no longer on the sheet.
324
+ *
325
+ * @type {(i: number) => Promise<void>}
326
+ */
327
+ let paintPage = (i) => {
328
+ let carrying = backed.get(pageAt(i));
329
+ if (carrying && carrying.scale === deviceScale()) return carrying.painted;
330
+ return start(i);
331
+ };
332
+
333
+ /**
334
+ * Bumped per repaint, so a repaint overtaken by the next stops walking.
335
+ * What that guards is the backing store, not the pixels: a superseded loop
336
+ * would paint at the *current* scale — `paintPage` reads it afresh — but
337
+ * onto pages the newer reach has since dropped, re-backing pages that
338
+ * should be blank (ADR 0043). Stale pixels are `retire`'s business, not
339
+ * this one, so neither guard stands in for the other.
340
+ *
341
+ * It guards repaint against repaint, and nothing else. A repaint chooses
342
+ * its pages once and holds that list across its awaits, so a scroll pass
343
+ * releasing a page mid-repaint is one this loop will paint anyway; the
344
+ * store that leaves behind is bounded by the reach and goes back on the
345
+ * next pass over it.
346
+ */
194
347
  let epoch = 0;
195
348
 
196
349
  /**
197
350
  * Give every page its CSS size. Callers run this before writing the scroll
198
351
  * offsets back: the extent those offsets are clamped against is this one,
199
- * and the band below is read from the offsets once they are in.
352
+ * and the reach below is read from the offsets once they are in.
200
353
  */
201
354
  let sizeAll = () => {
202
355
  if (!list) return;
203
356
  let px = ratio();
204
- for (let [i, each] of list.pages.entries()) {
205
- let canvas = /** @type {HTMLElement} */ (sheet.children[i]);
206
- canvas.style.width = each.width * px + "px";
207
- canvas.style.height = each.height * px + "px";
208
- }
357
+ for (let i = 0; i < list.pages.length; i++) sizePage(pageAt(i), i, px);
209
358
  };
210
359
 
211
360
  /**
212
- * The indices of the pages in the band — the ones the viewport shows, and
361
+ * The indices of the pages in the reach — the ones the viewport shows, and
213
362
  * everything within one viewport height above or below. Walked over the
214
363
  * list's own geometry (the gutter, each page's height at the applied
215
364
  * scale, the gap), never a layout read, so it costs nothing to ask
216
365
  * mid-scroll — the whole list is walked because a multiply and an add per
217
366
  * page is beneath measuring, and stopping early would be a second rule
218
- * about where the band ends. `clientHeight` is read afresh each time, so a
219
- * pane that changed size changes the band with it.
367
+ * about where the reach ends. `clientHeight` is read afresh each time, so a
368
+ * pane that changed size changes the reach with it.
220
369
  *
221
370
  * @returns {number[]}
222
371
  */
223
- let inBand = () => {
372
+ let inReach = () => {
224
373
  let view = scroll.clientHeight;
225
374
  let top = scroll.scrollTop - view;
226
375
  let bottom = scroll.scrollTop + 2 * view;
@@ -236,73 +385,59 @@ export let stage = () => {
236
385
  };
237
386
 
238
387
  /**
239
- * Release every page the band has left behind, and answer with the ones to
240
- * keep, each with its index in the order they are painted in. The single
388
+ * Release every page the reach has left behind, and answer with the indices
389
+ * of the ones to keep, in the order they are painted in. The single
241
390
  * definition of which pages carry pixels: the repaint below and the scroll
242
391
  * pass both go through here, and neither holds a page the other released.
243
392
  *
244
- * @type {() => [number, HTMLCanvasElement][]}
393
+ * Indices rather than elements, for the reason `paintPage` takes one: a
394
+ * page can be retired between this pass and the paint that follows it.
395
+ *
396
+ * @type {() => number[]}
245
397
  */
246
398
  let keep = () => {
247
- let wanted = new Set(inBand());
399
+ let wanted = new Set(inReach());
248
400
  let kept = [];
249
- for (let i = 0; i < sheet.children.length; i++) {
250
- let canvas = /** @type {HTMLCanvasElement} */ (sheet.children[i]);
251
- if (wanted.has(i)) kept.push(/** @type {[number, HTMLCanvasElement]} */ ([i, canvas]));
252
- else release(canvas);
401
+ // `retire` replaces one for one, so the count is fixed across the walk.
402
+ for (let i = 0, total = sheet.children.length; i < total; i++) {
403
+ if (wanted.has(i)) kept.push(i);
404
+ else release(i);
253
405
  }
254
406
  return kept;
255
407
  };
256
408
 
257
- // Paint the band in order, yielding between pages and giving way to any
409
+ // Paint the reach in order, yielding between pages and giving way to any
258
410
  // repaint that started since. Sizing is the caller's, and comes first.
259
411
  let repaint = async () => {
260
412
  if (!list) return;
261
413
  let mine = ++epoch;
262
- for (let [i, canvas] of keep()) {
414
+ for (let i of keep()) {
263
415
  if (mine !== epoch) return;
264
- await paintPage(canvas, i);
416
+ await paintPage(i);
265
417
  }
266
418
  };
267
419
 
268
420
  /**
269
- * The band pass a scroll or a resize runs. It keeps no epoch of its own, so
270
- * a swap painting behind it is never cut off part-painted, and a page it
271
- * starts is one a swap reaching the same page waits behind rather than
272
- * skips. Nothing awaits these paints here: a scroll is not a render and has
273
- * no failure channel of its own, and a paint that failed is forgotten, so
274
- * the next pass over the band reports it the way any other paint does.
421
+ * One reach pass a frame, however many scrolls and resizes ask for one. It
422
+ * keeps no epoch of its own, so a swap painting behind it is never cut off
423
+ * part-painted, and a page it starts is one a swap arriving at the same page
424
+ * waits behind rather than skips. Nothing awaits these paints here: a scroll
425
+ * is not a render and has no failure channel of its own, and a paint that
426
+ * failed is forgotten, so the next pass over the reach reports it the way
427
+ * any other paint does.
275
428
  */
276
- let sync = () => {
277
- if (!list) return;
278
- for (let [i, canvas] of keep()) paintPage(canvas, i).catch(() => {});
279
- };
280
-
281
- /** One band pass a frame, however many scrolls and resizes ask for one. */
282
429
  let pending = false;
283
430
  let follow = () => {
284
431
  if (pending) return;
285
432
  pending = true;
286
433
  requestAnimationFrame(() => {
287
434
  pending = false;
288
- sync();
435
+ if (!list) return;
436
+ for (let i of keep()) paintPage(i).catch(() => {});
289
437
  });
290
438
  };
291
439
  scroll.addEventListener("scroll", follow);
292
440
 
293
- /** @type {(i: number, total: number) => HTMLCanvasElement} */
294
- let pageOf = (i, total) => {
295
- let canvas = document.createElement("canvas");
296
- canvas.className = "qv-page";
297
- // A canvas is 300 x 150 until told otherwise, and that store counts. A
298
- // page starts with none and takes one when the band reaches it.
299
- canvas.width = 0;
300
- canvas.height = 0;
301
- canvas.setAttribute("role", "img");
302
- canvas.setAttribute("aria-label", "Page " + (i + 1) + " of " + total);
303
- return canvas;
304
- };
305
-
306
441
  return {
307
442
  element: scroll,
308
443
 
@@ -314,7 +449,8 @@ export let stage = () => {
314
449
  */
315
450
  fit: () => {
316
451
  let usable = scroll.clientWidth - 2 * GUTTER;
317
- let width = pageWidth() * PX_PER_POINT;
452
+ // The list's page width, or the geometry's before one arrives.
453
+ let width = (list ? list.width : fallback) * PX_PER_POINT;
318
454
  if (usable <= 0 || !width) return null;
319
455
  return (usable / width) * 100;
320
456
  },
@@ -333,7 +469,7 @@ export let stage = () => {
333
469
  * seen. The browser clamps whatever it cannot honour. An empty sheet has
334
470
  * no view to hold, which is what mounting at an authored zoom takes.
335
471
  *
336
- * Only the band is repainted, and only where the scale actually changed,
472
+ * Only the reach is repainted, and only where the scale actually changed,
337
473
  * so a zoom step costs a handful of pages however long the report is.
338
474
  */
339
475
  scale: (percent) => {
@@ -367,26 +503,27 @@ export let stage = () => {
367
503
 
368
504
  /**
369
505
  * Put a laid-out report on the sheet, keeping the reader where they
370
- * were: one canvas per page, the band among them painted at the applied
371
- * scale. The order is the point of the method: the pages take their size
372
- * before the offsets go back, so the browser clamps them against the
373
- * extent they will have rather than the one they had — and the band is
374
- * read from those offsets, so it is chosen after they are in. What the
375
- * returned promise settles behind is the band, which is what the caller's
506
+ * were: one canvas per page, the reach among them painted at the applied
507
+ * scale. The order is the point of the method: the pages come off
508
+ * `pageOf` already at their size, so the browser clamps the offsets going
509
+ * back against the extent the sheet will have rather than the one it had
510
+ * — and the reach is read from those offsets, so it is chosen after they
511
+ * are in. What the
512
+ * returned promise settles behind is the reach, which is what the caller's
376
513
  * `renderComplete` means by "the pages on screen are painted".
377
514
  */
378
515
  swap: async (next, faces) => {
379
516
  // Reading the offsets flushes layout, so only an actual reswap pays for
380
517
  // it: on an empty sheet there is nothing scrolled to preserve.
381
- let held = sheet.firstChild && { top: scroll.scrollTop, left: scroll.scrollLeft };
518
+ let held = sheet.firstChild && {
519
+ top: scroll.scrollTop,
520
+ left: scroll.scrollLeft,
521
+ };
382
522
  list = next;
383
523
  fonts = faces;
384
524
  sheet.replaceChildren(
385
- ...next.pages.map((/** @type {any} */ _, /** @type {number} */ i) =>
386
- pageOf(i, next.pages.length),
387
- ),
525
+ ...next.pages.map((/** @type {any} */ _, /** @type {number} */ i) => pageOf(i)),
388
526
  );
389
- sizeAll();
390
527
  if (held) {
391
528
  scroll.scrollTop = held.top;
392
529
  scroll.scrollLeft = held.left;
@@ -403,7 +540,7 @@ export let stage = () => {
403
540
  watch: (changed) => {
404
541
  let observer = new ResizeObserver(() => {
405
542
  // A resize that changes the fit repaints through `changed()`; one
406
- // that does not still moved the viewport the band is measured in.
543
+ // that does not still moved the viewport the reach is measured in.
407
544
  follow();
408
545
  changed();
409
546
  });
package/lib/toolbar.js CHANGED
@@ -3,54 +3,32 @@
3
3
  * host passed, in the order they were given. It sits at the right of the
4
4
  * viewer's bar, beside the zoom menu; no exportable target, no group.
5
5
  * The element owns the click — rendering with its current data and
6
- * auto-downloading through `download` below — because only it knows
6
+ * auto-downloading through `saveAs` below — because only it knows
7
7
  * whether it is still connected when the file arrives.
8
8
  *
9
- * The icon's label goes in as a template value, never as a string spliced
9
+ * The format's name goes in as a template value, never as a string spliced
10
10
  * into a document, so this module has no markup edge to escape at and never
11
11
  * grows one (hard constraint 4).
12
12
  */
13
13
  import { html, nothing } from "lit";
14
14
  import { button } from "./button.js";
15
+ import { download } from "./icons.js";
15
16
 
16
- /** The exportable targets: content type and button wording. */
17
- /** @type {Record<string, { type: string, label: string }>} */
17
+ /** The exportable targets, filed under the extension each downloads as: the
18
+ * content type its blob carries. The wording on the button and in a failure
19
+ * is that same name upper-cased, never a second spelling of it. */
20
+ /** @type {Record<string, string>} */
18
21
  export let EXPORTS = {
19
- pdf: { type: "application/pdf", label: "PDF" },
20
- xlsx: {
21
- type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
22
- label: "XLSX",
23
- },
24
- csv: { type: "text/csv", label: "CSV" },
22
+ pdf: "application/pdf",
23
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
24
+ csv: "text/csv",
25
25
  };
26
26
 
27
- // Sheet outline with a folded corner, over the format's name.
28
- /** @type {(label: string) => import('lit').TemplateResult} */
29
- let icon = (label) => html`
30
- <svg viewBox="0 0 20 20" width="19" height="19" aria-hidden="true">
31
- <g fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round">
32
- <path d="M4.5 2.5h7l4 4v11h-11z"></path>
33
- <path d="M11.5 2.5v4h4"></path>
34
- </g>
35
- <text
36
- x="10"
37
- y="14.5"
38
- text-anchor="middle"
39
- font-size="5"
40
- font-weight="700"
41
- letter-spacing="0.2"
42
- fill="currentColor"
43
- >
44
- ${label}
45
- </text>
46
- </svg>
47
- `;
48
-
49
27
  // The anchor joins the document for the click — detached-anchor downloads
50
28
  // are unreliable outside Chromium — and the URL is revoked well after the
51
29
  // browser has had time to start reading the blob.
52
30
  /** @type {(body: string | Uint8Array<ArrayBuffer>, name: string, type: string) => void} */
53
- export let download = (body, name, type) => {
31
+ export let saveAs = (body, name, type) => {
54
32
  let url = URL.createObjectURL(new Blob([body], { type }));
55
33
  let anchor = document.createElement("a");
56
34
  anchor.href = url;
@@ -80,14 +58,17 @@ export let exportGroup = (targets, context) => {
80
58
  return html`
81
59
  <div class="qv-exports">
82
60
  ${exportable.map((target) => {
83
- let { label } = EXPORTS[target.name];
61
+ let label = target.name.toUpperCase();
84
62
  return button({
85
- // The icon carries the format, so the button takes no text of its own.
63
+ // One drawn verb and the format in words beside it: the download
64
+ // icon says what the click does, and the format is information the
65
+ // reader needs rather than a glyph to decode (ADR 0044).
86
66
  title: "Download " + label,
67
+ label,
87
68
  name: target.name,
88
69
  disabled: context.busy.has(target.name),
89
70
  click: () => context.click(target),
90
- content: icon(label),
71
+ content: download,
91
72
  });
92
73
  })}
93
74
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/viewer",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "The embeddable report viewer shell for quario — in the makings, not yet released",
5
5
  "homepage": "https://getquario.com",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -44,21 +44,21 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@lit/task": "^1.0.3",
47
- "@quario/layout": "^0.1.0",
47
+ "@quario/layout": "^0.2.0",
48
48
  "lit": "^3.3.3"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@arethetypeswrong/cli": "^0.18.3",
52
+ "@cantoo/pdf-lib": "^2.9.1",
52
53
  "@size-limit/preset-small-lib": "^13.0.3",
53
54
  "esbuild": "^0.28.2",
54
55
  "exceljs": "^4.4.0",
55
- "pdf-lib": "^1.17.1",
56
- "quario": "^0.4.0",
56
+ "quario": "^0.5.0",
57
57
  "size-limit": "^13.0.3",
58
58
  "typescript": "^7.0.2"
59
59
  },
60
60
  "peerDependencies": {
61
- "quario": "^0.4.0"
61
+ "quario": "^0.5.0"
62
62
  },
63
63
  "size-limit": [
64
64
  {