pdf.js.mirror/web/internal/draw_ops_view.js
calixteman 7bac644731
Split the new debugger into multiple files
Instead of having all the code for the new debugger in a single file,
split it into multiple files.
This makes it easier to navigate and maintain the codebase.
It'll be make hacking and fixing bugs in the debugger easier.
2026-03-15 13:21:26 +01:00

660 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 { ImageKind, OPS } from "pdfjs-lib";
import { makePathFromDrawOPS } from "pdfjs/display/display_utils.js";
import { MultilineView } from "./multiline_view.js";
// 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;
}
// Single hidden color input reused for all swatch pickers.
const colorPickerInput = document.createElement("input");
colorPickerInput.type = "color";
colorPickerInput.className = "color-picker-input";
// AbortController for the currently open color-picker session (if any).
let _colorPickerAc = null;
function ensureColorPickerInput() {
if (!colorPickerInput.isConnected) {
document.body.append(colorPickerInput);
}
}
function openColorPicker(hex, onPick) {
// Cancel any previous session that was dismissed without a change event.
_colorPickerAc?.abort();
ensureColorPickerInput();
colorPickerInput.value = hex;
const ac = new AbortController();
_colorPickerAc = ac;
colorPickerInput.addEventListener(
"input",
() => {
onPick(colorPickerInput.value);
},
{ signal: ac.signal }
);
colorPickerInput.addEventListener(
"change",
() => {
ac.abort();
},
{ once: true, signal: ac.signal }
);
colorPickerInput.click();
}
// 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.role = "button";
swatch.tabIndex = 0;
swatch.ariaLabel = "Change color";
swatch.title = "Click to change color";
const activate = e => {
e.stopPropagation();
openColorPicker(hex, newHex => {
hex = newHex;
swatch.style.background = newHex;
onPick(newHex);
});
};
swatch.addEventListener("click", activate);
swatch.addEventListener("keydown", e => {
if (e.key !== "Enter" && e.key !== " ") {
return;
}
e.preventDefault();
activate(e);
});
}
return swatch;
}
// 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);
}
class DrawOpDetailView {
#el;
#prefersDark;
constructor(detailPanelEl, { prefersDark }) {
this.#el = detailPanelEl;
this.#prefersDark = prefersDark;
}
show(
name,
args,
opIdx,
{ originalColors, renderedPage, selectedLine = null }
) {
const detailEl = this.#el;
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 = formatArg(args[i], true);
}
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 = selectedLine?.querySelector(".color-swatch");
if (listSwatch) {
listSwatch.style.background = newHex;
}
const listArgSpan = selectedLine?.querySelector(".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 = this.#makeImageArgPreview(args[i], renderedPage);
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,
this.#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);
}
}
clear() {
this.#el.replaceChildren();
}
#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 = this.#prefersDark.matches ? "#9cdcfe" : "#0070c1";
ctx.stroke(data instanceof Path2D ? data : makePathFromDrawOPS(data));
return canvas;
}
// 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.
#makeImageArgPreview(name, renderedPage) {
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.ariaLabel = `${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 === ImageKind.RGBA_32BPP) {
rgba = new Uint8ClampedArray(
data.buffer,
data.byteOffset,
data.byteLength
);
} else if (kind === ImageKind.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 === ImageKind.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;
}
}
class DrawOpsView {
#listPanelEl;
#detailView;
#multilineView = null;
#opLines = [];
#selectedLine = null;
#breakpoints = new Set();
#originalColors = new Map();
#renderedPage = null;
#pausedAtIdx = null;
#onHighlight;
#onClearHighlight;
constructor(
opListPanelEl,
detailPanelEl,
{ onHighlight, onClearHighlight, prefersDark }
) {
this.#listPanelEl = opListPanelEl;
this.#detailView = new DrawOpDetailView(detailPanelEl, { prefersDark });
this.#onHighlight = onHighlight;
this.#onClearHighlight = onClearHighlight;
}
get breakpoints() {
return this.#breakpoints;
}
load(opList, renderedPage) {
this.#renderedPage = renderedPage;
this.#opLines = [];
const opTexts = [];
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, text } = this.#buildLine(i, name, args);
this.#opLines.push(line);
opTexts.push(text);
}
const multilineView = new MultilineView({
total: opList.fnArray.length,
getText: i => opTexts[i],
makeLineEl: (i, isHighlighted) => {
this.#opLines[i].classList.toggle("mlc-match", isHighlighted);
return this.#opLines[i];
},
});
multilineView.element.classList.add("op-list-panel-wrapper");
multilineView.inner.id = "op-list";
multilineView.inner.role = "listbox";
multilineView.inner.ariaLabel = "Operator list";
multilineView.inner.addEventListener("keydown", e => {
const { key } = e;
const lines = this.#opLines;
if (!lines.length) {
return;
}
const focused = document.activeElement;
const currentIdx = lines.indexOf(focused);
let targetIdx = -1;
if (key === "ArrowDown") {
targetIdx = currentIdx < lines.length - 1 ? currentIdx + 1 : currentIdx;
} else if (key === "ArrowUp") {
targetIdx = currentIdx > 0 ? currentIdx - 1 : 0;
} else if (key === "Home") {
targetIdx = 0;
} else if (key === "End") {
targetIdx = lines.length - 1;
} else if (key === "Enter" || 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;
}
multilineView.scrollToLine(targetIdx);
lines[targetIdx].focus();
}
});
this.#listPanelEl.replaceWith(multilineView.element);
this.#multilineView = multilineView;
}
clear() {
if (this.#multilineView) {
this.#multilineView.destroy();
this.#multilineView.element.replaceWith(this.#listPanelEl);
this.#multilineView = null;
}
document.getElementById("op-list").replaceChildren();
this.#detailView.clear();
this.#opLines = [];
this.#selectedLine = null;
this.#originalColors.clear();
this.#breakpoints.clear();
this.#pausedAtIdx = this.#renderedPage = null;
}
markPaused(i) {
if (this.#pausedAtIdx !== null) {
this.#opLines[this.#pausedAtIdx]?.classList.remove("paused");
}
this.#pausedAtIdx = i;
this.#opLines[i]?.classList.add("paused");
this.#multilineView?.scrollToLine(i);
}
clearPaused() {
if (this.#pausedAtIdx !== null) {
this.#opLines[this.#pausedAtIdx]?.classList.remove("paused");
this.#pausedAtIdx = null;
}
}
// The evaluator normalizes all color ops to setFillRGBColor /
// setStrokeRGBColor with args = ["#rrggbb"]. Return that hex string, or null.
#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;
}
#buildLine(i, name, args) {
const line = document.createElement("div");
line.className = "op-line";
line.role = "option";
line.ariaSelected = "false";
line.tabIndex = i === 0 ? 0 : -1;
// Breakpoint gutter — click to toggle a red-bullet breakpoint.
const gutter = document.createElement("span");
gutter.className = "bp-gutter";
gutter.role = "checkbox";
gutter.tabIndex = 0;
gutter.ariaLabel = "Breakpoint";
const isInitiallyActive = this.#breakpoints.has(i);
gutter.ariaChecked = String(isInitiallyActive);
if (isInitiallyActive) {
gutter.classList.add("active");
}
gutter.addEventListener("click", e => {
e.stopPropagation();
if (this.#breakpoints.has(i)) {
this.#breakpoints.delete(i);
gutter.classList.remove("active");
gutter.ariaChecked = "false";
} else {
this.#breakpoints.add(i);
gutter.classList.add("active");
gutter.ariaChecked = "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 = this.#getOpColor(name, args);
let colorArgSpan = null;
if (rgb) {
this.#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}` : "";
}
})
);
}
// Build arg spans and plain-text representation for search in one pass.
let text = name;
if (name === "showText" && Array.isArray(args[0])) {
const formatted = formatGlyphItems(args[0]);
const argEl = document.createElement("span");
argEl.className = "op-arg";
argEl.textContent = formatted;
line.append(argEl);
text += " " + formatted;
} 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]))
: formatArg(args[j], false);
if (s) {
const argEl = document.createElement("span");
argEl.className = "op-arg";
argEl.textContent = s;
line.append(argEl);
if (rgb && j === 0) {
colorArgSpan = argEl;
}
text += " " + s;
}
}
}
line.addEventListener("pointerenter", () => this.#onHighlight(i));
line.addEventListener("pointerleave", () => this.#onClearHighlight());
line.addEventListener("click", () => {
if (this.#selectedLine) {
this.#selectedLine.classList.remove("selected");
this.#selectedLine.ariaSelected = "false";
this.#selectedLine.tabIndex = -1;
}
this.#selectedLine = line;
line.classList.add("selected");
line.ariaSelected = "true";
line.tabIndex = 0;
this.#detailView.show(name, args, i, {
originalColors: this.#originalColors,
renderedPage: this.#renderedPage,
selectedLine: line,
});
});
return { line, text };
}
}
export { DrawOpsView };