geometryview 0.1.2 → 0.3.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/web/kept.js ADDED
@@ -0,0 +1,263 @@
1
+ // What this browser keeps of its imports, in the origin-private file system under library2/: each
2
+ // file's cache file, its note, its pictures, and, for a STEP file that cannot be read again from
3
+ // a connected folder, its source, for refining. Each is named by a key, a hash of where the file
4
+ // came from, its path there, its size, its time and the cache version: two files of one name in
5
+ // two folders never meet, and a changed file is a new key.
6
+ //
7
+ // library2/blobs/<key>.geoblob the cache file
8
+ // library2/sources/<key>.source a STEP file's source, when it cannot be had again
9
+ // library2/thumbs/<key>.<T>.png its pictures, one per drawing spec T, each with a .json note
10
+ // library2/notes/<key>.json the note, written last: an entry without one is unfinished
11
+ //
12
+ // What is kept is evicted least recently used first, to a budget from the browser's storage
13
+ // estimate. The first library/ folder, named by bare file names, is still read until its files are
14
+ // imported again.
15
+
16
+ export const kRoot = "library2";
17
+ const kNote = "notes", kBlobs = "blobs", kSources = "sources", kThumbs = "thumbs";
18
+
19
+ /**
20
+ * The key of a file: the first 128 bits of SHA-256 over where it came from (a connected folder's
21
+ * name, or "file" for one opened on its own), its path there, its size, its modification time and
22
+ * the cache version, as 32 hex digits.
23
+ */
24
+ export async function keyOf({ source, path, size, modified, version }) {
25
+ const text = JSON.stringify([String(source), String(path), Number(size), Number(modified), Number(version)]);
26
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
27
+ return [...digest.subarray(0, 16)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
28
+ }
29
+
30
+ /** The share of the browser's quota each kind of entry may take. */
31
+ export const kBlobShare = 0.5;
32
+ export const kPictureShare = 0.02;
33
+
34
+ /**
35
+ * Which entries to delete so that what is kept fits `budget` bytes: the least recently used first,
36
+ * never those in `keep`. Notes are { key, bytes, usedAt }.
37
+ */
38
+ export function evictions(notes, budget, keep = new Set()) {
39
+ let total = notes.reduce((sum, note) => sum + (note.bytes ?? 0), 0);
40
+ const dropped = [];
41
+ for (const note of [...notes].sort((a, b) => (a.usedAt ?? 0) - (b.usedAt ?? 0))) {
42
+ if (total <= budget) break;
43
+ if (keep.has(note.key)) continue;
44
+ dropped.push(note.key);
45
+ total -= note.bytes ?? 0;
46
+ }
47
+ return dropped;
48
+ }
49
+
50
+ // ---- The origin-private file system ------------------------------------------------------------
51
+
52
+ async function folder(name) {
53
+ const root = await (await navigator.storage.getDirectory()).getDirectoryHandle(kRoot, { create: true });
54
+ return root.getDirectoryHandle(name, { create: true });
55
+ }
56
+
57
+ async function readText(directory, name) {
58
+ return (await (await directory.getFileHandle(name)).getFile()).text();
59
+ }
60
+
61
+ // Written whole through a writable stream, which replaces the file only when closed.
62
+ async function writeText(directory, name, text) {
63
+ const writable = await (await directory.getFileHandle(name, { create: true })).createWritable();
64
+ await writable.write(text);
65
+ await writable.close();
66
+ }
67
+
68
+ /** A cache file's version, from its header: 8 bytes of magic, then a little-endian u32. */
69
+ export async function versionOf(blob) {
70
+ const header = new DataView(await blob.slice(0, 12).arrayBuffer());
71
+ return header.byteLength >= 12 ? header.getUint32(8, true) : -1;
72
+ }
73
+
74
+ /** An entry's note, or null if it is not kept whole. */
75
+ export async function noteOf(key) {
76
+ try {
77
+ return JSON.parse(await readText(await folder(kNote), `${key}.json`));
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The kept import of `key`: { blob, note }, or null if there is none, it is unfinished, or its
85
+ * cache file is in another version than `version`. Marks it used.
86
+ */
87
+ export async function keptImport(key, version = null) {
88
+ const note = await noteOf(key);
89
+ if (!note) return null;
90
+ try {
91
+ const blob = await (await (await folder(kBlobs)).getFileHandle(`${key}.geoblob`)).getFile();
92
+ if (version !== null && (await versionOf(blob)) !== version) return null;
93
+ await touch(key, note);
94
+ return { blob, note };
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /** The STEP source kept for `key`, or null if none was kept whole. */
101
+ export async function keptSource(key) {
102
+ const note = await noteOf(key);
103
+ if (!note?.sourceBytes) return null;
104
+ try {
105
+ const source = await (await (await folder(kSources)).getFileHandle(`${key}.source`)).getFile();
106
+ return source.size === note.sourceBytes ? source : null;
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /** Marks an entry as just used, so eviction keeps it longest. */
113
+ export async function touch(key, note = null) {
114
+ note ??= await noteOf(key);
115
+ if (!note) return;
116
+ note.usedAt = Date.now();
117
+ await writeText(await folder(kNote), `${key}.json`, JSON.stringify(note)).catch(() => {});
118
+ }
119
+
120
+ /** Deletes everything kept for `key`: its note first, so a reader never sees half an entry. */
121
+ export async function forget(key) {
122
+ try {
123
+ await (await folder(kNote)).removeEntry(`${key}.json`).catch(() => {});
124
+ await (await folder(kBlobs)).removeEntry(`${key}.geoblob`).catch(() => {});
125
+ await (await folder(kSources)).removeEntry(`${key}.source`).catch(() => {});
126
+ const thumbs = await folder(kThumbs);
127
+ for await (const name of thumbs.keys()) {
128
+ if (name.startsWith(`${key}.`)) await thumbs.removeEntry(name).catch(() => {});
129
+ }
130
+ } catch {
131
+ // No origin-private file system here: nothing is kept.
132
+ }
133
+ }
134
+
135
+ /** Deletes everything kept of files named `name`, wherever they came from: for checks. */
136
+ export async function forgetNamed(name) {
137
+ for (const note of await notes()) if (note.name === name) await forget(note.key);
138
+ }
139
+
140
+ /** Every finished entry's note. */
141
+ export async function notes() {
142
+ const found = [];
143
+ try {
144
+ const directory = await folder(kNote);
145
+ for await (const [name, handle] of directory.entries()) {
146
+ if (handle.kind !== "file" || !name.endsWith(".json")) continue;
147
+ try {
148
+ found.push(JSON.parse(await (await handle.getFile()).text()));
149
+ } catch {
150
+ // Being written: not finished.
151
+ }
152
+ }
153
+ } catch {
154
+ // Nothing kept here.
155
+ }
156
+ return found;
157
+ }
158
+
159
+ /** The bytes kept imports may hold: a share of the browser's quota. */
160
+ export async function blobBudget() {
161
+ const { quota = 0 } = (await navigator.storage?.estimate?.().catch(() => null)) ?? {};
162
+ return quota > 0 ? quota * kBlobShare : 2 * 1024 ** 3;
163
+ }
164
+
165
+ /** Evicts kept imports to the budget, sparing `keep` (keys open or in view). Returns the keys dropped. */
166
+ export async function evict(keep = new Set(), budget = null) {
167
+ budget ??= await blobBudget();
168
+ const dropped = evictions(await notes(), budget, keep);
169
+ for (const key of dropped) await forget(key);
170
+ return dropped;
171
+ }
172
+
173
+ let persisted = false;
174
+ /** Asks once that what is kept not be cleared under storage pressure: once a folder is connected. */
175
+ export function persistOnce() {
176
+ if (persisted) return;
177
+ persisted = true;
178
+ navigator.storage?.persist?.().catch(() => {});
179
+ }
180
+
181
+ // ---- Writing, from a worker --------------------------------------------------------------------
182
+
183
+ async function writeWhole(directory, name, bytes) {
184
+ const access = await (await directory.getFileHandle(name, { create: true })).createSyncAccessHandle();
185
+ access.truncate(0);
186
+ for (let at = 0; at < bytes.length;) at += access.write(bytes.subarray(at), { at });
187
+ access.flush();
188
+ access.close();
189
+ }
190
+
191
+ /**
192
+ * Keeps an import, from a worker, where sync access handles write a cache file without holding a
193
+ * second copy: the cache file, then the note, last, which makes it whole.
194
+ */
195
+ export async function keepImport(key, bytes, note) {
196
+ await writeWhole(await folder(kBlobs), `${key}.geoblob`, bytes);
197
+ const whole = { ...note, key, bytes: bytes.length, sourceBytes: 0, usedAt: Date.now() };
198
+ await writeWhole(await folder(kNote), `${key}.json`, new TextEncoder().encode(JSON.stringify(whole)));
199
+ return whole;
200
+ }
201
+
202
+ /**
203
+ * Keeps a STEP file's source beside its kept import, from a worker, read in pieces, once the import
204
+ * is done, since nothing waits for it; then notes it, so a copy cut short is never read.
205
+ */
206
+ export async function keepSource(key, file) {
207
+ const access = await (await (await folder(kSources)).getFileHandle(`${key}.source`, { create: true }))
208
+ .createSyncAccessHandle();
209
+ access.truncate(0);
210
+ for (let at = 0; at < file.size; at += kSourcePiece) {
211
+ const piece = new Uint8Array(await file.slice(at, at + kSourcePiece).arrayBuffer());
212
+ for (let done = 0; done < piece.length;) done += access.write(piece.subarray(done), { at: at + done });
213
+ }
214
+ access.flush();
215
+ access.close();
216
+ const note = await noteOf(key);
217
+ if (!note) return;
218
+ note.sourceBytes = file.size;
219
+ note.bytes += file.size;
220
+ await writeWhole(await folder(kNote), `${key}.json`, new TextEncoder().encode(JSON.stringify(note)));
221
+ }
222
+ const kSourcePiece = 8 << 20;
223
+
224
+ // ---- Pictures and facts --------------------------------------------------------------------------
225
+
226
+ /** The key of a picture of a file: a hash of how it is drawn and the engine's version. */
227
+ export async function pictureKeyOf({ style, colorMode, pixels, pixelsPerPoint, lineScale, folderColor, version,
228
+ supersample = 1 }) {
229
+ // Supersampled: another picture, smoother; one drawn plainly keeps its key.
230
+ const fields = [style, colorMode, pixels, pixelsPerPoint, lineScale, folderColor, version];
231
+ const text = JSON.stringify(supersample > 1 ? [...fields, `ss${supersample}`] : fields);
232
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
233
+ return [...digest.subarray(0, 8)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
234
+ }
235
+
236
+ /** A file's facts and its picture kept under `key`: { facts, png, note } or null for each missing. */
237
+ export async function keptPicture(key, picture) {
238
+ try {
239
+ const thumbs = await folder(kThumbs);
240
+ const facts = JSON.parse(await readText(thumbs, `${key}.facts.json`));
241
+ let png = null, note = null;
242
+ try {
243
+ note = JSON.parse(await readText(thumbs, `${key}.${picture}.json`));
244
+ png = await (await thumbs.getFileHandle(`${key}.${picture}.png`)).getFile();
245
+ } catch {
246
+ // Drawn in another style, or not at all.
247
+ }
248
+ return { facts, png, note };
249
+ } catch {
250
+ return null;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Keeps a picture, its note and the file's facts, from a worker: the PNG, then the facts, then the
256
+ * note, last, which marks the picture whole.
257
+ */
258
+ export async function keepPicture(key, picture, png, note, facts) {
259
+ const thumbs = await folder(kThumbs);
260
+ await writeWhole(thumbs, `${key}.${picture}.png`, new Uint8Array(await png.arrayBuffer()));
261
+ if (facts) await writeWhole(thumbs, `${key}.facts.json`, new TextEncoder().encode(JSON.stringify(facts)));
262
+ await writeWhole(thumbs, `${key}.${picture}.json`, new TextEncoder().encode(JSON.stringify(note)));
263
+ }
@@ -8,8 +8,11 @@
8
8
  // for this visit only.
9
9
  //
10
10
  // Nothing is read until it is shown: a folder is listed when it is opened, by names alone, and a
11
- // file is read when it is opened. The recent files are kept in IndexedDB everywhere.
11
+ // file is read when it is opened. A gallery walks a folder with walk(), which yields its folders
12
+ // and STEP and STL files, by names first wherever the browser allows. The recent files are kept in
13
+ // IndexedDB everywhere.
12
14
 
15
+ import { keptImport } from "./kept.js";
13
16
  import { keptFile } from "./library.js";
14
17
  import { remember as rememberIn } from "./library-rows.js";
15
18
 
@@ -42,6 +45,10 @@ const all = (name) => store(name, "readonly", (s) => s.getAll()).catch(() => [])
42
45
  const put = (name, value) => store(name, "readwrite", (s) => s.put(value)).catch(() => {});
43
46
  const remove = (name, key) => store(name, "readwrite", (s) => s.delete(key)).catch(() => {});
44
47
 
48
+ // What a gallery shows, and how far a walk goes, as on the Mac (Engine/library/scan.h).
49
+ export const isModelName = (name) => /\.(step|stp|stl)$/i.test(name);
50
+ export const kWalkLimits = { depth: 32, files: 20000 };
51
+
45
52
  const kModelTypes = [{ description: "STEP, STL or cache files",
46
53
  accept: { "application/octet-stream": [".step", ".stp", ".stl", ".geoblob"] } }];
47
54
 
@@ -120,6 +127,28 @@ class AccessFolders {
120
127
  return (await this.#directory(id, parts.join("/"))).getFileHandle(name);
121
128
  }
122
129
 
130
+ // A gallery's walk, by names alone: { path, kind, handle } for each folder and STEP or STL file.
131
+ // A file's size and time come from handle.getFile(), asked for when its tile is in view.
132
+ async *walk(id, path = "", { depth = kWalkLimits.depth, files = kWalkLimits.files } = {}) {
133
+ const start = await this.#directory(id, path);
134
+ const pending = [[start, path, 0]];
135
+ let found = 0;
136
+ while (pending.length > 0) {
137
+ const [directory, at, level] = pending.shift();
138
+ for await (const [name, handle] of directory.entries()) {
139
+ if (name.startsWith(".")) continue;
140
+ const inner = at ? `${at}/${name}` : name;
141
+ if (handle.kind === "directory") {
142
+ yield { path: inner, kind: "folder", handle };
143
+ if (level + 1 < depth) pending.push([handle, inner, level + 1]);
144
+ } else if (isModelName(name)) {
145
+ if (found++ === files) return;
146
+ yield { path: inner, kind: "file", handle };
147
+ }
148
+ }
149
+ }
150
+ }
151
+
123
152
  async file(id, path) {
124
153
  return (await this.fileHandle(id, path)).getFile();
125
154
  }
@@ -172,6 +201,21 @@ class VisitFolders {
172
201
  return this.#add({ name, files });
173
202
  }
174
203
 
204
+ // A folder served elsewhere, for checks: its listing [{ path, folder, bytes, modified }], each
205
+ // file read from `base` + its path only when asked for.
206
+ adoptRemote(name, listing, base) {
207
+ const files = new Map();
208
+ for (const entry of listing) {
209
+ if (entry.folder) continue;
210
+ files.set(entry.path, async () => {
211
+ const response = await fetch(base + entry.path.split("/").map(encodeURIComponent).join("/"));
212
+ if (!response.ok) throw new Error(`${entry.path} could not be read.`);
213
+ return new File([await response.blob()], entry.path.split("/").pop(), { lastModified: entry.modified });
214
+ });
215
+ }
216
+ return this.#add({ name, files, listing });
217
+ }
218
+
175
219
  #add(folder) {
176
220
  const id = crypto.randomUUID();
177
221
  this.#folders.push({ id, ...folder });
@@ -218,7 +262,10 @@ class VisitFolders {
218
262
  async file(id, path) {
219
263
  const folder = this.#folders.find((f) => f.id === id);
220
264
  if (!folder) throw new Error("That folder is no longer connected.");
221
- if (folder.files) return folder.files.get(path);
265
+ if (folder.files) {
266
+ const file = folder.files.get(path);
267
+ return typeof file === "function" ? file() : file;
268
+ }
222
269
  const parts = path.split("/");
223
270
  const name = parts.pop();
224
271
  const directory = await this.#entry(folder.entry, parts.join("/"));
@@ -227,6 +274,57 @@ class VisitFolders {
227
274
  }
228
275
 
229
276
  pickFiles() { return Promise.resolve(undefined); } // the page's own file input instead
277
+
278
+ // A gallery's walk: { path, kind, file? }, files with their File where the folder came whole.
279
+ async *walk(id, path = "", { depth = kWalkLimits.depth, files = kWalkLimits.files } = {}) {
280
+ const folder = this.#folders.find((f) => f.id === id);
281
+ if (!folder) throw new Error("That folder is no longer connected.");
282
+ let found = 0;
283
+ if (folder.listing) {
284
+ const prefix = path ? `${path}/` : "";
285
+ for (const entry of folder.listing) {
286
+ if (!entry.path.startsWith(prefix)) continue;
287
+ if (!entry.folder && found++ === files) return;
288
+ yield { path: entry.path, kind: entry.folder ? "folder" : "file", bytes: entry.bytes, modified: entry.modified };
289
+ }
290
+ return;
291
+ }
292
+ if (folder.files) {
293
+ const prefix = path ? `${path}/` : "";
294
+ const folders = new Set();
295
+ for (const [inner, file] of folder.files) {
296
+ if (!inner.startsWith(prefix)) continue;
297
+ const parts = inner.slice(prefix.length).split("/");
298
+ if (parts.some((part) => part.startsWith(".")) || parts.length > depth) continue;
299
+ for (let i = 1; i < parts.length; i++) {
300
+ const dir = prefix + parts.slice(0, i).join("/");
301
+ if (!folders.has(dir)) {
302
+ folders.add(dir);
303
+ yield { path: dir, kind: "folder" };
304
+ }
305
+ }
306
+ if (!isModelName(inner)) continue;
307
+ if (found++ === files) return;
308
+ yield { path: inner, kind: "file", file };
309
+ }
310
+ return;
311
+ }
312
+ const pending = [[path, 0]];
313
+ while (pending.length > 0) {
314
+ const [at, level] = pending.shift();
315
+ for (const entry of await this.list(id, at)) {
316
+ if (entry.name.startsWith(".")) continue;
317
+ const inner = at ? `${at}/${entry.name}` : entry.name;
318
+ if (entry.kind === "folder") {
319
+ yield { path: inner, kind: "folder" };
320
+ if (level + 1 < depth) pending.push([inner, level + 1]);
321
+ } else if (isModelName(entry.name)) {
322
+ if (found++ === files) return;
323
+ yield { path: inner, kind: "file" };
324
+ }
325
+ }
326
+ }
327
+ }
230
328
  }
231
329
 
232
330
  // ---- The iPhone shell ------------------------------------------------------------------------
@@ -262,6 +360,20 @@ class ShellFolders {
262
360
  return this.#fetchFile(`/open/${id}/${encodePath(path)}`, path.split("/").pop());
263
361
  }
264
362
 
363
+ // A gallery's walk, from the shell's /scan, which walks the folder natively: { path, kind, bytes,
364
+ // modified, cloud }, a file that lives only in iCloud marked cloud.
365
+ async *walk(id, path = "", { depth = kWalkLimits.depth, files = kWalkLimits.files } = {}) {
366
+ const response = await fetch(`/scan/${id}/${encodePath(path)}?depth=${depth}&limit=${files}`);
367
+ if (!response.ok) throw new Error("This folder could not be read.");
368
+ const prefix = path ? `${path}/` : "";
369
+ for (const line of (await response.text()).split("\n")) {
370
+ if (!line) continue;
371
+ const entry = JSON.parse(line);
372
+ if (entry.done) return;
373
+ yield { ...entry, path: prefix + entry.path };
374
+ }
375
+ }
376
+
265
377
  async #fetchFile(url, name) {
266
378
  const response = await fetch(url);
267
379
  if (!response.ok) throw new Error(`${name} could not be read.`);
@@ -357,7 +469,8 @@ export class LibraryStore {
357
469
  }
358
470
  const permission = await entry.handle?.queryPermission?.({ mode: "read" }).catch(() => "denied");
359
471
  if (permission === "granted") return { file: await entry.handle.getFile() };
360
- const cache = await keptFile(entry.name).catch(() => null);
472
+ const cache = (entry.cacheKey ? (await keptImport(entry.cacheKey))?.blob : null) ??
473
+ await keptFile(entry.name).catch(() => null);
361
474
  if (cache) return { cache };
362
475
  if (entry.handle && (await entry.handle.requestPermission({ mode: "read" }).catch(() => "denied")) === "granted") {
363
476
  return { file: await entry.handle.getFile() };
package/web/library.js CHANGED
@@ -108,7 +108,12 @@ export class LibraryCard {
108
108
  #recent = [];
109
109
  #open = new Set();
110
110
 
111
- // `on` takes: open({ root, path, name }, add), reopen(entry, add), choose(), connect(), switch(show)
111
+ // `on` takes: open({ root, path, name }, add), reopen(entry, add), choose(), connect(), switch(show),
112
+ // showFolder(root, path), search(query) while the gallery shows
113
+ #search;
114
+ #searchBox;
115
+ #found;
116
+
112
117
  constructor(store, on) {
113
118
  this.#store = store;
114
119
  this.#on = on;
@@ -124,12 +129,29 @@ export class LibraryCard {
124
129
  connect.hidden = !store.canConnect;
125
130
  this.#list = new VirtualList({ role: "tree", rowHeight, count: () => this.#rows.length,
126
131
  row: (index) => this.#row(this.#rows[index]) });
132
+ // While the gallery shows: search the folder in view. Typing in the gallery searches too.
133
+ this.#search = el("input.search", { type: "search", placeholder: "Search this folder",
134
+ "aria-label": "Search this folder" });
135
+ this.#search.addEventListener("input", () => on.search?.(this.#search.value));
136
+ this.#found = el("span.match");
137
+ this.#searchBox = el("label.searchbox", { hidden: true },
138
+ [el("span.glyph", { html: icons.search(14) }), this.#search, this.#found]);
127
139
  this.element = el("section.card.structure.library", { hidden: true, "aria-label": "Library" }, [
128
140
  el("div.header", {}, [this.switcher.element, el("span.grow"), add, connect]),
141
+ this.#searchBox,
129
142
  this.#list.element,
130
143
  ]);
131
144
  }
132
145
 
146
+ /** Shows the gallery's search, with its query and how many files it found (null: no search). */
147
+ showSearch(shown, query = "", found = null) {
148
+ this.#searchBox.hidden = !shown;
149
+ if (this.#search.value !== query) this.#search.value = query;
150
+ this.#found.textContent = found === null ? "" : `${found} found`;
151
+ }
152
+
153
+ get searchInput() { return this.#search; }
154
+
133
155
  // Reads the folders and recent files again; `kept` are the imports this browser kept, which
134
156
  // fill Recent on a first visit.
135
157
  async refresh(kept) {
@@ -212,6 +234,8 @@ export class LibraryCard {
212
234
  this.#expanded.add(row.key);
213
235
  await this.#readListing(row.root, row.path);
214
236
  }
237
+ // A folder's gallery shows beside the card.
238
+ this.#on.showFolder?.({ id: row.root, name: this.folderName(row.root) }, row.path);
215
239
  }
216
240
 
217
241
  async #disconnect(row) {
Binary file
package/web/pdf.js ADDED
@@ -0,0 +1,54 @@
1
+ // A PDF of pictures: each page one JPEG, at its size in points. No fonts and no dependencies: the
2
+ // gallery's posters and its catalogue, drawn as they show, for the browser to save. (The Mac's are
3
+ // drawn from its layers, their text and lines kept as text and lines.)
4
+
5
+ /**
6
+ * @param {{ jpeg: Uint8Array, width: number, height: number, points: [number, number] }[]} pages
7
+ * each page's JPEG, its size in pixels, and the page's size in points
8
+ * @returns {Blob}
9
+ */
10
+ export function pdfOfJpegs(pages) {
11
+ const encoder = new TextEncoder();
12
+ const chunks = [];
13
+ const offsets = [];
14
+ let length = 0;
15
+ const push = (data) => {
16
+ const bytes = typeof data === "string" ? encoder.encode(data) : data;
17
+ chunks.push(bytes);
18
+ length += bytes.length;
19
+ };
20
+ const object = (n, body) => {
21
+ offsets[n] = length;
22
+ push(`${n} 0 obj\n`);
23
+ body();
24
+ push("\nendobj\n");
25
+ };
26
+ const number = (v) => (Math.round(v * 100) / 100).toString();
27
+ // The header, and a comment of high bytes so tools take the file as binary.
28
+ push("%PDF-1.4\n");
29
+ push(new Uint8Array([0x25, 0xe2, 0xe3, 0xcf, 0xd3, 0x0a]));
30
+ // 1 the catalogue, 2 the page tree, then for each page: the page, its content, its picture.
31
+ const count = 3 + pages.length * 3;
32
+ object(1, () => push("<< /Type /Catalog /Pages 2 0 R >>"));
33
+ object(2, () => push(`<< /Type /Pages /Kids [${pages.map((_, i) => `${3 + i * 3} 0 R`).join(" ")}] /Count ${pages.length} >>`));
34
+ pages.forEach((p, i) => {
35
+ const page = 3 + i * 3, content = page + 1, picture = page + 2;
36
+ const [w, h] = p.points.map(number);
37
+ object(page, () => push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${w} ${h}] ` +
38
+ `/Resources << /XObject << /P ${picture} 0 R >> >> /Contents ${content} 0 R >>`));
39
+ const draw = `q ${w} 0 0 ${h} 0 0 cm /P Do Q`;
40
+ object(content, () => push(`<< /Length ${draw.length} >>\nstream\n${draw}\nendstream`));
41
+ object(picture, () => {
42
+ push(`<< /Type /XObject /Subtype /Image /Width ${p.width} /Height ${p.height} /ColorSpace /DeviceRGB ` +
43
+ `/BitsPerComponent 8 /Filter /DCTDecode /Length ${p.jpeg.length} >>\nstream\n`);
44
+ push(p.jpeg);
45
+ push("\nendstream");
46
+ });
47
+ });
48
+ // The cross-reference table: each entry exactly 20 bytes.
49
+ const table = length;
50
+ push(`xref\n0 ${count}\n0000000000 65535 f \n`);
51
+ for (let n = 1; n < count; n++) push(`${String(offsets[n]).padStart(10, "0")} 00000 n \n`);
52
+ push(`trailer\n<< /Size ${count} /Root 1 0 R >>\nstartxref\n${table}\n%%EOF\n`);
53
+ return new Blob(chunks, { type: "application/pdf" });
54
+ }
@@ -0,0 +1,86 @@
1
+ // The gallery's picture worker: the engine's WebGPU renderer with a GPU device of its own
2
+ // (geometryview-thumbs-web, Engine/browser/web_thumbs.cpp), drawing cache files one at a time as
3
+ // the Mac's thumbnailer does, so pictures never hold up the page's own frames.
4
+ //
5
+ // Messages in: { type: "draw", id, blob, pixels, style, colorMode, pixelsPerPoint, lineScale,
6
+ // firstColor, folderColor, keep }, blob a cache file as a Blob or ArrayBuffer.
7
+ // A file of one part takes `folderColor`, if given. With keep { key, picture },
8
+ // the picture, its note and the file's facts are kept in the origin-private file
9
+ // system (kept.js), written here since only a worker can write there everywhere.
10
+ // { type: "trim" }, between pictures: lets go of the last file drawn
11
+ // Messages out: { type: "ready" } or { type: "failed", message }, once
12
+ // { type: "drawn", id, bitmap, png, width, height, worldPerPixel, partRect, inkRect,
13
+ // facts, firstColor, heapMB }, the bitmap to show at once and the PNG to keep; or
14
+ // { type: "drawn", id, message } if it could not be drawn
15
+ //
16
+ // Its memory never shrinks, so the page replaces it once heapMB passes a limit.
17
+
18
+ import createThumbs from "./build/geometryview-thumbs-web.mjs";
19
+ import { keepPicture } from "./kept.js";
20
+
21
+ let module = null;
22
+ let answer = null; // the draw waiting for thumbsHost.drawn
23
+
24
+ const host = {
25
+ ready() {
26
+ self.postMessage({ type: "ready" });
27
+ },
28
+ failed(message) {
29
+ self.postMessage({ type: "failed", message });
30
+ },
31
+ drawn(pixels, width, height, worldPerPixel, rects, message) {
32
+ const resolve = answer;
33
+ answer = null;
34
+ resolve?.({ pixels, width, height, worldPerPixel, rects, message });
35
+ },
36
+ };
37
+
38
+ async function draw(data) {
39
+ const bytes = new Uint8Array(data.blob instanceof Blob ? await data.blob.arrayBuffer() : data.blob);
40
+ // Each call may grow the heap, which detaches views made before it: read them after.
41
+ const staging = module._thumbs_staging(bytes.length);
42
+ module.HEAPU8.set(bytes, staging);
43
+ // Its facts first: drawing lets go of the staged file.
44
+ const factsText = module.UTF8ToString(module._thumbs_facts());
45
+ const facts = factsText ? JSON.parse(factsText) : null;
46
+ const firstColor = data.folderColor >= 0 && facts?.parts === 1 ? data.folderColor : data.firstColor ?? -1;
47
+ const drawn = await new Promise((resolve) => {
48
+ answer = resolve;
49
+ module._thumbs_draw(data.pixels, data.style, data.colorMode, data.pixelsPerPoint ?? 2,
50
+ data.lineScale ?? 0.5, firstColor, data.turn ?? 0, data.worldPerPixel ?? 0,
51
+ data.supersample ?? 1);
52
+ });
53
+ const heapMB = Math.round(module._thumbs_heap_bytes() / 2 ** 20);
54
+ if (!drawn.pixels) {
55
+ self.postMessage({ type: "drawn", id: data.id, message: drawn.message || "This picture could not be drawn.", heapMB });
56
+ return;
57
+ }
58
+ const { width, height } = drawn;
59
+ const canvas = new OffscreenCanvas(width, height);
60
+ canvas.getContext("2d").putImageData(new ImageData(new Uint8ClampedArray(drawn.pixels.buffer), width, height), 0, 0);
61
+ const png = await canvas.convertToBlob({ type: "image/png" });
62
+ const bitmap = canvas.transferToImageBitmap();
63
+ const note = { width, height, mmPerPixel: drawn.worldPerPixel, part: drawn.rects.slice(0, 4),
64
+ ink: drawn.rects.slice(4, 8), firstColor };
65
+ if (data.keep) await keepPicture(data.keep.key, data.keep.picture, png, note, facts).catch(() => {});
66
+ self.postMessage({ type: "drawn", id: data.id, bitmap, png, width, height, worldPerPixel: drawn.worldPerPixel,
67
+ partRect: note.part, inkRect: note.ink, facts, firstColor, heapMB }, [bitmap]);
68
+ }
69
+
70
+ // One message at a time, in order, so a draw never starts while another waits for the GPU.
71
+ let queue = Promise.resolve();
72
+ self.onmessage = ({ data }) => {
73
+ queue = queue.then(async () => {
74
+ try {
75
+ if (data.type === "draw") await draw(data);
76
+ else if (data.type === "trim") module?._thumbs_trim();
77
+ } catch (error) {
78
+ if (data.type === "draw") self.postMessage({ type: "drawn", id: data.id, message: String(error.message ?? error) });
79
+ }
80
+ });
81
+ };
82
+
83
+ createThumbs({ thumbsHost: host }).then((created) => {
84
+ module = created;
85
+ module._thumbs_start();
86
+ }, (error) => host.failed(String(error.message ?? error)));