// 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, editLayout, handlesFor, signatureStrokes, textLayout, RULE_OFFSET, SEGMENT_KINDS } from "./shape.js"; const NS = "http://www.w3.org/2000/svg"; const HANDLE = 9; // grab-handle size in CSS px, deliberately not scaled with zoom let clipSeq = 0; // clipPath ids must be unique across every page in the document const HIT_TOLERANCE = 14; // CSS px of grab room around a thin stroke 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 }); // Marks are clipped to the page, because a PDF reader shows nothing // outside the crop box and a mark that spilled over the edge on screen // would vanish on save. The selection chrome is NOT clipped: a mark // flush against the edge still needs grabbable handles, and those sit // just outside its bounds. this.clipId = `smclip-${++clipSeq}`; const defs = el("defs", {}); this.clipRect = el("rect", { x: 0, y: 0, width: 0, height: 0 }); const clip = el("clipPath", { id: this.clipId }); clip.append(this.clipRect); defs.append(clip); this.marks = el("g", { class: "marks", "clip-path": `url(#${this.clipId})` }); this.chrome = el("g", { class: "chrome" }); // selection box + handles this.svg.append(defs, 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._selId = selectedId; this.svg.setAttribute("width", viewport.width); this.svg.setAttribute("height", viewport.height); this.svg.setAttribute("viewBox", `0 0 ${viewport.width} ${viewport.height}`); this.clipRect.setAttribute("width", viewport.width); this.clipRect.setAttribute("height", viewport.height); this.marks.textContent = ""; for (const a of annots) { const node = this._draw(a); if (!node) continue; this._addHitArea(node, a); 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 b = this._box({ x: r.x, y: r.y, w: r.w, h: r.h }); g.append(el("rect", { ...b, fill: a.color, "fill-opacity": a.opacity ?? 0.38, style: "mix-blend-mode:multiply", })); } g.dataset.fill = "1"; return g; } // Underline and strikeout share the highlight's geometry — one rect per // line of selected text — and differ only in where the rule sits inside // that rect. Keeping them on the same shape means a selection spanning a // page break splits the same way for all three. case "underline": case "strikeout": { const frac = RULE_OFFSET[a.kind]; for (const r of a.rects || []) { const yy = r.y + r.h * frac; const [x1, y1] = this.toView(r.x, yy); const [x2, y2] = this.toView(r.x + r.w, yy); g.append(el("line", { x1, y1, x2, y2, stroke: a.color, "stroke-width": Math.max(0.6, (a.width || 1.4)) * this.k, "stroke-linecap": "butt", })); } 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, ...this._paint(a) })); if (a.fill) g.dataset.fill = "1"; 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), ...this._paint(a), })); if (a.fill) g.dataset.fill = "1"; return g; } case "line": { const [x1, y1] = this.toView(a.x1, a.y1); const [x2, y2] = this.toView(a.x2, a.y2); g.append(el("line", { x1, y1, x2, y2, stroke: a.color, "stroke-width": (a.width || 2) * this.k, "stroke-linecap": "round", })); 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", "font-weight": a.bold ? "bold" : "normal", "font-style": a.italic ? "italic" : "normal", }); t.textContent = line.text; g.append(t); } g.dataset.fill = "1"; // A string's width is only knowable once the browser has laid it out, // so it is measured a frame later and cached on the mark in user-space // units for boundsOf(). The selection box is drawn from that, and was // drawn before the measurement existed — so redraw it once the real // width is in, or the box and its handles sit at the fallback guess. requestAnimationFrame(() => { try { for (const t of g.querySelectorAll("text")) widest = Math.max(widest, t.getComputedTextLength()); if (widest <= 0) return; const w = widest / this.k; const changed = Math.abs((a._w ?? 0) - w) > 0.5; a._w = w; if (changed && a.id === this._selId) this._drawChrome(a); } 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; } // A replaced text run: the original covered in the page's own background // colour, the replacement drawn on the original's baseline. Rendering it // here from the same numbers the writer uses is what makes the preview // honest — the cover is the part people need to trust. case "textedit": { const b = this._box(a); g.append(el("rect", { ...b, fill: a.cover || "#ffffff" })); const size = (a.size || 12) * this.k; for (const line of editLayout(a)) { const [vx, vy] = this.toView(line.x, line.y); const t = el("text", { x: vx, y: vy, fill: a.color || "#000000", "font-family": a.family || "Helvetica, Arial, sans-serif", "font-size": size, "xml:space": "preserve", "font-weight": a.bold ? "bold" : "normal", "font-style": a.italic ? "italic" : "normal", }); t.textContent = line.text; g.append(t); } g.dataset.fill = "1"; 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; } } /** * Give a mark something to grab. * * A 2 pt outline is about one screen pixel: selecting it means hitting a * hairline exactly, which is miserable with a mouse and impossible with a * trackpad. Each stroked shape gets an invisible copy of itself with a fat * stroke underneath, purely to catch the pointer. `pointer-events: stroke` * hit-tests the stroke area whatever the paint is, so a transparent one * still counts. */ _addHitArea(g, a) { if (g.dataset.fill === "1") return; // already a solid target const wide = Math.max(HIT_TOLERANCE, (a.width || 2) * this.k * 1.8); const clones = []; for (const child of g.children) { if (child.tagName === "text") continue; const c = child.cloneNode(false); c.setAttribute("stroke", "transparent"); c.setAttribute("stroke-width", wide); c.setAttribute("fill", "none"); c.removeAttribute("style"); c.setAttribute("class", "hit"); clones.push(c); } for (const c of clones) g.insertBefore(c, g.firstChild); } // 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) }; } // The selection outline and its grab handles. // // Handles are drawn at a fixed pixel size rather than scaled with the page, // so they stay grabbable at 50% zoom and do not swell into the artwork at // 400%. Each carries data-handle, which is what tools.js hit-tests to tell a // resize from a move. _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, })); const names = handlesFor(sel); if (SEGMENT_KINDS.has(sel.kind)) { // A line's ends are the only meaningful grips; a bounding box would let // you stretch it in ways that never match what you were aiming at. const ends = { p1: this.toView(sel.x1, sel.y1), p2: this.toView(sel.x2, sel.y2) }; for (const n of names) this._handle(n, ends[n][0], ends[n][1], "move"); return; } const L = r.x - pad, R = r.x + r.width + pad; const T = r.y - pad, B = r.y + r.height + pad; const MX = (L + R) / 2, MY = (T + B) / 2; const at = { nw: [L, T], n: [MX, T], ne: [R, T], e: [R, MY], se: [R, B], s: [MX, B], sw: [L, B], w: [L, MY] }; const cursor = { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize" }; for (const n of names) { const p = at[n]; if (p) this._handle(n, p[0], p[1], cursor[n]); } } _handle(name, cx, cy, cursor) { const h = el("rect", { class: "handle", x: cx - HANDLE / 2, y: cy - HANDLE / 2, width: HANDLE, height: HANDLE, rx: 1.5, style: `cursor:${cursor}`, }); h.dataset.handle = name; this.chrome.append(h); } // Stroke and fill for a shape, honouring its fill and opacity. _paint(a) { return { fill: a.fill ? a.color : "none", "fill-opacity": a.fill ? (a.opacity ?? 0.25) : 0, stroke: a.color, "stroke-width": (a.width || 2) * this.k, }; } /** 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; } }