// The on-screen layer that draws marks over a rendered page. // // One SVG per page, sized to that page's current viewport in CSS pixels. // Every mark is stored in PDF user space, so drawing means running each point // through `viewport.convertToViewportPoint` — which already accounts for the // page's scale AND its rotation. That is why rotating a page needs no special // handling here: the viewport changes, we re-render, and the marks turn with // the content they were drawn on. // // SVG rather than a canvas because marks have to be individually // hit-testable, selectable and movable, and because it stays crisp when the // user zooms without us re-rasterising anything. import { arrowBarbs, boundsOf, signatureStrokes, textLayout } from "./shape.js"; const NS = "http://www.w3.org/2000/svg"; function el(name, attrs) { const n = document.createElementNS(NS, name); for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]); return n; } export class Overlay { /** @param {HTMLElement} pageDiv the .page div owned by a PDFPageView */ constructor(pageDiv) { this.svg = el("svg", { class: "smOverlay", xmlns: NS }); this.marks = el("g", { class: "marks" }); this.chrome = el("g", { class: "chrome" }); // selection box + handles this.svg.append(this.marks, this.chrome); pageDiv.append(this.svg); this.viewport = null; } destroy() { this.svg.remove(); } /** Map a user-space point to CSS pixels inside the page div. */ toView(x, y) { const [vx, vy] = this.viewport.convertToViewportPoint(x, y); return [vx, vy]; } /** Map a page-relative CSS pixel point back to user space. */ toPdf(vx, vy) { const [x, y] = this.viewport.convertToPdfPoint(vx, vy); return [x, y]; } render(viewport, annots, selectedId) { this.viewport = viewport; this.svg.setAttribute("width", viewport.width); this.svg.setAttribute("height", viewport.height); this.svg.setAttribute("viewBox", `0 0 ${viewport.width} ${viewport.height}`); this.marks.textContent = ""; for (const a of annots) { const node = this._draw(a); if (!node) continue; node.classList.add("mark"); node.dataset.id = a.id; if (a.id === selectedId) node.classList.add("sel"); this.marks.append(node); } this._drawChrome(annots.find((a) => a.id === selectedId) || null); } // Scale a user-space length (a stroke width, a font size) into CSS pixels. // The viewport's scale already carries zoom; rotation does not change length. get k() { return this.viewport ? this.viewport.scale : 1; } _draw(a) { const g = el("g", {}); switch (a.kind) { case "highlight": { for (const r of a.rects || []) { const [x0, y1] = this.toView(r.x, r.y + r.h); const [x1, y0] = this.toView(r.x + r.w, r.y); g.append(el("rect", { x: Math.min(x0, x1), y: Math.min(y0, y1), width: Math.abs(x1 - x0), height: Math.abs(y0 - y1), fill: a.color, "fill-opacity": a.opacity ?? 0.38, style: "mix-blend-mode:multiply", })); } g.dataset.fill = "1"; return g; } case "pen": { const pts = (a.pts || []).map(([x, y]) => this.toView(x, y)); if (pts.length < 2) return null; g.append(el("polyline", { points: pts.map((p) => `${p[0]},${p[1]}`).join(" "), fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k, "stroke-linecap": "round", "stroke-linejoin": "round", })); return g; } case "rect": { const b = this._box(a); g.append(el("rect", { ...b, fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k })); return g; } case "ellipse": { const b = this._box(a); g.append(el("ellipse", { cx: b.x + b.width / 2, cy: b.y + b.height / 2, rx: Math.max(0.5, b.width / 2), ry: Math.max(0.5, b.height / 2), fill: "none", stroke: a.color, "stroke-width": (a.width || 2) * this.k, })); return g; } case "arrow": { const [x1, y1] = this.toView(a.x1, a.y1); const [x2, y2] = this.toView(a.x2, a.y2); const w = (a.width || 2) * this.k; const barbs = arrowBarbs(a.x1, a.y1, a.x2, a.y2, a.width || 2).map((p) => this.toView(p.x, p.y)); g.append(el("line", { x1, y1, x2, y2, stroke: a.color, "stroke-width": w, "stroke-linecap": "round" })); g.append(el("polyline", { points: `${barbs[0][0]},${barbs[0][1]} ${x2},${y2} ${barbs[1][0]},${barbs[1][1]}`, fill: "none", stroke: a.color, "stroke-width": w, "stroke-linecap": "round", "stroke-linejoin": "round", })); return g; } case "text": { const size = (a.size || 12) * this.k; let widest = 0; for (const line of textLayout(a)) { const [vx, vy] = this.toView(line.x, line.y); const t = el("text", { x: vx, y: vy, fill: a.color, "font-family": "Helvetica, Arial, sans-serif", "font-size": size, "xml:space": "preserve", }); t.textContent = line.text; g.append(t); } g.dataset.fill = "1"; // Measure once attached so the selection box is tight. Cached on the // mark in user-space units; boundsOf() reads it. requestAnimationFrame(() => { try { for (const t of g.querySelectorAll("text")) widest = Math.max(widest, t.getComputedTextLength()); if (widest > 0) a._w = widest / this.k; } catch {} }); return g; } case "signature": { const w = (a.width || 1.6) * this.k; for (const stroke of signatureStrokes(a)) { if (stroke.length < 2) continue; g.append(el("polyline", { points: stroke.map(([x, y]) => this.toView(x, y).join(",")).join(" "), fill: "none", stroke: a.color, "stroke-width": w, "stroke-linecap": "round", "stroke-linejoin": "round", })); } return g; } case "redact": { const b = this._box(a); g.append(el("rect", { ...b, fill: "#000" })); g.dataset.fill = "1"; return g; } default: return null; } } // A user-space {x,y,w,h} box as an SVG rect's attributes. Done through both // corners so a rotated viewport lands the right way up. _box(a) { const [ax, ay] = this.toView(a.x, a.y); const [bx, by] = this.toView(a.x + a.w, a.y + a.h); return { x: Math.min(ax, bx), y: Math.min(ay, by), width: Math.abs(bx - ax), height: Math.abs(by - ay) }; } _drawChrome(sel) { this.chrome.textContent = ""; if (!sel) return; const b = boundsOf(sel); const r = this._box({ x: b.x, y: b.y, w: b.w, h: b.h }); const pad = 3; this.chrome.append(el("rect", { class: "selbox", x: r.x - pad, y: r.y - pad, width: r.width + pad * 2, height: r.height + pad * 2, })); } /** The mark id under a page-relative CSS pixel point, or null. */ hitTest(vx, vy) { const node = document.elementFromPoint( vx + this.svg.getBoundingClientRect().left, vy + this.svg.getBoundingClientRect().top); const mark = node?.closest?.(".mark"); return mark ? mark.dataset.id : null; } }