@quario/viewer 0.1.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/lib/index.js ADDED
@@ -0,0 +1,450 @@
1
+ /**
2
+ * @quario/viewer — the embeddable viewer element. Not a render target: it
3
+ * compiles nothing and never touches the event stream. `<quario-viewer>`
4
+ * consumes a compiled report plus the host's configured targets through
5
+ * properties, displays the html target's fragment on a shadow-DOM sheet, and
6
+ * offers export downloads for whichever exportable targets the host passed.
7
+ * Outcomes reach the host as `rendered` and `error` events, plus
8
+ * `renderComplete`, the one awaitable for the newest render settling.
9
+ *
10
+ * Importing this module defines nothing: the element class is the export,
11
+ * and `@quario/viewer/register` performs the one-line define for hosts that
12
+ * want it (docs/adr/0005-the-surfaces-are-custom-elements.md).
13
+ *
14
+ * Escaping stays exclusively the html target's job (its documented output is
15
+ * injected the documented way), and the viewer adds no report content of its
16
+ * own. The unlicensed marking is the one thing it presents rather than merely
17
+ * styles: the wording still arrives inside the fragment, and the stage draws
18
+ * repeating watermarks on the scaled sheet — see
19
+ * docs/adr/0017-the-viewer-watermarks-the-sheet.md.
20
+ *
21
+ * The error panel is the only wording the viewer authors, and it is not
22
+ * report content: it names which failure occurred, because no error knows
23
+ * whether it was a mount, an update or an export (panel.js).
24
+ */
25
+ import { Task, TaskStatus } from "@lit/task";
26
+ import { LitElement, html } from "lit";
27
+ import { BUTTON } from "./button.js";
28
+ import { CHROME, progress } from "./chrome.js";
29
+ import { display, geometry, level, name, scheme } from "./check.js";
30
+ import { MENU, zoomMenu } from "./menu.js";
31
+ import { PANEL, label, panel } from "./panel.js";
32
+ import { SURFACE, stage } from "./stage.js";
33
+ import { REPORT } from "./style.js";
34
+ import { EXPORTS, download, exportGroup } from "./toolbar.js";
35
+ import { wanted } from "./zoom.js";
36
+
37
+ let OPTION = ["page", "zoom", "filename", "colorScheme"];
38
+ let SUBJECT = ["data", "report"];
39
+
40
+ /** @type {(el: LitElement, changed: Map<string, unknown>) => boolean} */
41
+ let optionWrite = (el, changed) => !el.hasUpdated || OPTION.some((key) => changed.has(key));
42
+
43
+ /** @type {(changed: Map<string, unknown>) => boolean} */
44
+ let subjectWrite = (changed) => SUBJECT.some((key) => changed.has(key));
45
+
46
+ /** @type {(report: unknown, targets: unknown) => boolean} */
47
+ let vacant = (report, targets) => report === undefined && targets === undefined;
48
+
49
+ /** @type {(error: unknown) => unknown} */
50
+ let said = (error) => {
51
+ // oxlint-disable-next-line typescript/no-base-to-string
52
+ return error && String(error);
53
+ };
54
+
55
+ // The properties are described once, in the hand-written public declarations,
56
+ // and read back here — a second copy in JSDoc is a copy that drifts.
57
+ /** @import { ViewableReport, ViewerPage } from './index.d.ts' */
58
+
59
+ /** The tag `@quario/viewer/register` defines the element under. */
60
+ export const TAG = "quario-viewer";
61
+
62
+ /**
63
+ * The viewer element. Hosts assign `report`, `targets` and `data` (plus the
64
+ * option properties `zoom`, `page`, `filename` and `colorScheme`) and listen for `rendered`
65
+ * and `error`; rapid successive writes render only the newest state, because
66
+ * the render pipeline is a task keyed on those properties and the task's own
67
+ * call-id guard drops every superseded run whole — including its failure,
68
+ * which is reported to no one (ADR 0005's narrowed guarantee).
69
+ *
70
+ * Removal is not destruction: disconnecting abandons in-flight work and
71
+ * releases the resize observer, the properties persist, and reconnecting
72
+ * re-renders from them. There is no `destroy()`.
73
+ */
74
+ export class QuarioViewer extends LitElement {
75
+ // CHROME declares the default palette every other sheet paints from, so it
76
+ // has to be in this list -- but anywhere in it: a custom property resolves
77
+ // down the inherited chain, not by stylesheet order.
78
+ static styles = [CHROME, BUTTON, MENU, SURFACE, PANEL, REPORT];
79
+
80
+ // Properties only, no attributes: `report`, `targets`, `data` and `page`
81
+ // are values no attribute could carry, and one door beats two.
82
+ static properties = {
83
+ report: { attribute: false },
84
+ targets: { attribute: false },
85
+ data: { attribute: false },
86
+ zoom: { attribute: false },
87
+ page: { attribute: false },
88
+ filename: { attribute: false },
89
+ colorScheme: { attribute: false },
90
+ };
91
+
92
+ // The stage is built once and never rebuilt: it is the one imperative thing
93
+ // the element owns, and the sheet's content has to survive an update that
94
+ // fails and a disconnect that does not. The template interpolates its
95
+ // element as a node, which lit-html leaves untouched across re-renders.
96
+ #stage = stage();
97
+ /** Whether a render has ever reached the sheet — the mount/update boundary. */
98
+ #landed = false;
99
+ /** Whether the sheet stopped matching the properties while disconnected. */
100
+ #stale = false;
101
+ /** @type {"fit" | number} The live zoom mode; the controls move it. */
102
+ #mode = "fit";
103
+ /** @type {{ width: number, height: number, margin: number }} */
104
+ #box = geometry(undefined);
105
+ #name = "report";
106
+ /** A property the host got wrong, rethrown by the task so it reports once. */
107
+ /** @type {unknown} */
108
+ #invalid;
109
+ /** Bumped to re-run the task when its real arguments did not change. */
110
+ #epoch = 0;
111
+ /** @type {{ label: string, error: unknown } | undefined} */
112
+ #failure;
113
+ #dismissed = false;
114
+ /** Re-applies geometry and scale after the update that changed them. */
115
+ #reapply = false;
116
+ /** @type {Set<string>} The exports in flight; their buttons disable. */
117
+ #exporting = new Set();
118
+ /** @type {(() => void) | undefined} */
119
+ #unwatch;
120
+
121
+ // The whole async pipeline: keyed on the render properties, re-run when one
122
+ // changes. The task's call-id guard is the latest-wins machinery; side
123
+ // effects — the stage swap and both events — live only in the callbacks
124
+ // below, which that guard restricts to the newest run, never in the task
125
+ // body a stale run still executes to completion. `null` is the "nothing to
126
+ // show" result: unlike the task primitive's own initial-state symbol it
127
+ // settles `taskComplete`, which is what lets `renderComplete` always answer.
128
+ #task = new Task(this, {
129
+ args: () => [this.report, this.targets, this.data, this.#epoch],
130
+ task: async ([report, targets, data], { signal }) => {
131
+ if (!this.#begin(report, targets)) return null;
132
+ let target = display(report, targets);
133
+ let fragment = await /** @type {any} */ (report).render(target, data);
134
+ // The engine takes no signal, so abandonment is the guards around this
135
+ // body; the check only spares the swap when the answer arrives after a
136
+ // disconnect mid-render.
137
+ if (this.#drop(signal)) return this.#abandon();
138
+ return fragment;
139
+ },
140
+ onComplete: (fragment) => {
141
+ if (fragment === null || !this.isConnected) return;
142
+ this.#stage.swap(fragment);
143
+ this.#landed = true;
144
+ this.#stale = false;
145
+ // A render that reached the sheet takes the panel down: the panel says
146
+ // what is wrong with what the reader is looking at, and this is the
147
+ // moment that stops being true. A successful export is not that moment.
148
+ this.#failure = undefined;
149
+ this.#dismissed = false;
150
+ this.dispatchEvent(new CustomEvent("rendered"));
151
+ },
152
+ onError: (error) => {
153
+ if (!this.isConnected) return;
154
+ this.#announce(error, this.#landed ? "update-render" : "mount-render");
155
+ },
156
+ });
157
+
158
+ constructor() {
159
+ super();
160
+ // Constructor assignment, never class fields: the source ships verbatim,
161
+ // so a declared field would shadow the accessor `static properties`
162
+ // installs and reactivity would go quiet.
163
+ /** @type {ViewableReport | undefined} */
164
+ this.report = undefined;
165
+ /** @type {readonly any[] | undefined} */
166
+ this.targets = undefined;
167
+ /** @type {unknown} */
168
+ this.data = undefined;
169
+ /** @type {"fit" | number | undefined} */
170
+ this.zoom = undefined;
171
+ /** @type {ViewerPage | undefined} */
172
+ this.page = undefined;
173
+ /** @type {string | undefined} */
174
+ this.filename = undefined;
175
+ /** @type {"light" | "dark" | "auto" | undefined} */
176
+ this.colorScheme = undefined;
177
+ }
178
+
179
+ /**
180
+ * The newest render settling: `true` when it reached the sheet, `false`
181
+ * when it failed or there was nothing to render. It never rejects — a
182
+ * failed render is handled, on the panel and through the error event — and
183
+ * like every outcome here it answers for the newest render only.
184
+ *
185
+ * @returns {Promise<boolean>}
186
+ */
187
+ get renderComplete() {
188
+ return this.updateComplete.then(() =>
189
+ this.#task.status === TaskStatus.INITIAL
190
+ ? this.#landed
191
+ : this.#task.taskComplete.then(
192
+ () => this.#landed,
193
+ () => false,
194
+ ),
195
+ );
196
+ }
197
+
198
+ /** @param {Map<string, unknown>} changed */
199
+ willUpdate(changed) {
200
+ // The option properties are derived here, not in the task: a zoom change
201
+ // must not re-render the report. A property the host got wrong keeps the
202
+ // last good values and is stashed for the task, whose next run reports it
203
+ // through the one failure channel; flipping validity re-runs the task by
204
+ // epoch, because the render arguments themselves did not change.
205
+ if (optionWrite(this, changed)) this.#checkOptions(changed);
206
+ // A new document is a new subject: dismissing the last failure said
207
+ // nothing about this one.
208
+ if (subjectWrite(changed)) this.#dismissed = false;
209
+ }
210
+
211
+ render() {
212
+ let busy = this.#task.status === TaskStatus.PENDING;
213
+ return html`
214
+ <div class="qv-viewer" aria-busy=${String(busy)}>
215
+ <div class="qv-bar">
216
+ ${progress(busy)}
217
+ ${zoomMenu({
218
+ mode: this.#mode,
219
+ percent: Math.round(this.#stage.percent()),
220
+ choose: (next) => this.#setMode(next),
221
+ })}
222
+ ${exportGroup(this.targets, {
223
+ busy: this.#exporting,
224
+ click: (target) => this.#export(target),
225
+ })}
226
+ </div>
227
+ <div class="qv-body">
228
+ ${this.#panel()}
229
+ ${this.#stage.element}
230
+ </div>
231
+ </div>
232
+ `;
233
+ }
234
+
235
+ updated() {
236
+ // Layout writes belong here, never in render(): applying a scale measures
237
+ // the box, and the stage is only measurable once the update committed it.
238
+ if (this.#reapply) {
239
+ this.#reapply = false;
240
+ this.#stage.resize(this.#box);
241
+ this.#apply();
242
+ }
243
+ }
244
+
245
+ connectedCallback() {
246
+ super.connectedCallback();
247
+ // A fitted sheet follows the host's box; the observer is released on
248
+ // disconnect, so reconnection takes it again.
249
+ this.#watchStage();
250
+ // Reconnect re-renders from the current properties — but only when the
251
+ // sheet stopped matching them: a render was abandoned mid-flight, or a
252
+ // property write landed while disconnected. The task's arguments did not
253
+ // change, so the epoch is what re-runs it; left alone, reparenting a
254
+ // settled viewer costs nothing.
255
+ this.#wake();
256
+ }
257
+
258
+ disconnectedCallback() {
259
+ super.disconnectedCallback();
260
+ // Abandon in-flight work and release the observer. The properties and the
261
+ // sheet persist: removal is destruction only in the collector's sense.
262
+ this.#task.abort();
263
+ this.#unwatch?.();
264
+ this.#unwatch = undefined;
265
+ }
266
+
267
+ /**
268
+ * A property the host got wrong fails loudly even while there is nothing to
269
+ * render yet: a misconfigured host is caught in development, not when the
270
+ * report arrives. Nothing assigned yet is not a failure. Disconnected
271
+ * elements keep processing updates, so the guard is what makes removal
272
+ * abandon work rather than merely hide it.
273
+ *
274
+ * @param {unknown} report
275
+ * @param {unknown} targets
276
+ */
277
+ #begin(report, targets) {
278
+ if (this.#invalid) throw this.#invalid;
279
+ if (vacant(report, targets)) return null;
280
+ if (!this.isConnected) return this.#abandon();
281
+ return true;
282
+ }
283
+
284
+ /** @param {AbortSignal} signal */
285
+ #drop(signal) {
286
+ return signal.aborted && !this.isConnected;
287
+ }
288
+
289
+ #abandon() {
290
+ this.#stale = true;
291
+ return null;
292
+ }
293
+
294
+ /** @param {() => void} run */
295
+ #take(run) {
296
+ try {
297
+ run();
298
+ } catch (error) {
299
+ this.#invalid ??= error;
300
+ }
301
+ }
302
+
303
+ /** @param {Map<string, unknown>} changed */
304
+ #commitPage(changed) {
305
+ let box = geometry(this.page);
306
+ if (!this.hasUpdated || changed.has("page")) {
307
+ this.#box = box;
308
+ this.#reapply = true;
309
+ }
310
+ }
311
+
312
+ /** @param {Map<string, unknown>} changed */
313
+ #commitZoom(changed) {
314
+ let mode = level(this.zoom);
315
+ // Committed only when the host wrote `zoom`, so re-validating on a
316
+ // filename change cannot clobber the mode the reader clicked to.
317
+ if (!this.hasUpdated || changed.has("zoom")) {
318
+ this.#mode = mode;
319
+ this.#reapply = true;
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Each property validates and commits on its own, stashing the first
325
+ * failure: one bad property must not block a good write to another. All
326
+ * four re-validate whatever changed, so a still-broken property keeps the
327
+ * element failing loudly rather than being cleared by an unrelated write.
328
+ *
329
+ * Compared by wording rather than identity: each check mints a fresh
330
+ * TypeError, and the same still-broken property re-reported on every
331
+ * unrelated write would be churn, while a genuinely different failure (or
332
+ * recovery) is a render outcome and re-runs the task. Stringifying whatever
333
+ * was thrown is the comparison — its wording is the identity here, and a
334
+ * value with no better one still compares equal.
335
+ *
336
+ * @param {Map<string, unknown>} changed
337
+ */
338
+ #checkOptions(changed) {
339
+ let was = this.#invalid;
340
+ this.#invalid = undefined;
341
+ this.#take(() => this.#commitPage(changed));
342
+ this.#take(() => this.#commitZoom(changed));
343
+ this.#take(() => {
344
+ this.#name = name(this.filename);
345
+ });
346
+ this.#take(() => {
347
+ let used = scheme(this.colorScheme);
348
+ // Same gate as zoom/page: re-validate always, write the CSSOM pin only
349
+ // when this property changed, so a filename write does not dirty
350
+ // inherited color-scheme before layout.
351
+ if (!this.hasUpdated || changed.has("colorScheme")) this.style.colorScheme = used;
352
+ });
353
+ if (said(was) !== said(this.#invalid)) this.#epoch++;
354
+ }
355
+
356
+ #panel() {
357
+ return this.#failure && !this.#dismissed ? panel(this.#failure, () => this.#dismiss()) : "";
358
+ }
359
+
360
+ #onResize() {
361
+ if (this.#mode === "fit" && this.#wanted() !== this.#stage.percent()) this.#apply();
362
+ }
363
+
364
+ #watchStage() {
365
+ this.#unwatch ??= this.#stage.watch(() => this.#onResize());
366
+ }
367
+
368
+ #wake() {
369
+ if (this.#stale || this.#task.status === TaskStatus.PENDING) {
370
+ this.#epoch++;
371
+ this.requestUpdate();
372
+ }
373
+ }
374
+
375
+ /** @param {() => void} run */
376
+ #ifConnected(run) {
377
+ if (this.isConnected) run();
378
+ }
379
+
380
+ /** The percentage the zoom policy asks for right now. */
381
+ #wanted() {
382
+ return wanted(this.#mode, this.#stage.fit(), this.#stage.percent());
383
+ }
384
+
385
+ /**
386
+ * Write the asked-for scale to the stage — the one imperative side effect
387
+ * the declarative render cannot own — then re-render so the trigger's name
388
+ * re-derives from what is actually on screen.
389
+ */
390
+ #apply() {
391
+ this.#stage.scale(this.#wanted());
392
+ this.requestUpdate();
393
+ }
394
+
395
+ /**
396
+ * Move the zoom. Settling on the mode already current is not a change — the
397
+ * menu's rows are radios, and re-choosing the checked one only closes the
398
+ * menu — so it costs no measurement and no re-render.
399
+ *
400
+ * @param {"fit" | number} next
401
+ */
402
+ #setMode(next) {
403
+ if (next === this.#mode) return;
404
+ this.#mode = next;
405
+ this.#apply();
406
+ }
407
+
408
+ #dismiss() {
409
+ this.#dismissed = true;
410
+ this.requestUpdate();
411
+ }
412
+
413
+ /**
414
+ * Report one failure, wherever it came from: the panel for the reader, the
415
+ * event for the host. Failures that reach neither — a superseded render's,
416
+ * or anything after a disconnect — never come through here.
417
+ *
418
+ * @param {unknown} error The caught value.
419
+ * @param {"mount-render" | "update-render" | "export"} kind
420
+ * @param {string} [format] The export format's name, for that kind alone.
421
+ */
422
+ #announce(error, kind, format) {
423
+ this.#failure = { error, label: label(kind, format) };
424
+ this.#dismissed = false;
425
+ this.requestUpdate();
426
+ this.dispatchEvent(new CustomEvent("error", { detail: { error, kind } }));
427
+ }
428
+
429
+ /**
430
+ * Render and download one export. The render is paid for wherever it
431
+ * settles; the download and the failure report are dropped once the element
432
+ * is disconnected — a discarded viewer never hands the reader a file.
433
+ *
434
+ * @param {any} target
435
+ */
436
+ async #export(target) {
437
+ let { type, label: format } = EXPORTS[target.name];
438
+ this.#exporting.add(target.name);
439
+ this.requestUpdate();
440
+ try {
441
+ let body = await /** @type {any} */ (this.report).render(target, this.data);
442
+ this.#ifConnected(() => download(body, this.#name + "." + target.name, type));
443
+ } catch (error) {
444
+ this.#ifConnected(() => this.#announce(error, "export", format));
445
+ } finally {
446
+ this.#exporting.delete(target.name);
447
+ this.requestUpdate();
448
+ }
449
+ }
450
+ }
package/lib/mark.js ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Unlicensed watermark layout for the continuous sheet: stamp centres along
3
+ * the sheet height, and PDF-like face geometry from the full page box (same
4
+ * atan2 / 75%-diagonal / size-at-54pt proportions as the PDF stamp). Pure
5
+ * numbers — the stage owns the DOM. Appearance is best-effort (ADR 0017).
6
+ */
7
+
8
+ /** Reference face size in points — matches the PDF target's stamp probe. */
9
+ export let FACE = 54;
10
+
11
+ /**
12
+ * Centre Y of each stamp band covering `[0, sheetHeight)`. Bands are
13
+ * `pageHeight` tall; the last may be short. A short sheet still gets one stamp.
14
+ *
15
+ * @param {number} sheetHeight
16
+ * @param {number} pageHeight
17
+ * @returns {number[]}
18
+ */
19
+ export let centres = (sheetHeight, pageHeight) => {
20
+ if (!(sheetHeight > 0) || !(pageHeight > 0)) return [];
21
+ let n = Math.max(1, Math.ceil(sheetHeight / pageHeight));
22
+ /** @type {number[]} */
23
+ let out = [];
24
+ for (let i = 0; i < n; i++) {
25
+ let top = i * pageHeight;
26
+ let bottom = Math.min((i + 1) * pageHeight, sheetHeight);
27
+ out.push((top + bottom) / 2);
28
+ }
29
+ return out;
30
+ };
31
+
32
+ /** @type {(width: number, height: number) => number} */
33
+ export let angle = (width, height) => (Math.atan2(height, width) * 180) / Math.PI;
34
+
35
+ /** @type {(width: number, height: number) => number} */
36
+ export let span = (width, height) => Math.hypot(width, height) * 0.75;
37
+
38
+ /** @type {(spanLength: number, textWidth: number, at?: number) => number} */
39
+ export let size = (spanLength, textWidth, at = FACE) =>
40
+ textWidth > 0 ? (at * spanLength) / textWidth : at;