/* Copyright 2026 Mozilla Foundation * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { getDocument, GlobalWorkerOptions, OPS, PasswordResponses, } from "pdfjs-lib"; import { DOMCanvasFactory } from "pdfjs/display/canvas_factory.js"; import { makePathFromDrawOPS } from "pdfjs/display/display_utils.js"; GlobalWorkerOptions.workerSrc = typeof PDFJSDev === "undefined" ? "../src/pdf.worker.js" : "../build/pdf.worker.mjs"; const ARROW_COLLAPSED = "▶"; const ARROW_EXPANDED = "▼"; // Matches indirect object references such as "10 0 R". const REF_RE = /^\d+ \d+ R$/; // Parses "num" into { page: num }, or "numR"/"numRgen" into { ref: {num,gen} }. // Returns null for invalid input. function parseGoToInput(str) { const m = str.trim().match(/^(\d+)(R(\d+)?)?$/i); if (!m) { return null; } if (!m[2]) { return { page: parseInt(m[1]) }; } return { ref: { num: parseInt(m[1]), gen: m[3] !== undefined ? parseInt(m[3]) : 0 }, }; } // Parses "num", "numR" or "numRgen" into { num, gen }, or returns null. // Used for URL hash param parsing where a bare number means a ref, not a page. function parseRefInput(str) { const m = str.trim().match(/^(\d+)(?:R(\d+)?)?$/i); if (!m) { return null; } return { num: parseInt(m[1]), gen: m[2] !== undefined ? parseInt(m[2]) : 0 }; } let pdfDoc = null; // Page number currently displayed in the tree (null when showing a // ref/trailer). let currentPage = null; // PDFPageProxy currently shown in the render view (null when not rendering). let renderedPage = null; // Explicit zoom scale (CSS pixels per PDF point). null → auto-fit to panel. let renderScale = null; // RenderTask currently in progress, so it can be cancelled on zoom change. let currentRenderTask = null; // Operator list for the currently rendered page. Exposed as a module variable // so it can be mutated and the page redrawn via the Redraw button. let currentOpList = null; // Incremented by resetRenderView() to cancel any in-flight showRenderView(). let debugViewGeneration = 0; // Original color values before user edits: Map. // Keyed by op index so showOpDetail can tell whether a value has been changed. const originalColors = new Map(); // Breakpoint state: set of op indices, array of line elements, paused index. const breakpoints = new Set(); let opLines = []; let pausedAtIdx = null; let selectedOpLine = null; // Reverse map: OPS numeric id → string name, built once from the OPS object. const OPS_TO_NAME = Object.create(null); for (const [name, id] of Object.entries(OPS)) { OPS_TO_NAME[id] = name; } // Properties of CanvasRenderingContext2D that we track while stepping. const TRACKED_CTX_PROPS = new Set([ "direction", "fillStyle", "filter", "font", "globalAlpha", "globalCompositeOperation", "imageSmoothingEnabled", "lineCap", "lineDashOffset", "lineJoin", "lineWidth", "miterLimit", "strokeStyle", "textAlign", "textBaseline", ]); // Map> — live graphics state per tracked context. const ctxStates = new Map(); // Map>> — save() stack snapshots per context. const ctxStateStacks = new Map(); // Map — which stack frame is shown in the panel. // null = live/current; 0..N-1 = index into ctxStateStacks (0 = oldest). const ctxStackViewIdx = new Map(); // Map> — DOM elements for live updates. const gfxStateValueElements = new Map(); // Map — stack-nav DOM elements. const gfxStateNavElements = new Map(); function formatCtxValue(value) { return Array.isArray(value) ? `[${value.join(", ")}]` : String(value); } // Shallow-copy a state Map (arrays and plain objects are cloned one level // deep). function copyState(state) { const clone = v => { if (Array.isArray(v)) { return [...v]; } if (typeof v === "object" && v !== null) { return { ...v }; } return v; }; return new Map([...state].map(([k, v]) => [k, clone(v)])); } // Apply a single (label, prop, value) update to the DOM unconditionally. function _applyGfxStatePropEl(label, prop, value) { const entry = gfxStateValueElements.get(label)?.get(prop); if (!entry) { return; } if (entry.mnEls) { for (const k of ["a", "b", "c", "d", "e", "f"]) { entry.mnEls[k].textContent = formatMatrixValue(value[k]); } return; } const text = formatCtxValue(value); entry.valEl.textContent = text; entry.valEl.title = text; if (entry.swatchEl) { entry.swatchEl.style.background = String(value); } } // Update DOM for a live setter — skipped when the user is browsing a saved // state so that live updates don't overwrite the frozen view. function updateGfxStatePropEl(label, prop, value) { if (ctxStackViewIdx.get(label) !== null) { return; } _applyGfxStatePropEl(label, prop, value); } // Re-render all value DOM elements for label using the currently-viewed state. function showGfxState(label) { const viewIdx = ctxStackViewIdx.get(label); const stateToShow = viewIdx === null ? ctxStates.get(label) : ctxStateStacks.get(label)?.[viewIdx]; if (!stateToShow) { return; } for (const [prop, value] of stateToShow) { _applyGfxStatePropEl(label, prop, value); } } // Sync the stack-nav button states and position counter for a context. function updateGfxStateStackNav(label) { const nav = gfxStateNavElements.get(label); if (!nav) { return; } const stack = ctxStateStacks.get(label) ?? []; const viewIdx = ctxStackViewIdx.get(label); nav.container.hidden = stack.length === 0; if (stack.length === 0) { return; } nav.prevBtn.disabled = viewIdx === 0; nav.nextBtn.disabled = viewIdx === null; nav.pos.textContent = viewIdx === null ? "cur" : `${viewIdx + 1}/${stack.length}`; } /** * Draw a checkerboard pattern filling the canvas, to reveal transparency. * Mirrors the pattern used in src/display/editor/stamp.js. */ function drawCheckerboard(ctx, width, height) { const isHCM = prefersHCM.matches; const isDark = prefersDark.matches; let light, dark; if (isHCM) { light = "white"; dark = "black"; } else if (isDark) { light = "#8f8f9d"; dark = "#42414d"; } else { light = "white"; dark = "#cfcfd8"; } const boxDim = 15; const pattern = new OffscreenCanvas(boxDim * 2, boxDim * 2); const patternCtx = pattern.getContext("2d"); patternCtx.fillStyle = light; patternCtx.fillRect(0, 0, boxDim * 2, boxDim * 2); patternCtx.fillStyle = dark; patternCtx.fillRect(0, 0, boxDim, boxDim); patternCtx.fillRect(boxDim, boxDim, boxDim, boxDim); ctx.save(); ctx.fillStyle = ctx.createPattern(pattern, "repeat"); ctx.fillRect(0, 0, width, height); ctx.restore(); } // Override canvas.getContext to return a tracked proxy for "2d" contexts. // Caches the proxy so repeated getContext("2d") calls return the same wrapper. function wrapCanvasGetContext(canvas, label) { let wrappedCtx = null; const origGetContext = canvas.getContext.bind(canvas); canvas.getContext = (type, ...args) => { const ctx = origGetContext(type, ...args); if (type !== "2d") { return ctx; } if (!wrappedCtx) { if ( globalThis.StepperManager._active !== null && args[0]?.alpha !== false ) { drawCheckerboard(ctx, canvas.width, canvas.height); } wrappedCtx = wrapContext(ctx, label); } return wrappedCtx; }; return canvas.getContext("2d"); } // Methods that modify the current transform matrix. const TRANSFORM_METHODS = new Set([ "setTransform", "transform", "resetTransform", "translate", "rotate", "scale", ]); // Maps every tracked context property to a function that reads its current // value from a CanvasRenderingContext2D. Covers directly-readable properties // (TRACKED_CTX_PROPS) and method-read ones (lineDash, transform). const CTX_PROP_READERS = new Map([ ...Array.from(TRACKED_CTX_PROPS, p => [p, ctx => ctx[p]]), ["lineDash", ctx => ctx.getLineDash()], [ "transform", ctx => { const { a, b, c, d, e, f } = ctx.getTransform(); return { a, b, c, d, e, f }; }, ], ]); const MATHML_NS = "http://www.w3.org/1998/Math/MathML"; function mEl(tag, ...children) { const el = document.createElementNS(MATHML_NS, tag); el.append(...children); return el; } function formatMatrixValue(v) { return Number.isInteger(v) ? String(v) : String(parseFloat(v.toFixed(4))); } function buildTransformMathML({ a, b, c, d, e, f }) { const mnEls = {}; for (const [k, v] of Object.entries({ a, b, c, d, e, f })) { mnEls[k] = mEl("mn", formatMatrixValue(v)); } const math = mEl( "math", mEl( "mrow", mEl("mo", "["), mEl( "mtable", mEl( "mtr", mEl("mtd", mnEls.a), mEl("mtd", mnEls.c), mEl("mtd", mnEls.e) ), mEl( "mtr", mEl("mtd", mnEls.b), mEl("mtd", mnEls.d), mEl("mtd", mnEls.f) ), mEl( "mtr", mEl("mtd", mEl("mn", "0")), mEl("mtd", mEl("mn", "0")), mEl("mtd", mEl("mn", "1")) ) ), mEl("mo", "]") ) ); return { math, mnEls }; } // Wrap a CanvasRenderingContext2D so every setter and setLineDash/restore call // updates `ctxStates` and the live DOM elements for the given label. function wrapContext(ctx, label) { const state = new Map(); for (const [prop, read] of CTX_PROP_READERS) { state.set(prop, read(ctx)); } ctxStates.set(label, state); ctxStateStacks.set(label, []); ctxStackViewIdx.set(label, null); // If the panel is already visible (stepping in progress), rebuild it so the // new context section is added and its live-update entries are registered. if (gfxStateValueElements.size > 0) { buildGfxStatePanel(); } return new Proxy(ctx, { set(target, prop, value) { target[prop] = value; if (TRACKED_CTX_PROPS.has(prop)) { state.set(prop, value); updateGfxStatePropEl(label, prop, value); } return true; }, get(target, prop) { const val = target[prop]; if (typeof val !== "function") { return val; } if (prop === "save") { return function (...args) { const result = val.apply(target, args); ctxStateStacks.get(label).push(copyState(state)); updateGfxStateStackNav(label); return result; }; } if (prop === "restore") { return function (...args) { const result = val.apply(target, args); for (const [p, read] of CTX_PROP_READERS) { const v = read(target); state.set(p, v); updateGfxStatePropEl(label, p, v); } const stack = ctxStateStacks.get(label); if (stack.length > 0) { stack.pop(); // If the viewed frame was just removed, fall back to current. const viewIndex = ctxStackViewIdx.get(label); if (viewIndex !== null && viewIndex >= stack.length) { ctxStackViewIdx.set(label, null); showGfxState(label); } updateGfxStateStackNav(label); } return result; }; } if (prop === "setLineDash") { return function (segments) { val.call(target, segments); const dash = target.getLineDash(); state.set("lineDash", dash); updateGfxStatePropEl(label, "lineDash", dash); }; } if (TRANSFORM_METHODS.has(prop)) { return function (...args) { const result = val.apply(target, args); const { a, b, c, d, e, f } = target.getTransform(); const tf = { a, b, c, d, e, f }; state.set("transform", tf); updateGfxStatePropEl(label, "transform", tf); return result; }; } return val.bind(target); }, }); } // Custom CanvasFactory that tracks temporary canvases created during rendering. // When stepping, each temporary canvas is shown below the main page canvas so // the user can inspect intermediate compositing targets (masks, patterns, etc). class DebugCanvasFactory extends DOMCanvasFactory { // Wrapper objects currently alive: { canvas, context, wrapper, label }. #alive = []; // getDocument passes { ownerDocument, enableHWA } to the constructor. constructor({ ownerDocument, enableHWA } = {}) { super({ ownerDocument: ownerDocument ?? document, enableHWA }); } create(width, height) { const canvasAndCtx = super.create(width, height); const label = `Temp ${this.#alive.length + 1}`; canvasAndCtx.context = wrapCanvasGetContext(canvasAndCtx.canvas, label); if (globalThis.StepperManager._active !== null) { this.#attach(canvasAndCtx, width, height, label); } return canvasAndCtx; } reset(canvasAndCtx, width, height) { super.reset(canvasAndCtx, width, height); const entry = this.#alive.find(e => e.canvasAndCtx === canvasAndCtx); if (entry) { entry.labelEl.textContent = `${entry.labelEl.textContent.split("—")[0].trim()} — ${width}×${height}`; } } destroy(canvasAndCtx) { const idx = this.#alive.findIndex(e => e.canvasAndCtx === canvasAndCtx); if (idx !== -1) { this.#alive[idx].wrapper.remove(); this.#alive.splice(idx, 1); } super.destroy(canvasAndCtx); } // Show all currently-alive canvases (called when stepping starts). showAll() { for (const entry of this.#alive) { if (!entry.wrapper.isConnected) { this.#attachWrapper(entry); } } } // Remove all temporary canvases from the DOM and clear tracking state. clear() { for (const entry of this.#alive) { entry.wrapper.remove(); entry.canvasAndCtx.canvas.width = 0; entry.canvasAndCtx.canvas.height = 0; } this.#alive.length = 0; } #attach(canvasAndCtx, width, height, ctxLabel) { const wrapper = document.createElement("div"); wrapper.className = "temp-canvas-wrapper"; wrapper.addEventListener("click", () => scrollToGfxStateSection(ctxLabel)); const labelEl = document.createElement("div"); labelEl.className = "temp-canvas-label"; labelEl.textContent = `${ctxLabel} — ${width}×${height}`; wrapper.append(labelEl, canvasAndCtx.canvas); const entry = { canvasAndCtx, wrapper, labelEl }; this.#alive.push(entry); this.#attachWrapper(entry); } #attachWrapper(entry) { document.getElementById("canvas-scroll").append(entry.wrapper); } } // Cache for getRawData results, keyed by "num:gen". Cleared on each new // document. const refCache = new Map(); // Cached media query for dark mode detection. const prefersDark = window.matchMedia("(prefers-color-scheme: dark)"); // Cached media query for forced-colors (high-contrast) mode detection. const prefersHCM = window.matchMedia("(forced-colors: active)"); // Keep --dpr in sync so CSS can scale temp canvases correctly. function updateDPR() { document.documentElement.style.setProperty( "--dpr", window.devicePixelRatio || 1 ); } updateDPR(); window .matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`) .addEventListener("change", updateDPR); // Stepper for pausing/stepping through op list rendering. // Implements the interface expected by InternalRenderTask (pdfBug mode). class ViewerStepper { #continueCallback = null; // Pass resumeAt to re-pause at a specific index (e.g. after a zoom). constructor(resumeAt = null) { this.nextBreakPoint = resumeAt ?? this.#findNextAfter(-1); this.currentIdx = -1; } // Called by executeOperatorList when execution reaches nextBreakPoint. breakIt(i, continueCallback) { this.currentIdx = i; this.#continueCallback = continueCallback; onStepped(i); } // Advance one instruction then pause again. stepNext() { if (!this.#continueCallback) { return; } this.nextBreakPoint = this.currentIdx + 1; const cb = this.#continueCallback; this.#continueCallback = null; cb(); } // Continue until the next breakpoint (or end). continueToBreakpoint() { if (!this.#continueCallback) { return; } this.nextBreakPoint = this.#findNextAfter(this.currentIdx); const cb = this.#continueCallback; this.#continueCallback = null; cb(); } #findNextAfter(idx) { let next = null; for (const bp of breakpoints) { if (bp > idx && (next === null || bp < next)) { next = bp; } } return next; } // Called by InternalRenderTask when the operator list grows (streaming). updateOperatorList() {} // Called by InternalRenderTask to initialise the stepper. init() {} // Called by InternalRenderTask after recording bboxes (pdfBug mode). setOperatorBBoxes() {} getNextBreakPoint() { return this.nextBreakPoint; } } // Install a StepperManager so InternalRenderTask (pdfBug mode) picks it up. // A new instance is set on each redraw; null means no stepping. globalThis.StepperManager = { get enabled() { return globalThis.StepperManager._active !== null; }, _active: null, create() { return globalThis.StepperManager._active; }, }; // Color properties whose value is rendered as a swatch. const COLOR_CTX_PROPS = new Set(["fillStyle", "strokeStyle", "shadowColor"]); function scrollToGfxStateSection(label) { document .querySelector(`#gfx-state-panel [data-ctx-label="${CSS.escape(label)}"]`) ?.scrollIntoView({ block: "nearest" }); } // Navigate the save/restore stack view for a context. // delta = -1 → older (prev) frame; +1 → newer (next) frame. function navigateGfxStateStack(label, delta) { const stack = ctxStateStacks.get(label) ?? []; const viewIndex = ctxStackViewIdx.get(label); let newViewIndex; if (delta < 0) { newViewIndex = viewIndex === null ? stack.length - 1 : viewIndex - 1; if (newViewIndex < 0) { return; } } else { if (viewIndex === null) { return; } newViewIndex = viewIndex >= stack.length - 1 ? null : viewIndex + 1; } ctxStackViewIdx.set(label, newViewIndex); showGfxState(label); updateGfxStateStackNav(label); } function buildGfxStatePanel() { const panel = document.getElementById("gfx-state-panel"); const resizer = document.getElementById("op-gfx-state-resizer"); panel.hidden = false; resizer.hidden = false; panel.replaceChildren(); gfxStateValueElements.clear(); gfxStateNavElements.clear(); for (const [ctxLabel, state] of ctxStates) { const propEls = new Map(); gfxStateValueElements.set(ctxLabel, propEls); const section = document.createElement("div"); section.className = "gfx-state-section"; section.dataset.ctxLabel = ctxLabel; // Title row with label and stack-navigation arrows. const title = document.createElement("div"); title.className = "gfx-state-title"; const titleLabel = document.createElement("span"); titleLabel.textContent = ctxLabel; const navContainer = document.createElement("span"); navContainer.className = "gfx-state-stack-nav"; navContainer.hidden = true; const prevBtn = document.createElement("button"); prevBtn.className = "gfx-state-stack-btn"; prevBtn.setAttribute("aria-label", "View older saved state"); prevBtn.textContent = "←"; const pos = document.createElement("span"); pos.className = "gfx-state-stack-pos"; const nextBtn = document.createElement("button"); nextBtn.className = "gfx-state-stack-btn"; nextBtn.setAttribute("aria-label", "View newer saved state"); nextBtn.textContent = "→"; navContainer.append(prevBtn, pos, nextBtn); title.append(titleLabel, navContainer); section.append(title); gfxStateNavElements.set(ctxLabel, { container: navContainer, prevBtn, pos, nextBtn, }); prevBtn.addEventListener("click", () => navigateGfxStateStack(ctxLabel, -1) ); nextBtn.addEventListener("click", () => navigateGfxStateStack(ctxLabel, +1) ); for (const [prop, value] of state) { const row = document.createElement("div"); row.className = "gfx-state-row"; const key = document.createElement("span"); key.className = "gfx-state-key"; key.textContent = prop; row.append(key); if (prop === "transform") { const { math, mnEls } = buildTransformMathML(value); row.append(math); propEls.set(prop, { valEl: math, swatchEl: null, mnEls }); } else { const val = document.createElement("span"); val.className = "gfx-state-val"; const text = formatCtxValue(value); val.textContent = text; val.title = text; let swatchEl = null; if (COLOR_CTX_PROPS.has(prop)) { swatchEl = document.createElement("span"); swatchEl.className = "color-swatch"; swatchEl.style.background = String(value); row.append(swatchEl); } row.append(val); propEls.set(prop, { valEl: val, swatchEl }); } section.append(row); } panel.append(section); // Apply the correct state for the current view index (may be a saved // frame). showGfxState(ctxLabel); updateGfxStateStackNav(ctxLabel); } } function onStepped(i) { // Remove previous paused highlight. if (pausedAtIdx !== null) { opLines[pausedAtIdx]?.classList.remove("paused"); } pausedAtIdx = i; opLines[i]?.classList.add("paused"); opLines[i]?.scrollIntoView({ block: "nearest" }); stepBtn.disabled = false; continueBtn.disabled = false; buildGfxStatePanel(); } function clearPausedState() { if (pausedAtIdx !== null) { opLines[pausedAtIdx]?.classList.remove("paused"); pausedAtIdx = null; } globalThis.StepperManager._active = null; stepBtn.disabled = true; continueBtn.disabled = true; document.getElementById("gfx-state-panel").hidden = true; document.getElementById("op-gfx-state-resizer").hidden = true; } // Count of in-flight getRawData calls; drives the body "loading" cursor. let loadingCount = 0; function markLoading(delta) { loadingCount += delta; document.body.classList.toggle("loading", loadingCount > 0); } function resetRenderView() { debugViewGeneration++; currentRenderTask?.cancel(); currentRenderTask = null; renderedPage?.cleanup(); renderedPage = null; renderScale = null; currentOpList = null; originalColors.clear(); breakpoints.clear(); opLines = []; selectedOpLine = null; clearPausedState(); // If a toolbar wrapper was added in showRenderView, unwrap it. // #op-list-panel is inside opListBody inside the wrapper; replaceWith // extracts it, discarding the toolbar and line-number column automatically. const opListWrapper = document.querySelector(".op-list-panel-wrapper"); if (opListWrapper) { const opListPanelEl = document.getElementById("op-list-panel"); opListPanelEl.style.flex = ""; opListWrapper.replaceWith(opListPanelEl); } document.getElementById("op-list").replaceChildren(); document.getElementById("op-detail-panel").replaceChildren(); document.getElementById("gfx-state-panel").replaceChildren(); ctxStates.clear(); ctxStateStacks.clear(); ctxStackViewIdx.clear(); gfxStateValueElements.clear(); gfxStateNavElements.clear(); pdfDoc?.canvasFactory.clear(); const mainCanvas = document.getElementById("render-canvas"); mainCanvas.width = mainCanvas.height = 0; const highlightCanvas = document.getElementById("highlight-canvas"); highlightCanvas.width = highlightCanvas.height = 0; document.getElementById("zoom-level").textContent = ""; document.getElementById("zoom-out-btn").disabled = false; document.getElementById("zoom-in-btn").disabled = false; document.getElementById("redraw-btn").disabled = true; } async function loadTree(data, rootLabel = null) { currentPage = typeof data.page === "number" ? data.page : null; document.getElementById("debug-btn").hidden = currentPage === null; document.getElementById("debug-back-btn").hidden = true; resetRenderView(); document.getElementById("debug-view").hidden = true; document.getElementById("tree").hidden = false; const treeEl = document.getElementById("tree"); treeEl.classList.add("loading"); markLoading(1); try { const rootNode = renderNode( rootLabel, await pdfDoc.getRawData(data), pdfDoc ); treeEl.replaceChildren(rootNode); rootNode.querySelector("[role='button']").click(); const firstTreeItem = treeEl.querySelector("[role='treeitem']"); if (firstTreeItem) { firstTreeItem.tabIndex = 0; } } finally { treeEl.classList.remove("loading"); markLoading(-1); } } async function openDocument(source, name) { const statusEl = document.getElementById("status"); const pdfInfoEl = document.getElementById("pdf-info"); const gotoInput = document.getElementById("goto-input"); statusEl.textContent = `Loading ${name}…`; pdfInfoEl.textContent = ""; refCache.clear(); if (pdfDoc) { resetRenderView(); await pdfDoc.destroy(); pdfDoc = null; } const loadingTask = getDocument({ ...source, cMapUrl: typeof PDFJSDev === "undefined" ? "../external/bcmaps/" : "../web/cmaps/", iccUrl: typeof PDFJSDev === "undefined" ? "../external/iccs/" : "../web/iccs/", standardFontDataUrl: typeof PDFJSDev === "undefined" ? "../external/standard_fonts/" : "../web/standard_fonts/", wasmUrl: "../web/wasm/", useWorkerFetch: true, pdfBug: true, CanvasFactory: DebugCanvasFactory, }); loadingTask.onPassword = (updateCallback, reason) => { const dialog = document.getElementById("password-dialog"); const title = document.getElementById("password-dialog-title"); const input = document.getElementById("password-input"); const cancelBtn = document.getElementById("password-cancel"); title.textContent = reason === PasswordResponses.INCORRECT_PASSWORD ? "Incorrect password. Please try again:" : "This PDF is password-protected. Please enter the password:"; input.value = ""; dialog.showModal(); const onSubmit = () => { cleanup(); updateCallback(input.value); }; const onCancel = () => { cleanup(); dialog.close(); updateCallback(new Error("Password prompt cancelled.")); }; const cleanup = () => { dialog.removeEventListener("close", onSubmit); cancelBtn.removeEventListener("click", onCancel); }; dialog.addEventListener("close", onSubmit, { once: true }); cancelBtn.addEventListener("click", onCancel, { once: true }); }; pdfDoc = await loadingTask.promise; const plural = pdfDoc.numPages !== 1 ? "s" : ""; pdfInfoEl.textContent = `${name} — ${pdfDoc.numPages} page${plural}`; statusEl.textContent = ""; gotoInput.disabled = false; gotoInput.value = ""; } function showError(err) { document.getElementById("status").textContent = "Error: " + err.message; document.getElementById("tree").append(makeErrorEl(err.message)); } // Creates a role=alert div with "Error: " text. function makeErrorEl(message) { const el = document.createElement("div"); el.setAttribute("role", "alert"); el.textContent = `Error: ${message}`; return el; } document.getElementById("file-input").value = ""; document.getElementById("tree").addEventListener("keydown", e => { const treeEl = document.getElementById("tree"); // Collect all visible treeitems: those not inside a [hidden] group ancestor. const allItems = Array.from(treeEl.querySelectorAll("[role='treeitem']")); const visibleItems = allItems.filter(item => { let el = item.parentElement; while (el && el !== treeEl) { if (el.getAttribute("role") === "group" && el.hidden) { return false; } el = el.parentElement; } return true; }); const focused = document.activeElement; const idx = visibleItems.indexOf(focused); if (e.key === "ArrowDown") { e.preventDefault(); const next = visibleItems[idx + 1]; if (next) { focused.tabIndex = -1; next.tabIndex = 0; next.focus(); } } else if (e.key === "ArrowUp") { e.preventDefault(); const prev = visibleItems[idx - 1]; if (prev) { focused.tabIndex = -1; prev.tabIndex = 0; prev.focus(); } } else if (e.key === "ArrowRight") { e.preventDefault(); if (!focused || idx < 0) { return; } // Find the toggle button inside this treeitem (not inside a child group). const toggle = focused.querySelector(":scope > [role='button']"); if (!toggle) { return; } const expanded = toggle.getAttribute("aria-expanded"); if (expanded === "false") { toggle.click(); } else { // Already expanded — move to first child treeitem. const group = focused.querySelector( ":scope > [role='group']:not(.hidden)" ); const firstChild = group?.querySelector("[role='treeitem']"); if (firstChild) { focused.tabIndex = -1; firstChild.tabIndex = 0; firstChild.focus(); } } } else if (e.key === "ArrowLeft") { e.preventDefault(); if (!focused || idx < 0) { return; } const toggle = focused.querySelector(":scope > [role='button']"); const expanded = toggle?.getAttribute("aria-expanded"); if (expanded === "true") { toggle.click(); } else { // Collapsed or no children — move to parent treeitem. const parentGroup = focused.closest("[role='group']"); const parentItem = parentGroup?.closest("[role='treeitem']"); if (parentItem) { focused.tabIndex = -1; parentItem.tabIndex = 0; parentItem.focus(); } } } else if (e.key === "Home") { e.preventDefault(); const first = visibleItems[0]; if (first && first !== focused) { focused.tabIndex = -1; first.tabIndex = 0; first.focus(); } } else if (e.key === "End") { e.preventDefault(); const last = visibleItems.at(-1); if (last && last !== focused) { focused.tabIndex = -1; last.tabIndex = 0; last.focus(); } } }); document.getElementById("file-input").addEventListener("change", async e => { const file = e.target.files[0]; if (!file) { return; } try { await openDocument({ data: await file.arrayBuffer() }, file.name); await loadTree({ ref: null }, "Trailer"); } catch (err) { showError(err); } }); (async () => { const searchParams = new URLSearchParams(location.search); const hashParams = new URLSearchParams(location.hash.slice(1)); const fileUrl = searchParams.get("file"); if (!fileUrl) { return; } try { await openDocument({ url: fileUrl }, fileUrl.split("/").pop()); const refStr = hashParams.get("ref"); const pageStr = hashParams.get("page"); if (refStr) { const ref = parseRefInput(refStr); if (ref) { document.getElementById("goto-input").value = refStr; await loadTree({ ref }); return; } } if (pageStr) { const page = parseInt(pageStr); if (Number.isInteger(page) && page >= 1 && page <= pdfDoc.numPages) { document.getElementById("goto-input").value = pageStr; await loadTree({ page }); return; } } await loadTree({ ref: null }, "Trailer"); } catch (err) { showError(err); } })(); document.getElementById("goto-input").addEventListener("keydown", async e => { if (e.key !== "Enter" || !pdfDoc) { return; } const input = e.target; if (input.value.trim() === "") { input.setAttribute("aria-invalid", "false"); await loadTree({ ref: null }, "Trailer"); return; } const result = parseGoToInput(input.value); if (!result) { input.setAttribute("aria-invalid", "true"); return; } if ( result.page !== undefined && (result.page < 1 || result.page > pdfDoc.numPages) ) { input.setAttribute("aria-invalid", "true"); return; } input.setAttribute("aria-invalid", "false"); await (result.page !== undefined ? loadTree({ page: result.page }) : loadTree({ ref: result.ref })); }); document.getElementById("goto-input").addEventListener("input", e => { if (e.target.value.trim() === "") { e.target.setAttribute("aria-invalid", "false"); } }); document.getElementById("debug-btn").addEventListener("click", async () => { document.getElementById("debug-btn").hidden = true; document.getElementById("debug-back-btn").hidden = false; document.getElementById("tree").hidden = true; document.getElementById("debug-view").hidden = false; // Only render if not already loaded for this page; re-entering from the // back button keeps the existing debug state (op-list, canvas, breakpoints). if (currentOpList === null) { await showRenderView(currentPage); } }); document.getElementById("debug-back-btn").addEventListener("click", () => { document.getElementById("debug-back-btn").hidden = true; document.getElementById("debug-btn").hidden = false; document.getElementById("debug-view").hidden = true; document.getElementById("tree").hidden = false; }); /** * Attach a drag-to-resize handler to a resizer element. * @param {string} resizerId ID of the resizer element. * @param {string} firstId ID of the first panel (before the resizer). * @param {string} secondId ID of the second panel (after the resizer). * @param {"horizontal"|"vertical"} direction * @param {number} minSize Minimum size in px for each panel. * @param {Function} [onDone] Optional callback invoked after drag ends. */ function updateResizerAria(resizer, firstPanel, containerSize, resizerSize) { const dimension = resizer.getAttribute("aria-orientation") === "vertical" ? "width" : "height"; const total = containerSize - resizerSize; if (total <= 0) { return; } const firstSize = firstPanel.getBoundingClientRect()[dimension]; resizer.setAttribute( "aria-valuenow", String(Math.round((firstSize / containerSize) * 100)) ); } function makeResizer( resizerId, firstArg, secondArg, direction, minSize, onDone ) { const isHorizontal = direction === "horizontal"; const axis = isHorizontal ? "clientX" : "clientY"; const dimension = isHorizontal ? "width" : "height"; const cursor = isHorizontal ? "col-resize" : "row-resize"; const getFirst = typeof firstArg === "function" ? firstArg : () => document.getElementById(firstArg); const getSecond = typeof secondArg === "function" ? secondArg : () => document.getElementById(secondArg); const resizer = document.getElementById(resizerId); const minPct = Math.round( (minSize / Math.max( 1, resizer.parentElement.getBoundingClientRect()[dimension] - resizer.getBoundingClientRect()[dimension] )) * 100 ); resizer.setAttribute("aria-valuemin", String(minPct)); resizer.setAttribute("aria-valuemax", String(100 - minPct)); resizer.setAttribute("aria-valuenow", "50"); resizer.addEventListener("mousedown", e => { e.preventDefault(); const firstPanel = getFirst(); const secondPanel = getSecond(); const startPos = e[axis]; const containerSize = resizer.parentElement.getBoundingClientRect()[dimension]; const resizerSize = resizer.getBoundingClientRect()[dimension]; const total = containerSize - resizerSize; // After the first drag, panels have inline "N 1 0px" flex styles. Using // getBoundingClientRect() as the baseline is wrong here because sub-pixel // rendering makes the measured width slightly less than the grow value, // causing the panel to barely move for the first pixel(s) of the drag. // Parsing the grow value from the inline style gives the correct baseline. const inlineFirst = parseFloat(firstPanel.style.flex); const startFirst = isNaN(inlineFirst) ? firstPanel.getBoundingClientRect()[dimension] : inlineFirst; resizer.classList.add("dragging"); document.body.style.cursor = cursor; document.body.style.userSelect = "none"; const onMouseMove = ev => { const delta = ev[axis] - startPos; const newFirst = Math.max( minSize, Math.min(total - minSize, startFirst + delta) ); firstPanel.style.flex = `${newFirst} 1 0px`; secondPanel.style.flex = `${total - newFirst} 1 0px`; updateResizerAria(resizer, firstPanel, containerSize, resizerSize); }; const onMouseUp = () => { resizer.classList.remove("dragging"); document.body.style.cursor = ""; document.body.style.userSelect = ""; document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); // No flex re-assignment needed: onMouseMove already set grow ratios. // Re-measuring here would introduce a 1px jump due to sub-pixel // rounding (getBoundingClientRect returns integers while grow-ratio // flex-basis values are fractional). updateResizerAria( resizer, getFirst(), containerSize, resizer.getBoundingClientRect()[dimension] ); onDone?.(); }; document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }); resizer.addEventListener("keydown", e => { const firstPanel = getFirst(); const secondPanel = getSecond(); const containerSize = resizer.parentElement.getBoundingClientRect()[dimension]; const resizerSize = resizer.getBoundingClientRect()[dimension]; const total = containerSize - resizerSize; const step = e.shiftKey ? 50 : 10; let delta = 0; if (e.key === "ArrowUp" || e.key === "ArrowLeft") { delta = -step; } else if (e.key === "ArrowDown" || e.key === "ArrowRight") { delta = step; } else { return; } e.preventDefault(); const inlineCurrent = parseFloat(firstPanel.style.flex); const currentFirst = isNaN(inlineCurrent) ? firstPanel.getBoundingClientRect()[dimension] : inlineCurrent; const newFirst = Math.max( minSize, Math.min(total - minSize, currentFirst + delta) ); firstPanel.style.flex = `${newFirst} 1 0px`; secondPanel.style.flex = `${total - newFirst} 1 0px`; updateResizerAria(resizer, firstPanel, containerSize, resizerSize); onDone?.(); }); } // op-list-panel is wrapped in op-list-panel-wrapper after showRenderView(). // The wrapper is the actual flex sibling of gfx-state-panel in op-top-row, // so target it when present; fall back to op-list-panel otherwise. makeResizer( "op-gfx-state-resizer", () => document.querySelector(".op-list-panel-wrapper") ?? document.getElementById("op-list-panel"), "gfx-state-panel", "horizontal", 60 ); makeResizer("op-resizer", "op-top-row", "op-detail-panel", "vertical", 40); makeResizer( "render-resizer", "op-left-col", "canvas-panel", "horizontal", 100, renderCanvas ); function getFitScale() { const canvasScroll = document.getElementById("canvas-scroll"); return ( (canvasScroll.clientWidth - 24) / renderedPage.getViewport({ scale: 1 }).width ); } const MIN_ZOOM = 0.1; const MAX_ZOOM = 10; const ZOOM_STEP = 1.25; function zoomRenderCanvas(newScale) { // If zoomed again while a re-render is already running (not yet re-paused), // pausedAtIdx is null but the active stepper still knows the target index. const resumeAt = pausedAtIdx ?? globalThis.StepperManager._active?.nextBreakPoint ?? null; clearPausedState(); renderScale = newScale; if (resumeAt !== null) { globalThis.StepperManager._active = new ViewerStepper(resumeAt); } return renderCanvas(); } document .getElementById("zoom-in-btn") .addEventListener("click", () => zoomRenderCanvas( Math.min(MAX_ZOOM, (renderScale ?? getFitScale()) * ZOOM_STEP) ) ); document .getElementById("zoom-out-btn") .addEventListener("click", () => zoomRenderCanvas( Math.max(MIN_ZOOM, (renderScale ?? getFitScale()) / ZOOM_STEP) ) ); document.getElementById("redraw-btn").addEventListener("click", async () => { if (!renderedPage || !currentOpList) { return; } clearPausedState(); // Reset recorded bboxes so they get re-recorded for the modified op list. renderedPage.recordedBBoxes = null; if (breakpoints.size > 0) { globalThis.StepperManager._active = new ViewerStepper(); } await renderCanvas(); }); const stepBtn = document.getElementById("step-btn"); const continueBtn = document.getElementById("continue-btn"); const opDetailEl = document.getElementById("op-detail-panel"); stepBtn.addEventListener("click", () => { globalThis.StepperManager._active?.stepNext(); }); continueBtn.addEventListener("click", () => { globalThis.StepperManager._active?.continueToBreakpoint(); }); document.addEventListener("keydown", e => { if ( e.target.matches("input, textarea, [contenteditable]") || e.altKey || e.ctrlKey || e.metaKey ) { return; } const stepper = globalThis.StepperManager._active; if (!stepper) { return; } if (e.key === "s") { e.preventDefault(); stepper.stepNext(); } else if (e.key === "c") { e.preventDefault(); stepper.continueToBreakpoint(); } }); // Formats a glyph items array as: "text" kerning "more text" … function formatGlyphItems(items) { const parts = []; let str = ""; for (const item of items) { if (typeof item === "number") { if (str) { parts.push(JSON.stringify(str)); str = ""; } parts.push(String(Math.round(item * 100) / 100)); } else if (item?.unicode) { str += item.unicode; } } if (str) { parts.push(JSON.stringify(str)); } return parts.join(" "); } /** * Format an operator argument for display. * @param {*} arg The argument value. * @param {boolean} full true → expand fully (detail panel); * false → truncate for compact list display. */ function formatArg(arg, full) { if (arg === null || arg === undefined) { return full ? "null" : ""; } if (typeof arg === "number") { return Number.isInteger(arg) ? String(arg) : String(Math.round(arg * 10000) / 10000); } if (typeof arg === "string") { return JSON.stringify(arg); } if (typeof arg === "boolean") { return String(arg); } if (ArrayBuffer.isView(arg)) { if (!full && arg.length > 8) { return `<${arg.length} values>`; } const fmt = n => (Number.isInteger(n) ? n : Math.round(n * 1000) / 1000); return `[${Array.from(arg).map(fmt).join(" ")}]`; } if (Array.isArray(arg)) { if (arg.length === 0) { return "[]"; } if (!full && arg.length > 4) { return `[…${arg.length}]`; } return `[${arg.map(a => formatArg(a, full)).join(", ")}]`; } if (typeof arg === "object") { if (!full) { return "{…}"; } return `{${Object.entries(arg) .map(([k, v]) => `${k}: ${formatArg(v, true)}`) .join(", ")}}`; } return String(arg); } const formatOpArg = arg => formatArg(arg, false); const formatFullArg = arg => formatArg(arg, true); function showOpDetail(name, args, opIdx) { const detailEl = opDetailEl; detailEl.replaceChildren(); // Always build args into a .detail-args-col so it can be placed in a // .detail-body alongside a path preview or image preview on the right. const argsContainer = document.createElement("div"); argsContainer.className = "detail-args-col"; const header = document.createElement("div"); header.className = "detail-name"; header.textContent = name; argsContainer.append(header); if (!args || args.length === 0) { const none = document.createElement("div"); none.className = "detail-empty"; none.textContent = "(no arguments)"; argsContainer.append(none); detailEl.append(argsContainer); return; } const imagePreviews = []; for (let i = 0; i < args.length; i++) { const row = document.createElement("div"); row.className = "detail-row"; const idx = document.createElement("span"); idx.className = "detail-idx"; idx.textContent = `[${i}]`; const val = document.createElement("span"); val.className = "detail-val"; if (name === "showText" && i === 0 && Array.isArray(args[0])) { val.textContent = formatGlyphItems(args[0]); } else if ( name === "constructPath" && i === 0 && typeof args[0] === "number" ) { val.textContent = OPS_TO_NAME[args[0]] ?? String(args[0]); } else { val.textContent = formatFullArg(args[i]); } row.append(idx); if (typeof args[i] === "string" && /^#[0-9a-f]{6}$/i.test(args[i])) { const argIdx = i; const originalHex = originalColors.get(opIdx); if (originalHex && args[i] !== originalHex) { val.classList.add("changed-value"); val.title = `Original: ${originalHex}`; } row.append( makeColorSwatch(args[i], newHex => { args[argIdx] = newHex; val.textContent = JSON.stringify(newHex); const changed = originalHex && newHex !== originalHex; val.classList.toggle("changed-value", !!changed); val.title = changed ? `Original: ${originalHex}` : ""; // Also update the swatch and arg span in the selected op list line. const listSwatch = document.querySelector( "#op-list .op-line.selected .color-swatch" ); if (listSwatch) { listSwatch.style.background = newHex; } const listArgSpan = document.querySelector( "#op-list .op-line.selected .op-arg" ); if (listArgSpan) { listArgSpan.textContent = JSON.stringify(newHex); listArgSpan.classList.toggle("changed-value", !!changed); listArgSpan.title = changed ? `Original: ${originalHex}` : ""; } }) ); } row.append(val); argsContainer.append(row); if (typeof args[i] === "string" && args[i].startsWith("img_")) { const preview = makeImageArgPreview(args[i]); if (preview) { imagePreviews.push(preview); } } } // Assemble the final layout: constructPath gets a path preview on the right; // image ops get an image column on the right; others just use argsContainer. if (name === "constructPath") { // args[1] is [Float32Array|null], args[2] is [minX,minY,maxX,maxY]|null const data = Array.isArray(args?.[1]) ? args[1][0] : null; const body = document.createElement("div"); body.className = "detail-body"; body.append(argsContainer, renderPathPreview(data, args?.[2] ?? null)); detailEl.append(body); } else if (imagePreviews.length > 0) { const imgCol = document.createElement("div"); imgCol.className = "detail-img-col"; imgCol.append(...imagePreviews); const body = document.createElement("div"); body.className = "detail-body"; body.append(argsContainer, imgCol); detailEl.append(body); } else { detailEl.append(argsContainer); } } // Render an img_ argument value into a canvas preview using the decoded image // stored in renderedPage.objs (or commonObjs for global images starting with // g_). Handles ImageBitmap and raw pixel data with ImageKind values // GRAYSCALE_1BPP, RGB_24BPP, and RGBA_32BPP. function makeImageArgPreview(name) { const objStore = name.startsWith("g_") ? renderedPage?.commonObjs : renderedPage?.objs; if (!objStore?.has(name)) { return null; } const imgObj = objStore.get(name); if (!imgObj) { return null; } const { width, height } = imgObj; const canvas = document.createElement("canvas"); canvas.className = "image-preview"; canvas.width = width; canvas.height = height; canvas.style.aspectRatio = `${width} / ${height}`; canvas.setAttribute("aria-label", `${name} ${width}×${height}`); const ctx = canvas.getContext("2d"); // Fast path: if the browser already decoded it as an ImageBitmap, draw it. if (imgObj.bitmap instanceof ImageBitmap) { ctx.drawImage(imgObj.bitmap, 0, 0); return canvas; } // Slow path: convert raw pixel data to RGBA for putImageData. const { data, kind } = imgObj; let rgba; if (kind === 3 /* RGBA_32BPP */) { rgba = new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength); } else if (kind === 2 /* RGB_24BPP */) { const pixels = width * height; rgba = new Uint8ClampedArray(pixels * 4); for (let i = 0, j = 0; i < pixels; i++, j += 3) { rgba[i * 4] = data[j]; rgba[i * 4 + 1] = data[j + 1]; rgba[i * 4 + 2] = data[j + 2]; rgba[i * 4 + 3] = 255; } } else if (kind === 1 /* GRAYSCALE_1BPP */) { const rowBytes = (width + 7) >> 3; rgba = new Uint8ClampedArray(width * height * 4); for (let row = 0; row < height; row++) { const srcRow = row * rowBytes; const dstRow = row * width * 4; for (let col = 0; col < width; col++) { const bit = (data[srcRow + (col >> 3)] >> (7 - (col & 7))) & 1; const v = bit ? 255 : 0; rgba[dstRow + col * 4] = v; rgba[dstRow + col * 4 + 1] = v; rgba[dstRow + col * 4 + 2] = v; rgba[dstRow + col * 4 + 3] = 255; } } } else { return null; } ctx.putImageData(new ImageData(rgba, width, height), 0, 0); return canvas; } async function renderCanvas() { if (!renderedPage) { return null; } // Cancel any in-progress render before starting a new one. currentRenderTask?.cancel(); currentRenderTask = null; const highlight = document.getElementById("highlight-canvas"); const dpr = window.devicePixelRatio || 1; const scale = renderScale ?? getFitScale(); document.getElementById("zoom-level").textContent = `${Math.round(scale * 100)}%`; document.getElementById("zoom-out-btn").disabled = scale <= MIN_ZOOM; document.getElementById("zoom-in-btn").disabled = scale >= MAX_ZOOM; const viewport = renderedPage.getViewport({ scale: scale * dpr }); const cssW = `${viewport.width / dpr}px`; const cssH = `${viewport.height / dpr}px`; // Size the highlight canvas immediately so it stays in sync. highlight.width = viewport.width; highlight.height = viewport.height; highlight.style.width = cssW; highlight.style.height = cssH; // Render into a fresh canvas. When stepping, insert it into the DOM // immediately so the user sees each instruction drawn live. For normal // renders, swap only after completion so there's no blank flash. const newCanvas = document.createElement("canvas"); newCanvas.id = "render-canvas"; newCanvas.width = viewport.width; newCanvas.height = viewport.height; newCanvas.style.width = cssW; newCanvas.style.height = cssH; newCanvas.addEventListener("click", () => scrollToGfxStateSection("Page")); const isStepping = globalThis.StepperManager._active !== null; if (isStepping) { const oldCanvas = document.getElementById("render-canvas"); oldCanvas.width = oldCanvas.height = 0; oldCanvas.replaceWith(newCanvas); // Show any temporary canvases that survived from the previous render // (e.g. after a zoom-while-stepping, the factory may already have entries). pdfDoc?.canvasFactory.showAll(); } else { // Starting a fresh non-stepping render: remove leftover temp canvases. pdfDoc?.canvasFactory.clear(); } // Record bboxes only on the first render; they stay valid for subsequent // re-renders because BBoxReader returns normalised [0, 1] fractions. const firstRender = !renderedPage.recordedBBoxes; const renderTask = renderedPage.render({ canvasContext: wrapCanvasGetContext(newCanvas, "Page"), viewport, recordOperations: firstRender, }); currentRenderTask = renderTask; try { await renderTask.promise; } catch (err) { if (err?.name === "RenderingCancelledException") { return null; } throw err; } finally { if (currentRenderTask === renderTask) { currentRenderTask = null; } } // Render completed fully — stepping session is over. clearPausedState(); pdfDoc?.canvasFactory.clear(); document.getElementById("redraw-btn").disabled = false; if (!isStepping) { // Swap the completed canvas in, replacing the previous one. Zero out the // old canvas dimensions to release its GPU memory. const oldCanvas = document.getElementById("render-canvas"); oldCanvas.width = oldCanvas.height = 0; oldCanvas.replaceWith(newCanvas); } // Return the task on first render so the caller can extract the operator // list without a separate getOperatorList() call (dev/testing builds only). return firstRender ? renderTask : null; } function drawHighlight(opIdx) { const bboxes = renderedPage?.recordedBBoxes; if (!bboxes || opIdx >= bboxes.length || bboxes.isEmpty(opIdx)) { clearHighlight(); return; } const canvas = document.getElementById("render-canvas"); const highlight = document.getElementById("highlight-canvas"); const cssW = parseFloat(canvas.style.width); const cssH = parseFloat(canvas.style.height); const x = bboxes.minX(opIdx) * cssW; const y = bboxes.minY(opIdx) * cssH; const w = (bboxes.maxX(opIdx) - bboxes.minX(opIdx)) * cssW; const h = (bboxes.maxY(opIdx) - bboxes.minY(opIdx)) * cssH; const dpr = window.devicePixelRatio || 1; const ctx = highlight.getContext("2d"); ctx.clearRect(0, 0, highlight.width, highlight.height); ctx.save(); ctx.scale(dpr, dpr); ctx.fillStyle = "rgba(255, 165, 0, 0.3)"; ctx.strokeStyle = "rgba(255, 140, 0, 0.9)"; ctx.lineWidth = 1.5; ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); ctx.restore(); } function clearHighlight() { const highlight = document.getElementById("highlight-canvas"); highlight.getContext("2d").clearRect(0, 0, highlight.width, highlight.height); } function renderPathPreview(data, minMax) { const canvas = document.createElement("canvas"); canvas.className = "path-preview"; const [minX, minY, maxX, maxY] = minMax ?? []; const pathW = maxX - minX || 1; const pathH = maxY - minY || 1; if (!data || !minMax || !(pathW > 0) || !(pathH > 0)) { canvas.width = canvas.height = 1; return canvas; } const PADDING = 10; // px const dpr = window.devicePixelRatio || 1; const drawW = Math.min(200, 200 * (pathW / pathH)); const drawH = Math.min(200, 200 * (pathH / pathW)); const scale = Math.min(drawW / pathW, drawH / pathH); canvas.width = Math.round((drawW + PADDING * 2) * dpr); canvas.height = Math.round((drawH + PADDING * 2) * dpr); canvas.style.width = `${drawW + PADDING * 2}px`; canvas.style.height = `${drawH + PADDING * 2}px`; const ctx = canvas.getContext("2d"); ctx.scale(dpr, dpr); // PDF user space has Y pointing up; canvas has Y pointing down — flip Y. ctx.translate(PADDING, PADDING + drawH); ctx.scale(scale, -scale); ctx.translate(-minX, -minY); ctx.lineWidth = 1 / scale; ctx.strokeStyle = prefersDark.matches ? "#9cdcfe" : "#0070c1"; ctx.stroke(data instanceof Path2D ? data : makePathFromDrawOPS(data)); return canvas; } // The evaluator normalizes all color ops to setFillRGBColor / // setStrokeRGBColor with args = ["#rrggbb"]. Return that hex string, or null. function getOpColor(name, args) { if ( (name === "setFillRGBColor" || name === "setStrokeRGBColor") && typeof args?.[0] === "string" && /^#[0-9a-f]{6}$/i.test(args[0]) ) { return args[0]; } return null; } // Single hidden color input reused for all swatch pickers. const colorPickerInput = document.createElement("input"); colorPickerInput.type = "color"; colorPickerInput.style.cssText = "position:fixed;opacity:0;pointer-events:none;width:0;height:0;"; document.body.append(colorPickerInput); // Creates a color swatch. If `onPick` is provided the swatch is clickable and // opens the browser color picker; onPick(newHex) is called on each change. function makeColorSwatch(hex, onPick) { const swatch = document.createElement("span"); swatch.className = "color-swatch"; swatch.style.background = hex; if (onPick) { swatch.setAttribute("role", "button"); swatch.setAttribute("tabindex", "0"); swatch.setAttribute("aria-label", "Change color"); swatch.title = "Click to change color"; swatch.addEventListener("click", e => { e.stopPropagation(); colorPickerInput.value = hex; const ac = new AbortController(); colorPickerInput.addEventListener( "input", () => { hex = colorPickerInput.value; swatch.style.background = hex; onPick(hex); }, { signal: ac.signal } ); colorPickerInput.addEventListener("change", () => ac.abort(), { once: true, }); colorPickerInput.click(); }); } return swatch; } async function showRenderView(pageNum) { const generation = debugViewGeneration; const opListEl = document.getElementById("op-list"); const spinner = document.createElement("div"); spinner.setAttribute("role", "status"); spinner.textContent = "Loading…"; opListEl.replaceChildren(spinner); opDetailEl.replaceChildren(); renderScale = null; markLoading(1); try { renderedPage = await pdfDoc.getPage(pageNum); if (debugViewGeneration !== generation) { return; } // Render the page (records bboxes too). Reuse the operator list from the // render task when available (dev/testing builds); fall back to a separate // getOperatorList() call otherwise. const renderTask = await renderCanvas(); if (debugViewGeneration !== generation) { return; } currentOpList = renderTask?.getOperatorList?.() ?? (await renderedPage.getOperatorList()); if (debugViewGeneration !== generation) { return; } const opList = currentOpList; // Build operator list display. opLines = []; const opTexts = []; let opHighlightedIdx = -1; const opNumCol = document.createElement("div"); opNumCol.className = "cs-line-nums-col"; opNumCol.style.setProperty( "--line-num-width", `${String(opList.fnArray.length).length}ch` ); const opNumFrag = document.createDocumentFragment(); const fragment = document.createDocumentFragment(); for (let i = 0; i < opList.fnArray.length; i++) { const name = OPS_TO_NAME[opList.fnArray[i]] ?? `op${opList.fnArray[i]}`; const args = opList.argsArray[i] ?? []; const line = document.createElement("div"); line.className = "op-line"; line.setAttribute("role", "option"); line.setAttribute("aria-selected", "false"); line.tabIndex = i === 0 ? 0 : -1; opLines.push(line); const numItem = document.createElement("div"); numItem.className = "cs-num-item"; numItem.append(makeSpan("cs-line-num", String(i + 1))); opNumFrag.append(numItem); // Breakpoint gutter — click to toggle a red-bullet breakpoint. const gutter = document.createElement("span"); gutter.className = "bp-gutter"; gutter.setAttribute("role", "checkbox"); gutter.setAttribute("tabindex", "0"); gutter.setAttribute("aria-label", "Breakpoint"); const isInitiallyActive = breakpoints.has(i); gutter.setAttribute("aria-checked", String(isInitiallyActive)); if (isInitiallyActive) { gutter.classList.add("active"); } gutter.addEventListener("click", e => { e.stopPropagation(); if (breakpoints.has(i)) { breakpoints.delete(i); gutter.classList.remove("active"); gutter.setAttribute("aria-checked", "false"); } else { breakpoints.add(i); gutter.classList.add("active"); gutter.setAttribute("aria-checked", "true"); } }); gutter.addEventListener("keydown", e => { if (e.key === " " || e.key === "Enter") { e.preventDefault(); gutter.click(); } }); line.append(gutter); const nameEl = document.createElement("span"); nameEl.className = "op-name"; nameEl.textContent = name; line.append(nameEl); const rgb = getOpColor(name, args); let colorArgSpan = null; if (rgb) { originalColors.set(i, rgb); line.append( makeColorSwatch(rgb, newHex => { args[0] = newHex; if (colorArgSpan) { const changed = newHex !== rgb; colorArgSpan.textContent = JSON.stringify(newHex); colorArgSpan.classList.toggle("changed-value", changed); colorArgSpan.title = changed ? `Original: ${rgb}` : ""; } }) ); } if (name === "showText" && Array.isArray(args[0])) { const argEl = document.createElement("span"); argEl.className = "op-arg"; argEl.textContent = formatGlyphItems(args[0]); line.append(argEl); } else { for (let j = 0; j < args.length; j++) { const s = name === "constructPath" && j === 0 && typeof args[0] === "number" ? (OPS_TO_NAME[args[0]] ?? String(args[0])) : formatOpArg(args[j]); if (s) { const argEl = document.createElement("span"); argEl.className = "op-arg"; argEl.textContent = s; line.append(argEl); if (rgb && j === 0) { colorArgSpan = argEl; } } } } // Build plain-text representation for search. let opText = name; if (name === "showText" && Array.isArray(args[0])) { opText += " " + formatGlyphItems(args[0]); } else { for (let j = 0; j < args.length; j++) { const s = name === "constructPath" && j === 0 && typeof args[0] === "number" ? (OPS_TO_NAME[args[0]] ?? String(args[0])) : formatOpArg(args[j]); if (s) { opText += " " + s; } } } opTexts.push(opText); line.addEventListener("mouseenter", () => drawHighlight(i)); line.addEventListener("mouseleave", clearHighlight); line.addEventListener("click", () => { if (selectedOpLine) { selectedOpLine.classList.remove("selected"); selectedOpLine.setAttribute("aria-selected", "false"); selectedOpLine.tabIndex = -1; } selectedOpLine = line; line.classList.add("selected"); line.setAttribute("aria-selected", "true"); line.tabIndex = 0; showOpDetail(name, args, i); }); fragment.append(line); } if (debugViewGeneration === generation) { opNumCol.append(opNumFrag); opListEl.replaceChildren(fragment); opListEl.addEventListener("keydown", e => { const lines = opLines; if (!lines.length) { return; } const focused = document.activeElement; const currentIdx = lines.indexOf(focused); let targetIdx = -1; if (e.key === "ArrowDown") { targetIdx = currentIdx < lines.length - 1 ? currentIdx + 1 : currentIdx; } else if (e.key === "ArrowUp") { targetIdx = currentIdx > 0 ? currentIdx - 1 : 0; } else if (e.key === "Home") { targetIdx = 0; } else if (e.key === "End") { targetIdx = lines.length - 1; } else if (e.key === "Enter" || e.key === " ") { if (currentIdx >= 0) { lines[currentIdx].click(); e.preventDefault(); } return; } else { return; } e.preventDefault(); if (targetIdx >= 0) { lines[targetIdx].tabIndex = 0; if (currentIdx >= 0 && currentIdx !== targetIdx) { lines[currentIdx].tabIndex = -1; } lines[targetIdx].focus(); lines[targetIdx].scrollIntoView({ block: "nearest" }); } }); // Wrap #op-list-panel: toolbar above, then a row with the frozen // line-number column alongside the scrollable panel. const opListPanelEl = document.getElementById("op-list-panel"); opListPanelEl.addEventListener("scroll", () => { opNumCol.scrollTop = opListPanelEl.scrollTop; }); // Replace #op-list-panel in the DOM *before* moving it into opListBody, // otherwise replaceWith() would act on its new (detached) position. const opListWrapper = document.createElement("div"); opListWrapper.className = "op-list-panel-wrapper"; opListPanelEl.replaceWith(opListWrapper); const opListBody = document.createElement("div"); opListBody.className = "op-list-body"; opListBody.append(opNumCol, opListPanelEl); opListWrapper.append( makeSearchToolbar({ total: opList.fnArray.length, getText: i => opTexts[i], jumpToItem(i) { if (opHighlightedIdx >= 0) { opLines[opHighlightedIdx]?.classList.remove("cs-match"); opNumCol.children[opHighlightedIdx]?.classList.remove("cs-match"); } opHighlightedIdx = i; if (i < 0) { return; } opLines[i].classList.add("cs-match"); opNumCol.children[i]?.classList.add("cs-match"); opLines[i].scrollIntoView({ block: "nearest" }); }, }), opListBody ); } } catch (err) { opListEl.replaceChildren(makeErrorEl(err.message)); } finally { markLoading(-1); } } // PDF Name objects arrive as { name: "..." } after structured clone. function isPDFName(val) { return ( val !== null && typeof val === "object" && !Array.isArray(val) && typeof val.name === "string" && Object.keys(val).length === 1 ); } // Ref objects arrive as { num: N, gen: G } after structured clone. function isRefObject(val) { return ( val !== null && typeof val === "object" && !Array.isArray(val) && typeof val.num === "number" && typeof val.gen === "number" && Object.keys(val).length === 2 ); } function refLabel(ref) { return ref.gen !== 0 ? `${ref.num}R${ref.gen}` : `${ref.num}R`; } // Page content streams: // { contentStream: true, instructions, cmdNames, rawContents }. function isContentStream(val) { return ( val !== null && typeof val === "object" && val.contentStream === true && Array.isArray(val.instructions) && Array.isArray(val.rawContents) ); } // Streams: { dict, bytes }, { dict, imageData }, // or { dict, contentStream: true, instructions, cmdNames } (Form XObject). function isStream(val) { return ( val !== null && typeof val === "object" && !Array.isArray(val) && Object.prototype.hasOwnProperty.call(val, "dict") && (Object.prototype.hasOwnProperty.call(val, "bytes") || Object.prototype.hasOwnProperty.call(val, "imageData") || val.contentStream === true) ); } function isImageStream(val) { return ( isStream(val) && Object.prototype.hasOwnProperty.call(val, "imageData") ); } function isFormXObjectStream(val) { return isStream(val) && val.contentStream === true; } /** Create a bare div.node treeitem with an optional "key: " prefix. */ function makeNodeEl(key) { const node = document.createElement("div"); node.className = "node"; node.setAttribute("role", "treeitem"); node.tabIndex = -1; if (key !== null) { node.append(makeSpan("key", key), makeSpan("separator", ": ")); } return node; } /** * Render one key/value pair as a
. * @param {string|null} key Dict key, array index, or null for root. * @param {*} value * @param {PDFDocumentProxy} doc */ function renderNode(key, value, doc) { const node = makeNodeEl(key); node.append(renderValue(value, doc)); return node; } /** * Populate a container element with the direct children of a value. * Used both by renderValue (inside expandables) and renderRef (directly * into the ref's children container, avoiding an extra toggle level). */ function buildChildren(value, doc, container) { if (isStream(value)) { for (const [k, v] of Object.entries(value.dict)) { container.append(renderNode(k, v, doc)); } if (isImageStream(value)) { container.append(renderImageData(value.imageData)); } else if (isFormXObjectStream(value)) { const contentNode = makeNodeEl("content"); const csLabel = `[Content Stream, ${value.instructions.length} instructions]`; const csLabelEl = makeSpan("stream-label", csLabel); contentNode.append( makeExpandable(csLabelEl, csLabel, c => buildContentStreamPanel(value, c, csLabelEl) ) ); container.append(contentNode); } else { const byteNode = makeNodeEl("bytes"); byteNode.append( makeSpan("stream-label", `<${value.bytes.length} raw bytes>`) ); container.append(byteNode); const bytesContentEl = document.createElement("div"); bytesContentEl.className = "bytes-content"; bytesContentEl.append(formatBytes(value.bytes)); container.append(bytesContentEl); } } else if (Array.isArray(value)) { value.forEach((v, i) => container.append(renderNode(String(i), v, doc))); } else if (value !== null && typeof value === "object") { for (const [k, v] of Object.entries(value)) { container.append(renderNode(k, v, doc)); } } else { container.append(renderNode(null, value, doc)); } } /** * Render a single content-stream token as a styled span. */ function renderToken(token) { if (!token) { return makeSpan("token-null", "null"); } switch (token.type) { case "cmd": return makeSpan("token-cmd", token.value); case "name": return makeSpan("token-name", "/" + token.value); case "ref": return makeSpan("token-ref", `${token.num} ${token.gen} R`); case "number": return makeSpan("token-num", String(token.value)); case "string": return makeSpan("token-str", JSON.stringify(token.value)); case "boolean": return makeSpan("token-bool", String(token.value)); case "null": return makeSpan("token-null", "null"); case "array": { const span = document.createElement("span"); span.className = "token-array"; span.append(makeSpan("bracket", "[")); for (const item of token.value) { span.append(document.createTextNode(" ")); span.append(renderToken(item)); } span.append(document.createTextNode(" ")); span.append(makeSpan("bracket", "]")); return span; } case "dict": { const span = document.createElement("span"); span.className = "token-dict"; span.append(makeSpan("bracket", "<<")); for (const [k, v] of Object.entries(token.value)) { span.append(document.createTextNode(" ")); span.append(makeSpan("token-name", "/" + k)); span.append(document.createTextNode(" ")); span.append(renderToken(v)); } span.append(document.createTextNode(" ")); span.append(makeSpan("bracket", ">>")); return span; } default: return makeSpan("token-unknown", String(token.value ?? token.type)); } } /** * Return the plain-text representation of a token (mirrors renderToken). * Used to build searchable strings for every instruction. */ function tokenToText(token) { if (!token) { return "null"; } switch (token.type) { case "cmd": return token.value; case "name": return "/" + token.value; case "ref": return `${token.num} ${token.gen} R`; case "number": return String(token.value); case "string": return JSON.stringify(token.value); case "boolean": return String(token.value); case "null": return "null"; case "array": return `[ ${token.value.map(tokenToText).join(" ")} ]`; case "dict": { const inner = Object.entries(token.value) .map(([k, v]) => `/${k} ${tokenToText(v)}`) .join(" "); return `<< ${inner} >>`; } default: return String(token.value ?? token.type); } } /** * Populate container with one .content-stm-instruction div per instruction. * Shared by Page content streams and Form XObject streams. */ const INSTRUCTION_BATCH_SIZE = 500; // Maximum instructions kept in the DOM at once (two batches). const MAX_RENDERED_INSTRUCTIONS = INSTRUCTION_BATCH_SIZE * 2; /** * Build and return a sticky search/goto toolbar div. * * @param {object} opts * @param {number} opts.total Total number of items. * @param {function} opts.getText getText(i) → plain-text string for item i. * @param {function} opts.jumpToItem jumpToItem(i) highlights item i and scrolls * to it; jumpToItem(-1) clears the highlight. * @returns {HTMLElement} The toolbar element (class "cs-goto-bar"). */ let _searchToolbarCounter = 0; function makeSearchToolbar({ total, getText, jumpToItem, actions = null }) { const toolbarId = ++_searchToolbarCounter; const gotoBar = document.createElement("div"); gotoBar.className = "cs-goto-bar"; // Search group (left side) const searchGroup = document.createElement("div"); searchGroup.className = "cs-search-group"; const searchErrorId = `search-error-${toolbarId}`; const searchInput = document.createElement("input"); searchInput.type = "search"; searchInput.className = "cs-search-input"; searchInput.placeholder = "Search for\u2026"; searchInput.setAttribute("aria-label", "Search instructions"); searchInput.setAttribute("aria-describedby", searchErrorId); const searchError = document.createElement("span"); searchError.id = searchErrorId; searchError.className = "sr-only"; searchError.setAttribute("role", "alert"); const prevBtn = document.createElement("button"); prevBtn.className = "cs-nav-btn"; prevBtn.textContent = "↑"; prevBtn.setAttribute("aria-label", "Previous match"); prevBtn.disabled = true; const nextBtn = document.createElement("button"); nextBtn.className = "cs-nav-btn"; nextBtn.textContent = "↓"; nextBtn.setAttribute("aria-label", "Next match"); nextBtn.disabled = true; const matchInfo = document.createElement("span"); matchInfo.className = "cs-match-info"; function makeCheckboxLabel(text) { const label = document.createElement("label"); label.className = "cs-check-label"; const cb = document.createElement("input"); cb.type = "checkbox"; label.append(cb, ` ${text}`); return { label, cb }; } const { label: ignoreCaseLabel, cb: ignoreCaseCb } = makeCheckboxLabel("Ignore case"); const { label: regexLabel, cb: regexCb } = makeCheckboxLabel("Regex"); searchGroup.append( searchInput, searchError, prevBtn, nextBtn, matchInfo, ignoreCaseLabel, regexLabel ); // Go-to-line input (right side) const gotoInput = document.createElement("input"); gotoInput.type = "text"; gotoInput.className = "cs-goto"; gotoInput.placeholder = "Go to line\u2026"; gotoInput.setAttribute("aria-label", "Go to line"); if (actions) { gotoBar.append(actions); } gotoBar.append(searchGroup, gotoInput); let searchMatches = []; let currentMatchIdx = -1; function updateMatchInfo() { if (!searchInput.value) { matchInfo.textContent = ""; prevBtn.disabled = true; nextBtn.disabled = true; } else if (searchMatches.length === 0) { matchInfo.textContent = "No results"; prevBtn.disabled = true; nextBtn.disabled = true; } else { matchInfo.textContent = `${currentMatchIdx + 1} / ${searchMatches.length}`; prevBtn.disabled = false; nextBtn.disabled = false; } } function computeMatches() { jumpToItem(-1); searchMatches = []; currentMatchIdx = -1; const query = searchInput.value; if (!query) { updateMatchInfo(); return false; } let test; if (regexCb.checked) { try { const re = new RegExp(query, ignoreCaseCb.checked ? "i" : ""); test = str => re.test(str); searchInput.removeAttribute("aria-invalid"); searchError.textContent = ""; } catch { searchInput.setAttribute("aria-invalid", "true"); searchError.textContent = "Invalid regular expression"; updateMatchInfo(); return false; } } else { const needle = ignoreCaseCb.checked ? query.toLowerCase() : query; test = str => (ignoreCaseCb.checked ? str.toLowerCase() : str).includes(needle); } searchInput.removeAttribute("aria-invalid"); searchError.textContent = ""; for (let i = 0; i < total; i++) { if (test(getText(i))) { searchMatches.push(i); } } return searchMatches.length > 0; } function navigateMatch(delta) { if (!searchMatches.length) { return; } currentMatchIdx = (currentMatchIdx + delta + searchMatches.length) % searchMatches.length; jumpToItem(searchMatches[currentMatchIdx]); updateMatchInfo(); } function runSearch() { if (computeMatches() && searchMatches.length) { currentMatchIdx = 0; jumpToItem(searchMatches[0]); } updateMatchInfo(); } searchInput.addEventListener("input", runSearch); searchInput.addEventListener("keydown", e => { if (e.key === "Enter") { navigateMatch(e.shiftKey ? -1 : 1); } }); prevBtn.addEventListener("click", () => navigateMatch(-1)); nextBtn.addEventListener("click", () => navigateMatch(1)); ignoreCaseCb.addEventListener("change", runSearch); regexCb.addEventListener("change", runSearch); gotoInput.addEventListener("keydown", e => { if (e.key !== "Enter") { return; } const n = parseInt(gotoInput.value, 10); if (Number.isNaN(n) || n < 1 || n > total) { gotoInput.setAttribute("aria-invalid", "true"); return; } gotoInput.removeAttribute("aria-invalid"); jumpToItem(n - 1); }); return gotoBar; } /** * Build a scrollable panel with a frozen line-number column and a * search/goto toolbar, backed by an IntersectionObserver virtual scroll * that keeps at most MAX_RENDERED_INSTRUCTIONS rows in the DOM at once. * * @param {object} opts * @param {number} opts.total Total number of rows. * @param {string} opts.preClass className(s) for the content
. * @param {Function} opts.getText (i) => plain-text string for search. * @param {Function} opts.makeItemEl (i, isHighlighted) => HTMLElement. * @param {HTMLElement} opts.container Target element; panel is appended here. */ function buildVirtualScrollPanel({ total, preClass, getText, makeItemEl, container, actions = null, }) { if (total === 0) { return; } const scrollEl = document.createElement("div"); scrollEl.className = "content-stm-scroll"; // Left panel: line-number column. Lives outside the scroll container so it // is unaffected by horizontal scroll. Its scrollTop is synced via JS. const numCol = document.createElement("div"); numCol.className = "cs-line-nums-col"; numCol.style.setProperty("--line-num-width", `${String(total).length}ch`); // Right panel: the actual scroll container. const innerEl = document.createElement("div"); innerEl.className = "content-stm-inner"; innerEl.addEventListener("scroll", () => { numCol.scrollTop = innerEl.scrollTop; }); // Right panel content: item rows. const pre = document.createElement("div"); pre.className = preClass; innerEl.append(pre); // Body row: frozen num column + scrollable content side by side. const body = document.createElement("div"); body.className = "content-stm-body"; body.append(numCol, innerEl); // Sentinels bracket the rendered window inside pre: // topSentinel [startIndex .. endIndex) bottomSentinel const topSentinel = document.createElement("div"); topSentinel.className = "content-stm-load-sentinel"; const bottomSentinel = document.createElement("div"); bottomSentinel.className = "content-stm-load-sentinel"; let startIndex = 0; let endIndex = Math.min(INSTRUCTION_BATCH_SIZE, total); let highlightedIndex = -1; function makeNumEl(i) { const item = document.createElement("div"); item.className = "cs-num-item"; if (i === highlightedIndex) { item.classList.add("cs-match"); } item.append(makeSpan("cs-line-num", String(i + 1))); return item; } function renderRange(from, to) { const frag = document.createDocumentFragment(); for (let i = from; i < to; i++) { frag.append(makeItemEl(i, i === highlightedIndex)); } return frag; } function renderNumRange(from, to) { const frag = document.createDocumentFragment(); for (let i = from; i < to; i++) { frag.append(makeNumEl(i)); } return frag; } pre.append(topSentinel, renderRange(0, endIndex), bottomSentinel); numCol.append(renderNumRange(0, endIndex)); function jumpToTarget(targetIndex) { // Clear both the content window and the number column. let el = topSentinel.nextElementSibling; while (el && el !== bottomSentinel) { const next = el.nextElementSibling; el.remove(); el = next; } numCol.replaceChildren(); // Re-render a window centred around the target. const half = Math.floor(MAX_RENDERED_INSTRUCTIONS / 2); startIndex = Math.max(0, targetIndex - half); endIndex = Math.min(total, startIndex + MAX_RENDERED_INSTRUCTIONS); startIndex = Math.max(0, endIndex - MAX_RENDERED_INSTRUCTIONS); topSentinel.after(renderRange(startIndex, endIndex)); numCol.append(renderNumRange(startIndex, endIndex)); // Scroll to centre the target in innerEl. // pre.children: [0]=topSentinel, [1..n]=rows, [n+1]=bottomSentinel const targetEl = pre.children[targetIndex - startIndex + 1]; if (targetEl) { const targetRect = targetEl.getBoundingClientRect(); const innerRect = innerEl.getBoundingClientRect(); const available = innerEl.clientHeight; innerEl.scrollTop += targetRect.top - innerRect.top - available / 2 + targetEl.clientHeight / 2; } } function jumpToItem(i) { pre.querySelector(".cs-match")?.classList.remove("cs-match"); numCol.querySelector(".cs-match")?.classList.remove("cs-match"); if (i < 0) { highlightedIndex = -1; return; } highlightedIndex = i; jumpToTarget(i); pre.children[i - startIndex + 1]?.classList.add("cs-match"); numCol.children[i - startIndex]?.classList.add("cs-match"); } scrollEl.append( makeSearchToolbar({ total, getText, jumpToItem, actions }), body ); if (total <= INSTRUCTION_BATCH_SIZE) { container.append(scrollEl); return; } const observer = new IntersectionObserver( entries => { for (const entry of entries) { if (!entry.isIntersecting) { continue; } if (entry.target === bottomSentinel) { // Append next batch at bottom. const newEnd = Math.min(endIndex + INSTRUCTION_BATCH_SIZE, total); if (newEnd === endIndex) { continue; } bottomSentinel.before(renderRange(endIndex, newEnd)); numCol.append(renderNumRange(endIndex, newEnd)); endIndex = newEnd; // Trim oldest rows from top if window exceeds the max. if (endIndex - startIndex > MAX_RENDERED_INSTRUCTIONS) { const removeCount = endIndex - startIndex - MAX_RENDERED_INSTRUCTIONS; const heightBefore = pre.scrollHeight; for (let i = 0; i < removeCount; i++) { topSentinel.nextElementSibling?.remove(); numCol.firstElementChild?.remove(); } startIndex += removeCount; // Compensate so the visible content doesn't jump upward. innerEl.scrollTop -= heightBefore - pre.scrollHeight; } } else { // Prepend next batch at top. if (startIndex === 0) { continue; } const newStart = Math.max(0, startIndex - INSTRUCTION_BATCH_SIZE); const scrollBefore = innerEl.scrollTop; const heightBefore = pre.scrollHeight; topSentinel.after(renderRange(newStart, startIndex)); numCol.prepend(renderNumRange(newStart, startIndex)); // Compensate so the visible content doesn't jump downward. innerEl.scrollTop = scrollBefore + (pre.scrollHeight - heightBefore); startIndex = newStart; // Trim oldest rows from bottom if window exceeds the max. if (endIndex - startIndex > MAX_RENDERED_INSTRUCTIONS) { const removeCount = endIndex - startIndex - MAX_RENDERED_INSTRUCTIONS; for (let i = 0; i < removeCount; i++) { bottomSentinel.previousElementSibling?.remove(); numCol.lastElementChild?.remove(); } endIndex -= removeCount; } } } }, { root: innerEl, rootMargin: "200px" } ); observer.observe(topSentinel); observer.observe(bottomSentinel); container.append(scrollEl); } function makeInstrItemEl(isHighlighted) { const el = document.createElement("div"); el.className = "content-stm-instruction"; if (isHighlighted) { el.classList.add("cs-match"); } return el; } function buildInstructionLines(val, container, actions = null) { const { instructions, cmdNames } = val; const total = instructions.length; // Pre-compute indentation depth for every instruction so that any // slice [from, to) can be rendered without replaying from the start. const depths = new Int32Array(total); let d = 0; for (let i = 0; i < total; i++) { const cmd = instructions[i].cmd; if (cmd === "ET" || cmd === "Q" || cmd === "EMC") { d = Math.max(0, d - 1); } depths[i] = d; if (cmd === "BT" || cmd === "q" || cmd === "BDC") { d++; } } // Pre-compute a plain-text string per instruction for searching. const instrTexts = instructions.map(instr => { const parts = instr.args.map(tokenToText); if (instr.cmd !== null) { parts.push(instr.cmd); } return parts.join(" "); }); buildVirtualScrollPanel({ total, preClass: "content-stream", getText: i => instrTexts[i], actions, makeItemEl(i, isHighlighted) { const instr = instructions[i]; const line = makeInstrItemEl(isHighlighted); // Wrap the instruction content so that indentation shifts the tokens. const content = document.createElement("span"); if (depths[i] > 0) { content.style.paddingInlineStart = `${depths[i] * 1.5}em`; } for (const arg of instr.args) { content.append(renderToken(arg)); content.append(document.createTextNode(" ")); } if (instr.cmd !== null) { const cmdEl = makeSpan("token-cmd", instr.cmd); const opsName = cmdNames[instr.cmd]; if (opsName) { cmdEl.title = opsName; } content.append(cmdEl); } line.append(content); return line; }, container, }); } // Fills container with a raw-bytes virtual-scroll panel. function buildRawBytesPanel(rawBytes, container, actions = null) { const lines = rawBytes.split(/\r?\n|\r/); if (lines.at(-1) === "") { lines.pop(); } buildVirtualScrollPanel({ total: lines.length, preClass: "content-stream raw-bytes-stream", getText: i => lines[i], makeItemEl(i, isHighlighted) { const el = makeInstrItemEl(isHighlighted); el.append(formatBytes(lines[i])); return el; }, container, actions, }); } // Creates a "Parsed" toggle button. aria-pressed=true means the parsed view // is currently active; clicking switches to the other view. function makeParseToggleBtn(isParsed, onToggle) { const btn = document.createElement("button"); btn.className = "cs-nav-btn"; btn.textContent = "Parsed"; btn.setAttribute("aria-pressed", String(isParsed)); btn.title = isParsed ? "Show raw bytes" : "Show parsed instructions"; btn.addEventListener("click", onToggle); return btn; } // Fills container with the content stream panel (parsed or raw), with a // toggle button in the toolbar that swaps the view in-place. function buildContentStreamPanel(val, container, labelEl = null) { let isParsed = true; const rawBytes = val.rawBytes ?? val.bytes; const rawLines = rawBytes ? rawBytes.split(/\r?\n|\r/) : []; if (rawLines.at(-1) === "") { rawLines.pop(); } const parsedLabel = `[Content Stream, ${val.instructions.length} instructions]`; const rawLabel = `[Content Stream, ${rawLines.length} lines]`; function rebuild() { container.replaceChildren(); if (labelEl) { labelEl.textContent = isParsed ? parsedLabel : rawLabel; } const btn = makeParseToggleBtn(isParsed, () => { isParsed = !isParsed; rebuild(); }); if (isParsed) { buildInstructionLines(val, container, btn); } else { buildRawBytesPanel(rawBytes, container, btn); } } rebuild(); } /** * Render Page content stream as an expandable panel with a Parsed/Raw toggle. */ function renderContentStream(val) { const label = `[Content Stream, ${val.instructions.length} instructions]`; const labelEl = makeSpan("stream-label", label); return makeExpandable(labelEl, label, container => buildContentStreamPanel(val, container, labelEl) ); } /** * Render a value inline (primitive) or as an expandable widget. * Returns a Node or DocumentFragment suitable for appendChild(). */ function renderValue(value, doc) { // Ref string ("10 0 R") – lazy expandable via getRawData() if (typeof value === "string" && REF_RE.test(value)) { return renderRef(value, doc); } // Ref object { num, gen } – lazy expandable via getRawData() if (isRefObject(value)) { return renderRef(value, doc); } // PDF Name → /Name if (isPDFName(value)) { return makeSpan("name-value", "/" + value.name); } // Content stream (Page Contents) → expandable with Parsed/Raw toggle if (isContentStream(value)) { return renderContentStream(value, doc); } // Stream → expandable showing dict entries + byte count or image preview if (isStream(value)) { return renderExpandable("[Stream]", "stream-label", container => buildChildren(value, doc, container) ); } // Plain object (dict) if (value !== null && typeof value === "object" && !Array.isArray(value)) { const keys = Object.keys(value); if (keys.length === 0) { return makeSpan("bracket", "{}"); } return renderExpandable(`{${keys.length}}`, "bracket", container => buildChildren(value, doc, container) ); } // Array if (Array.isArray(value)) { if (value.length === 0) { return makeSpan("bracket", "[]"); } return renderExpandable(`[${value.length}]`, "bracket", container => buildChildren(value, doc, container) ); } // Primitives if (typeof value === "string") { return makeSpan("str-value", JSON.stringify(value)); } if (typeof value === "number") { return makeSpan("num-value", String(value)); } if (typeof value === "boolean") { return makeSpan("bool-value", String(value)); } return makeSpan("null-value", "null"); } /** * Build a lazy-loading expand/collapse widget for a ref (string or object). * Results are cached in `refCache` keyed by "num:gen". */ function renderRef(ref, doc) { // Derive the cache key and display label from whichever form we received. // String refs look like "10 0 R"; object refs are { num, gen }. let cacheKey, label; if (typeof ref === "string") { const parts = ref.split(" "); cacheKey = `${parts[0]}:${parts[1]}`; label = ref; } else { cacheKey = `${ref.num}:${ref.gen}`; label = refLabel(ref); } return makeExpandable( makeSpan("ref", label), `reference ${label}`, childrenEl => { const spinner = document.createElement("div"); spinner.setAttribute("role", "status"); spinner.textContent = "Loading…"; childrenEl.append(spinner); markLoading(1); if (!refCache.has(cacheKey)) { refCache.set(cacheKey, doc.getRawData({ ref })); } refCache .get(cacheKey) .then(result => { childrenEl.replaceChildren(); buildChildren(result, doc, childrenEl); }) .catch(err => childrenEl.replaceChildren(makeErrorEl(err.message))) .finally(() => markLoading(-1)); } ); } /** * Build a shared expand/collapse widget. * labelEl is the element shown between the toggle arrow and the children. * ariaLabel is used for the toggle and group aria-labels. * onFirstOpen(childrenEl) is called once when first expanded (may be async). */ function makeExpandable(labelEl, ariaLabel, onFirstOpen) { const toggleEl = document.createElement("span"); toggleEl.textContent = ARROW_COLLAPSED; toggleEl.setAttribute("role", "button"); toggleEl.setAttribute("tabindex", "0"); toggleEl.setAttribute("aria-expanded", "false"); toggleEl.setAttribute("aria-label", `Expand ${ariaLabel}`); labelEl.setAttribute("aria-hidden", "true"); const childrenEl = document.createElement("div"); childrenEl.className = "hidden"; childrenEl.setAttribute("role", "group"); childrenEl.setAttribute("aria-label", `Contents of ${ariaLabel}`); let open = false, done = false; const toggle = () => { open = !open; toggleEl.textContent = open ? ARROW_EXPANDED : ARROW_COLLAPSED; toggleEl.setAttribute("aria-expanded", String(open)); childrenEl.classList.toggle("hidden", !open); if (open && !done) { done = true; onFirstOpen(childrenEl); } }; toggleEl.addEventListener("click", toggle); toggleEl.addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } }); labelEl.addEventListener("click", toggle); const frag = document.createDocumentFragment(); frag.append(toggleEl, labelEl, childrenEl); return frag; } /** * Build a synchronous expand/collapse widget. * @param {string} label Text shown on the collapsed line. * @param {string} labelClass CSS class for the label. * @param {Function} buildFn Called with (containerEl) on first open. */ function renderExpandable(label, labelClass, buildFn) { return makeExpandable(makeSpan(labelClass, label), label, c => buildFn(c)); } /** * Render image data (RGBA Uint8ClampedArray) into a node. */ function renderImageData({ width, height, data }) { const node = document.createElement("div"); node.className = "node"; const keyEl = document.createElement("span"); keyEl.className = "key"; keyEl.textContent = "imageData"; const sep = document.createElement("span"); sep.className = "separator"; sep.textContent = ": "; const info = document.createElement("span"); info.className = "stream-label"; info.textContent = `<${width}×${height}>`; node.append(keyEl, sep, info); const canvas = document.createElement("canvas"); canvas.className = "image-preview"; canvas.width = width; canvas.height = height; const dpr = window.devicePixelRatio || 1; canvas.style.width = `${width / dpr}px`; canvas.style.aspectRatio = `${width} / ${height}`; canvas.setAttribute("aria-label", `Image preview ${width}×${height}`); const ctx = canvas.getContext("2d"); const imgData = new ImageData(new Uint8ClampedArray(data), width, height); ctx.putImageData(imgData, 0, 0); node.append(canvas); return node; } function isMostlyText(str) { let printable = 0; for (let i = 0; i < str.length; i++) { const c = str.charCodeAt(i); if (c >= 0x20 && c <= 0x7e) { printable++; } } return str.length > 0 && printable / str.length >= 0.8; } function formatBytes(str) { const mostlyText = isMostlyText(str); const frag = document.createDocumentFragment(); if (!mostlyText) { // Binary content: render every byte as hex in a single span. const span = document.createElement("span"); span.className = "bytes-hex"; const hexParts = []; for (let i = 0; i < str.length; i++) { hexParts.push( str.charCodeAt(i).toString(16).toUpperCase().padStart(2, "0") ); } span.textContent = hexParts.join("\u00B7\u200B"); frag.append(span); return frag; } // Text content: printable ASCII + 0x0A as-is, other bytes as hex spans. const isPrintable = c => (c >= 0x20 && c <= 0x7e) || c === 0x0a; let i = 0; while (i < str.length) { const code = str.charCodeAt(i); if (isPrintable(code)) { let run = ""; while (i < str.length && isPrintable(str.charCodeAt(i))) { run += str[i++]; } frag.append(document.createTextNode(run)); } else { const span = document.createElement("span"); span.className = "bytes-hex"; const hexParts = []; while (i < str.length && !isPrintable(str.charCodeAt(i))) { hexParts.push( str.charCodeAt(i).toString(16).toUpperCase().padStart(2, "0") ); i++; } span.textContent = hexParts.join("\u00B7\u200B"); frag.append(span); } } return frag; } /** Create a with the given class and text content. */ function makeSpan(className, text) { const span = document.createElement("span"); span.className = className; span.textContent = text; return span; }