Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/web/pearl-reader/pearl.js

17.3 KiB, 60 runs

created by r1870400018:38480, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// pearl.js -- a browser reader for the Pearl (.prl) document format.
2//
3// It renders a Pearl document to inline SVG from the format's own data model -- glyph outlines stored
4// once, placed per leaf, plus fills, strokes, rules and rasters -- reproducing the transform the
5// Austenite SVG arm applies (see fe2o3_austenite/src/emit/pearl.rs render_page and
6// src/emit/svg.rs draw_text/run_text_layer). The goal is pixel parity with that arm's SVG, which already
7// matches the PDF.
8//
9// A `text` leaf's fields past its rigid geometry and outline glyphs (size, selectable spans, the
10// optional colour) ride in leaf[7], a v1 self-describing keyed object -- see pearl.rs's own comment on
11// why a positional tail was dropped. `spans` there is the same cluster-to-source-text mapping the Rust
12// SVG and PDF writers derive from `ShapedText::glyph_text`, so this reader's selectable `.tsel` layer
13// agrees with both of them about what each glyph says.
14//
15// Transport: the reader fetches the .prl and parses its text jdat directly in the browser (jdat.js) --
16// there is no JSON projection any more. Nothing here is pre-rendered: the page is built from the
17// outlines and placements the .prl carries. A typed jdat atom decodes to the same plain value its old
18// JSON projection did -- an omap to an object, a list to an array, u32/i32/u8/f32 to a Number, a base64
19// PNG to a string -- so the renderer below is unchanged from the JSON-fed first cut.
20
21"use strict";
22
23// One point is 65536 scaled points (Sp), as in TeX; every stored length is an Sp integer.
24const SP_PER_PT = 65536;
25const sp = v => v / SP_PER_PT;
26
27// A colour list [r, g, b, a] -> "#rrggbb", matching the Rust `rgb()` helper.
28function rgb(c) {
29 const h = n => n.toString(16).padStart(2, "0");
30 return "#" + h(c[0]) + h(c[1]) + h(c[2]);
31}
32
33// The fill/stroke opacity string, only written when the colour is not opaque, as `opacity()` does.
34function opacity(c) { return (c[3] / 255).toFixed(3); }
35
36const SVGNS = "http://www.w3.org/2000/svg";
37
38function el(name, attrs) {
39 const e = document.createElementNS(SVGNS, name);
40 for (const k in attrs) {
41 if (attrs[k] !== null && attrs[k] !== undefined) e.setAttribute(k, attrs[k]);
42 }
43 return e;
44}
45
46// A `<tspan>` at (x, y) in `size`, carrying `text` -- the selectable text layer's one building block,
47// used for both a run's own glyph spans and the synthetic interword space between two runs.
48function tspanEl(x, y, size, text) {
49 const t = el("tspan", { x, y, "font-size": size });
50 t.textContent = text;
51 return t;
52}
53
54// Renders one page block to an <svg> element, reproducing the SVG arm leaf by leaf.
55function renderPage(doc, blockKey) {
56 const block = doc.blocks[blockKey];
57 const glyphs = doc.glyphs;
58 const images = doc.images;
59
60 // The viewport is the media box: geometry width/height rounded to whole points.
61 const geom = block.geom;
62 const w = Math.round(sp(geom[0]));
63 const h = Math.round(sp(geom[1]));
64
65 const svg = el("svg", {
66 xmlns: SVGNS,
67 width: w,
68 height: h,
69 viewBox: `0 0 ${w} ${h}`,
70 });
71 svg.appendChild(el("rect", { x: 0, y: 0, width: w, height: h, fill: "#ffffff" }));
72
73 // The selectable text layer's style, matching the SVG arm's own <style> declaration verbatim.
74 const tselStyle = el("style", {});
75 tselStyle.textContent = ".tsel { fill: transparent; }";
76 svg.appendChild(tselStyle);
77
78 // Gathered across every "text" leaf below into ONE page-wide <text>, appended once at the end --
79 // see svg.rs's run_text_layer for why one element per run breaks a browser's cross-element search. A
80 // leading space precedes every run but the page's first, standing in for the interword gap Austenite's
81 // line breaker leaves as pure position rather than a glyph.
82 const tsel = el("text", { class: "tsel" });
83 let tselHasText = false;
84
85 for (const leaf of block.leaves) {
86 const tag = leaf[0];
87 switch (tag) {
88 case "text": {
89 // base_x = x, base_y = y + height (the baseline); each glyph is the stored outline
90 // flipped in y and moved to (base_x + gx, base_y - gy).
91 const baseX = sp(leaf[1]);
92 const baseY = sp(leaf[2]) + sp(leaf[4]);
93 // A leaf without a `colour` key is black, the form every pre-colour text leaf took --
94 // matching the Rust reader's own default at pearl.rs's `colour` lookup.
95 const meta = leaf[7];
96 const c = meta && meta.colour;
97 for (const g of leaf[6]) {
98 const entry = glyphs[g[0]];
99 const d = entry && entry.d;
100 if (!d) continue; // A space carries an advance but no ink.
101 const tx = baseX + g[1];
102 const ty = baseY - g[2];
103 svg.appendChild(el("path", {
104 d,
105 transform: `matrix(1,0,0,-1,${tx},${ty})`,
106 fill: c ? rgb(c) : "#000000",
107 "fill-opacity": c && c[3] < 255 ? opacity(c) : null,
108 }));
109 }
110 // The run's selectable twin: leaf[7].spans maps each text-bearing glyph (spaces included)
111 // to its source text, positioned exactly as its outline was above.
112 const runSpans = (meta && meta.spans) || [];
113 if (runSpans.length > 0) {
114 if (tselHasText) tsel.appendChild(tspanEl(baseX, baseY, meta.size, " "));
115 for (const s of runSpans) {
116 tsel.appendChild(tspanEl(baseX + s[0], baseY - s[1], meta.size, s[2]));
117 }
118 tselHasText = true;
119 }
120 break;
121 }
122 case "rule":
123 case "reserved": {
124 const x0 = sp(leaf[1]);
125 const y0 = sp(leaf[2]);
126 const x1 = sp(leaf[1] + leaf[3]);
127 const y1 = sp(leaf[2] + leaf[4] + leaf[5]); // y + height + depth
128 if (x1 <= x0 || y1 <= y0) continue; // A zero-area box draws nothing.
129 const rectAttrs = { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
130 if (tag === "rule") {
131 svg.appendChild(el("rect", { ...rectAttrs, fill: "#000000" }));
132 } else {
133 // A reservation: a half-point grey stroke, pen grey = (176,176,176).
134 svg.appendChild(el("rect", {
135 ...rectAttrs,
136 fill: "none",
137 stroke: "#b0b0b0",
138 "stroke-width": 0.5,
139 "stroke-linecap": "butt",
140 "stroke-linejoin": "miter",
141 "stroke-miterlimit": 4,
142 }));
143 }
144 break;
145 }
146 case "fill": {
147 // A pre-translated path: draw the d string at (bx, by), filled with its colour.
148 const c = leaf[4];
149 svg.appendChild(el("path", {
150 d: leaf[3],
151 transform: `translate(${sp(leaf[1])},${sp(leaf[2])})`,
152 fill: rgb(c),
153 "fill-opacity": c[3] < 255 ? opacity(c) : null,
154 }));
155 break;
156 }
157 case "stroke": {
158 const c = leaf[4];
159 svg.appendChild(el("path", {
160 d: leaf[3],
161 transform: `translate(${sp(leaf[1])},${sp(leaf[2])})`,
162 fill: "none",
163 stroke: rgb(c),
164 "stroke-opacity": c[3] < 255 ? opacity(c) : null,
165 "stroke-width": leaf[5],
166 "stroke-linecap": "butt",
167 "stroke-linejoin": "miter",
168 "stroke-miterlimit": 4,
169 }));
170 break;
171 }
172 case "image": {
173 // The raster's frame is the page's own, top-left, y down, so the box is placed directly.
174 const ox = sp(leaf[1]);
175 const oy = sp(leaf[2]);
176 const gx = leaf[3], gy = leaf[4], iw = leaf[5], ih = leaf[6];
177 const entry = images[leaf[7]];
178 svg.appendChild(el("image", {
179 x: ox + gx,
180 y: oy + gy,
181 width: iw,
182 height: ih,
183 preserveAspectRatio: "none",
184 href: "data:image/png;base64," + entry.png,
185 }));
186 break;
187 }
188 case "link":
189 // A link leaf places no ink -- it is a hotspot, drawn by the overlay layer, not the SVG.
190 break;
191 default:
192 console.warn("Unknown Pearl leaf kind:", tag);
193 }
194 }
195 if (tselHasText) svg.appendChild(tsel);
196 return svg;
197}
198
199// ---------------------------------------------------------------------------------------------------
200// Links: reading the `link` leaves off a page, and resolving a target the way `PearlDoc::resolve_link`
201// does -- an external uri stands as its address; an internal anchor goes through the shipped ledger to a
202// page, then through the index to that page's content-addressed block.
203// ---------------------------------------------------------------------------------------------------
204
205// The `link` leaves on the page at `idx`: each carries a rectangle in scaled points and a target, in the
206// order they were emitted, mirroring `PearlDoc::links_on_page`.
207function linksOnPage(doc, idx) {
208 const entry = doc.index[idx];
209 const block = doc.blocks[entry.block];
210 const out = [];
211 for (const leaf of block.leaves) {
212 if (leaf[0] !== "link") continue;
213 out.push({ x: leaf[1], y: leaf[2], w: leaf[3], h: leaf[4], target: leaf[5] });
214 }
215 return out;
216}
217
218// A stored link target -- `["uri", addr]` or `["anchor", { kind, key }]` -- resolved to where it points.
219// Returns { kind: "uri", uri } for an external target; { kind: "block", block, page } for an internal one
220// the ledger has fixed; or null for a dangling cross-reference, exactly as `resolve_link` returns `None`.
221function resolveLink(doc, target) {
222 const tag = target[0];
223 if (tag === "uri") {
224 return { kind: "uri", uri: target[1] };
225 }
226 if (tag === "anchor") {
227 const id = target[1]; // { kind: <u8 tag>, key: <string> }
228 const ledger = doc.ledger;
229 const anchor = (ledger.anchors || []).find(a => a.id.kind === id.kind && a.id.key === id.key);
230 if (!anchor) return null; // the ledger has not fixed this anchor
231 const page = anchor.page;
232 const hit = doc.index.find(e => e.page === page);
233 if (!hit) return null; // the anchor's page is not one the index holds
234 return { kind: "block", block: hit.block, page };
235 }
236 console.warn("Unknown Pearl link-target kind:", tag);
237 return null;
238}
239
240// The annotations anchored to a given block hash, in the order they were added. A file written before the
241// annotations section existed simply carries none.
242function annotationsForBlock(doc, blockHash) {
243 return (doc.annotations || []).filter(a => a.anchor === blockHash);
244}
245
246// ---------------------------------------------------------------------------------------------------
247// The outline: the heading tree carried in the `.prl` header, and the ledger lookup that fixes each
248// heading to a page and a y -- the same anchor resolution `resolveLink` performs for a cross-reference,
249// so a table-of-contents entry and a link to the same heading land in the same place.
250// ---------------------------------------------------------------------------------------------------
251
252// Resolves a stored anchor `{ kind, key }` through the shipped ledger to `{ page, y }` -- the 1-based
253// page and the y within it, both as the ledger recorded them -- or null when the ledger never fixed it,
254// exactly the dangling case `resolveLink` returns null for. Unlike `resolveLink` this keeps the y, so a
255// jump lands on the heading's own line rather than the page top.
256function resolveAnchor(doc, anchor) {
257 const a = (doc.ledger.anchors || []).find(x => x.id.kind === anchor.kind && x.id.key === anchor.key);
258 if (!a) return null;
259 return { page: a.page, y: a.y };
260}
261
262// The document's heading outline as an array of `{ level, number, title, page, y }`, each entry resolved
263// through the ledger, mirroring `PearlDoc::outline` on the Rust side. `page`/`y` are null for a heading
264// the ledger never fixed. A `.prl` without an outline section (a headless manuscript, or a file that
265// predates the field) yields an empty array.
266function outlineEntries(doc) {
267 return (doc.outline || []).map(e => {
268 const loc = resolveAnchor(doc, e.anchor);
269 return {
270 level: e.level,
271 number: e.number || "",
272 title: e.title || "",
273 page: loc ? loc.page : null,
274 y: loc ? loc.y : null,
275 };
276 });
277}
278
279// ---------------------------------------------------------------------------------------------------
280// Rendering the document, plus an overlay layer per page carrying link hotspots and annotations. The
281// SVG is authored in points and drawn at 1 user unit = 1 px (its width/height attributes are the point
282// dimensions), so a scaled-point length converts to a CSS pixel through `sp()` alone -- no page scale to
283// track.
284// ---------------------------------------------------------------------------------------------------
285
286function renderDocument(doc, container) {
287 container.innerHTML = "";
288 // A version mismatch is refused outright, matching the Rust reader's own `PearlDoc::from_string`
289 // check -- a v0 file must fail loudly rather than render silently with no `.tsel` layer.
290 if (doc.pearl !== "1") {
291 throw new Error(`This reader speaks Pearl v1, but the file is v${doc.pearl}.`);
292 }
293
294 // Build every page first, keeping the DOM node beside its index entry so an internal link can scroll
295 // its target block into view.
296 const pageEls = [];
297 doc.index.forEach((entry, idx) => {
298 const page = document.createElement("div");
299 page.className = "pearl-page";
300 page.dataset.block = entry.block;
301 page.appendChild(renderPage(doc, entry.block));
302
303 const overlay = document.createElement("div");
304 overlay.className = "pearl-overlay";
305 page.appendChild(overlay);
306
307 container.appendChild(page);
308 pageEls.push(page);
309
310 addLinks(doc, idx, overlay, container);
311 addAnnotations(doc, entry.block, overlay);
312 });
313 return pageEls;
314}
315
316// Lays a clickable hotspot over each link leaf: an external uri opens in a new tab; an internal anchor
317// resolves and scrolls the target page's block into view. Each hotspot shows a subtle box-and-underline
318// so a reader can see it is a link, the affordance the SVG arm draws no ink for.
319function addLinks(doc, idx, overlay, container) {
320 for (const link of linksOnPage(doc, idx)) {
321 const res = resolveLink(doc, link.target);
322 const spot = document.createElement("a");
323 spot.className = "pearl-link" + (res && res.kind === "uri" ? " ext" : " int");
324 spot.style.left = sp(link.x) + "px";
325 spot.style.top = sp(link.y) + "px";
326 spot.style.width = sp(link.w) + "px";
327 spot.style.height = sp(link.h) + "px";
328
329 if (res && res.kind === "uri") {
330 spot.href = res.uri;
331 spot.target = "_blank";
332 spot.rel = "noopener";
333 spot.title = res.uri;
334 console.log(`link (page ${idx + 1}): external -> ${res.uri}`);
335 } else if (res && res.kind === "block") {
336 spot.href = "#";
337 spot.title = `page ${res.page}`;
338 spot.addEventListener("click", (ev) => {
339 ev.preventDefault();
340 const tgt = container.querySelector(`.pearl-page[data-block="${res.block}"]`);
341 if (tgt) tgt.scrollIntoView({ behavior: "smooth", block: "start" });
342 });
343 console.log(`link (page ${idx + 1}): internal -> block ${res.block.slice(0, 8)}… on page ${res.page}`);
344 } else {
345 // A dangling cross-reference: mark it, but do not pretend it goes anywhere.
346 spot.className += " dead";
347 spot.title = "unresolved link";
348 console.warn(`link (page ${idx + 1}): unresolved target`, link.target);
349 }
350 overlay.appendChild(spot);
351 }
352}
353
354// Draws the annotations anchored to this page's block, in order. Each is placed by `renderAnnotation`,
355// which the authoring layer also calls to show a freshly created annotation without a full repaint.
356function addAnnotations(doc, blockHash, overlay) {
357 for (const ann of annotationsForBlock(doc, blockHash)) {
358 renderAnnotation(ann, overlay);
359 }
360}
361
362// Places a single annotation into a page's overlay: a `highlight` is a translucent rectangle over its
363// `rect` (or a left-edge band when it has none); a `note` is a margin marker that reveals its payload and
364// author on click. Notes stack down the margin, the running row kept on the overlay so a later addition
365// lands below the ones already there.
366function renderAnnotation(ann, overlay) {
367 if (ann.kind === "highlight") {
368 const r = ann.rect;
369 const box = document.createElement("div");
370 box.className = "pearl-highlight";
371 if (r) {
372 box.style.left = sp(r[0]) + "px";
373 box.style.top = sp(r[1]) + "px";
374 box.style.width = sp(r[2]) + "px";
375 box.style.height = sp(r[3]) + "px";
376 } else {
377 // A whole-block highlight: a thin band down the page's left edge, so it is visible but does
378 // not blanket the text.
379 box.style.left = "0"; box.style.top = "0"; box.style.width = "6px"; box.style.height = "100%";
380 }
381 if (ann.payload) box.title = ann.payload;
382 overlay.appendChild(box);
383 } else if (ann.kind === "note") {
384 const noteRow = overlay._noteRow || 0;
385 overlay._noteRow = noteRow + 1;
386
387 const marker = document.createElement("button");
388 marker.className = "pearl-note";
389 marker.textContent = "✎"; // a pencil, the note affordance
390 marker.style.top = (18 + noteRow * 30) + "px";
391
392 const bubble = document.createElement("div");
393 bubble.className = "pearl-note-bubble";
394 bubble.innerHTML =
395 `<div class="pearl-note-text"></div><div class="pearl-note-meta"></div>`;
396 bubble.querySelector(".pearl-note-text").textContent = ann.payload;
397 bubble.querySelector(".pearl-note-meta").textContent =
398 `${ann.author || "unknown"} · ${ann.created || ""}`;
399 marker.addEventListener("click", () => {
400 bubble.classList.toggle("open");
401 });
402 marker.appendChild(bubble);
403 overlay.appendChild(marker);
404 }
405}
406
407// Parses a .prl's text jdat into the document model and renders it into `container`. The source text is
408// kept on the returned model as `__source`, so the authoring layer can splice an updated annotations
409// section back into the original document byte for byte (see authoring.js).
410function renderText(text, container) {
411 const doc = Jdat.parse(text);
412 doc.__source = text;
413 renderDocument(doc, container);
414 return doc;
415}
416
417// Fetches a .prl by URL, then parses and renders it.
418async function loadAndRender(url, container) {
419 const res = await fetch(url, { cache: "no-store" });
420 if (!res.ok) throw new Error(`Failed to load ${url}: ${res.status}`);
421 return renderText(await res.text(), container);
422}
423
424window.Pearl = {
425 renderDocument, renderPage, renderText, loadAndRender,
426 linksOnPage, resolveLink, annotationsForBlock, renderAnnotation,
427 resolveAnchor, outlineEntries,
428 sp, SP_PER_PT,
429};