This commit is contained in:
2026-06-08 13:26:57 +02:00
parent be60be5b03
commit 141762041b
2444 changed files with 1179392 additions and 15 deletions

View File

@@ -0,0 +1,764 @@
/* 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;
}
// Ops from the getTextContent() switch in evaluator.js — shown in the draw ops
// view when text-only filter is active, and used to determine step targets.
const TEXT_OP_IDS = new Set([
OPS.setFont,
OPS.setTextRise,
OPS.setHScale,
OPS.setLeading,
OPS.moveText,
OPS.setLeadingMoveText,
OPS.nextLine,
OPS.setTextMatrix,
OPS.setCharSpacing,
OPS.setWordSpacing,
OPS.beginText,
OPS.endText,
OPS.showSpacedText,
OPS.showText,
OPS.nextLineShowText,
OPS.nextLineSetSpacingShowText,
OPS.beginMarkedContent,
OPS.beginMarkedContentProps,
OPS.endMarkedContent,
]);
// Superset of TEXT_OP_IDS — all ops that must be executed (not skipped) during
// text-only rendering. The extra ops here are infrastructure (save/restore,
// transforms, XObject wrappers) that affect the graphics state but are not
// shown in the filtered op list.
const TEXT_EXEC_OP_IDS = new Set([
...TEXT_OP_IDS,
OPS.restore,
OPS.save,
OPS.dependency,
OPS.transform,
OPS.paintFormXObjectBegin,
OPS.paintFormXObjectEnd,
OPS.beginGroup,
OPS.endGroup,
OPS.setGState,
]);
const BreakpointType = {
PAUSE: 0,
SKIP: 1,
};
// 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;
// All op lines indexed by original op index.
#opLines = [];
// Plain-text representations for search, parallel to #opLines.
#opTexts = [];
// Currently visible lines (all lines, or text-only subset when filtering).
#visibleLines = [];
#textFilter = false;
#selectedLine = null;
// Map<opIndex, BreakpointType>
#breakpoints = new Map();
#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 = [];
this.#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);
this.#opTexts.push(text);
}
this.#rebuildMultilineView();
}
// Enable or disable the text-ops-only filter. Can be called at any time;
// rebuilds the list view in place when ops are already loaded.
setTextFilter(enabled) {
if (this.#textFilter === enabled) {
return;
}
this.#textFilter = enabled;
if (this.#opLines.length > 0) {
this.#rebuildMultilineView();
}
}
#rebuildMultilineView() {
// Compute the visible (possibly filtered) subset.
this.#visibleLines = this.#textFilter
? this.#opLines.filter(line => TEXT_OP_IDS.has(OPS[line.dataset.opName]))
: this.#opLines;
// Tear down the existing MultilineView (if any), keeping the placeholder.
const anchor = this.#multilineView?.element ?? this.#listPanelEl;
if (this.#multilineView) {
this.#multilineView.destroy();
this.#multilineView = null;
}
const multilineView = new MultilineView({
total: this.#visibleLines.length,
getText: i => this.#opTexts[+this.#visibleLines[i].dataset.opIdx],
makeLineEl: (i, isHighlighted) => {
this.#visibleLines[i].classList.toggle("mlc-match", isHighlighted);
return this.#visibleLines[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.#visibleLines;
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();
}
});
anchor.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.#opTexts = [];
this.#visibleLines = [];
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");
// Scroll to the position of this op within the currently visible list.
const visibleIdx = this.#visibleLines.indexOf(this.#opLines[i]);
if (visibleIdx >= 0) {
this.#multilineView?.scrollToLine(visibleIdx);
}
}
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;
line.dataset.opName = name;
line.dataset.opIdx = i;
// Breakpoint gutter — click cycles: none → pause (●) → skip (✕) → none.
const gutter = document.createElement("span");
gutter.className = "bp-gutter";
gutter.role = "checkbox";
gutter.tabIndex = 0;
gutter.ariaLabel = "Breakpoint";
const initBpType = this.#breakpoints.get(i);
if (initBpType === BreakpointType.PAUSE) {
gutter.dataset.bp = "pause";
gutter.ariaChecked = "true";
} else if (initBpType === BreakpointType.SKIP) {
gutter.dataset.bp = "skip";
gutter.ariaChecked = "mixed";
line.classList.add("op-skipped");
} else {
gutter.ariaChecked = "false";
}
gutter.addEventListener("click", e => {
e.stopPropagation();
const current = this.#breakpoints.get(i);
if (current === undefined) {
this.#breakpoints.set(i, BreakpointType.PAUSE);
gutter.dataset.bp = "pause";
gutter.ariaChecked = "true";
} else if (current === BreakpointType.PAUSE) {
this.#breakpoints.set(i, BreakpointType.SKIP);
gutter.dataset.bp = "skip";
gutter.ariaChecked = "mixed";
line.classList.add("op-skipped");
} else {
this.#breakpoints.delete(i);
delete gutter.dataset.bp;
gutter.ariaChecked = "false";
line.classList.remove("op-skipped");
}
});
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 { BreakpointType, DrawOpsView, TEXT_EXEC_OP_IDS, TEXT_OP_IDS };