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. |
| 24 | const SP_PER_PT = 65536; |
| 25 | const sp = v => v / SP_PER_PT; |
| 26 | |
| 27 | // A colour list [r, g, b, a] -> "#rrggbb", matching the Rust `rgb()` helper. |
| 28 | function 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. |
| 34 | function opacity(c) { return (c[3] / 255).toFixed(3); } |
| 35 | |
| 36 | const SVGNS = "http://www.w3.org/2000/svg"; |
| 37 | |
| 38 | function 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. |
| 48 | function 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. |
| 55 | function 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`. |
| 207 | function 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`. |
| 221 | function 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. |
| 242 | function 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. |
| 256 | function 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. |
| 266 | function 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 | |
| 286 | function 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. |
| 319 | function 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. |
| 356 | function 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. |
| 366 | function 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). |
| 410 | function 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. |
| 418 | async 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 | |
| 424 | window.Pearl = { |
| 425 | renderDocument, renderPage, renderText, loadAndRender, |
| 426 | linksOnPage, resolveLink, annotationsForBlock, renderAnnotation, |
| 427 | resolveAnchor, outlineEntries, |
| 428 | sp, SP_PER_PT, |
| 429 | }; |