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.
This commit is contained in:
calixteman 2026-03-14 22:47:19 +01:00
parent f4c6fff759
commit 7bac644731
No known key found for this signature in database
GPG Key ID: 0C5442631EE0691F
19 changed files with 4863 additions and 4155 deletions

View File

@ -46,7 +46,7 @@ directory `build/chromium`.
### PDF debugger
Browser the internal structure of a PDF document with https://mozilla.github.io/pdf.js/internal-viewer/web/pdf_internal_viewer.html
Browser the internal structure of a PDF document with https://mozilla.github.io/pdf.js/internal-viewer/web/debugger.html
## Getting the Code

View File

@ -2372,13 +2372,13 @@ gulp.task("check_l10n", function (done) {
function createInternalViewerBundle(defines) {
const viewerFileConfig = createWebpackConfig(defines, {
filename: "pdf_internal_viewer.mjs",
filename: "debugger.mjs",
library: {
type: "module",
},
});
return gulp
.src("./web/pdf_internal_viewer.js", { encoding: false })
.src("./web/internal/debugger.js", { encoding: false })
.pipe(webpack2Stream(viewerFileConfig));
}
@ -2389,10 +2389,10 @@ function buildInternalViewer(defines, dir) {
createMainBundle(defines).pipe(gulp.dest(dir + "build")),
createWorkerBundle(defines).pipe(gulp.dest(dir + "build")),
createInternalViewerBundle(defines).pipe(gulp.dest(dir + "web")),
preprocessHTML("web/pdf_internal_viewer.html", defines).pipe(
preprocessHTML("web/internal/debugger.html", defines).pipe(
gulp.dest(dir + "web")
),
preprocessCSS("web/pdf_internal_viewer.css", defines)
preprocessCSS("web/internal/debugger.css", defines)
.pipe(
postcss([
postcssDirPseudoClass(),

View File

@ -0,0 +1,85 @@
/* 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.
*/
.gfx-state-section {
padding-inline: 12px;
}
.gfx-state-section + .gfx-state-section {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-color);
}
.gfx-state-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
color: var(--accent-color);
font-weight: bold;
margin-bottom: 4px;
}
.gfx-state-stack-nav {
display: flex;
align-items: center;
gap: 2px;
font-weight: normal;
font-size: 0.8em;
}
.gfx-state-stack-button {
padding: 0 3px;
border: 1px solid currentcolor;
border-radius: 2px;
background: transparent;
color: inherit;
cursor: pointer;
line-height: 1.3;
&:disabled {
cursor: default;
opacity: 0.35;
}
}
.gfx-state-stack-pos {
min-width: 4ch;
text-align: center;
font-variant-numeric: tabular-nums;
}
.gfx-state-row {
display: flex;
align-items: center;
gap: 8px;
padding: 1px 0;
}
.gfx-state-key {
color: var(--muted-color);
flex-shrink: 0;
min-width: 20ch;
}
.gfx-state-val {
color: var(--number-color);
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -0,0 +1,533 @@
/* 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.
*/
// 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",
]);
// Methods that modify the current transform matrix.
const TRANSFORM_METHODS = new Set([
"resetTransform",
"rotate",
"scale",
"setTransform",
"transform",
"translate",
]);
// 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 };
},
],
]);
// Color properties whose value is rendered as a swatch.
const COLOR_CTX_PROPS = new Set(["fillStyle", "shadowColor", "strokeStyle"]);
const MATHML_NS = "http://www.w3.org/1998/Math/MathML";
// Cached media queries used by drawCheckerboard.
const _prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
const _prefersHCM = window.matchMedia("(forced-colors: active)");
/**
* 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 =
typeof OffscreenCanvas !== "undefined"
? new OffscreenCanvas(boxDim * 2, boxDim * 2)
: Object.assign(document.createElement("canvas"), {
width: boxDim * 2,
height: boxDim * 2,
});
const patternCtx = pattern.getContext("2d");
if (!patternCtx) {
return;
}
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();
const fillPattern = ctx.createPattern(pattern, "repeat");
if (!fillPattern) {
ctx.restore();
return;
}
ctx.fillStyle = fillPattern;
ctx.fillRect(0, 0, width, height);
ctx.restore();
}
/**
* Tracks and displays the CanvasRenderingContext2D graphics state for all
* contexts created during a stepped render.
*
* @param {HTMLElement} panelEl The #gfx-state-panel DOM element.
*/
class CanvasContextDetailsView {
#panel;
// Map<label, Map<prop, value>> — live graphics state per tracked context.
#ctxStates = new Map();
// Map<label, Array<Map<prop, value>>> — save() stack snapshots per context.
#ctxStateStacks = new Map();
// Map<label, number|null> — which stack frame is shown; null = live/current.
#ctxStackViewIdx = new Map();
// Map<label, Map<prop, {valEl, swatchEl?}>> — DOM elements for live updates.
#gfxStateValueElements = new Map();
// Map<label, {container, prevBtn, pos, nextBtn}> — stack-nav DOM elements.
#gfxStateNavElements = new Map();
constructor(panelEl) {
this.#panel = panelEl;
}
/**
* Wrap a CanvasRenderingContext2D to track its graphics state.
* Returns a Proxy that keeps internal state in sync and updates the DOM.
*/
wrapContext(ctx, label) {
const state = new Map();
for (const [prop, read] of CTX_PROP_READERS) {
state.set(prop, read(ctx));
}
this.#ctxStates.set(label, state);
this.#ctxStateStacks.set(label, []);
this.#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 (this.#gfxStateValueElements.size > 0) {
this.build();
}
return new Proxy(ctx, {
set: (target, prop, value) => {
target[prop] = value;
if (TRACKED_CTX_PROPS.has(prop)) {
state.set(prop, value);
this.#updatePropEl(label, prop, value);
}
return true;
},
get: (target, prop) => {
const val = target[prop];
if (typeof val !== "function") {
return val;
}
if (prop === "save") {
return (...args) => {
const result = val.apply(target, args);
this.#ctxStateStacks.get(label).push(this.#copyState(state));
this.#updateStackNav(label);
return result;
};
}
if (prop === "restore") {
return (...args) => {
const result = val.apply(target, args);
for (const [p, read] of CTX_PROP_READERS) {
const v = read(target);
state.set(p, v);
this.#updatePropEl(label, p, v);
}
const stack = this.#ctxStateStacks.get(label);
if (stack.length > 0) {
stack.pop();
// If the viewed frame was just removed, fall back to current.
const viewIndex = this.#ctxStackViewIdx.get(label);
if (viewIndex !== null && viewIndex >= stack.length) {
this.#ctxStackViewIdx.set(label, null);
this.#showState(label);
}
this.#updateStackNav(label);
}
return result;
};
}
if (prop === "setLineDash") {
return segments => {
val.call(target, segments);
const dash = target.getLineDash();
state.set("lineDash", dash);
this.#updatePropEl(label, "lineDash", dash);
};
}
if (TRANSFORM_METHODS.has(prop)) {
return (...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);
this.#updatePropEl(label, "transform", tf);
return result;
};
}
return val.bind(target);
},
});
}
/**
* Override canvas.getContext to return a tracked proxy for "2d" contexts.
* Caches the proxy so repeated getContext("2d") calls return the same
* wrapper.
*/
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 = this.wrapContext(ctx, label);
}
return wrappedCtx;
};
return canvas.getContext("2d");
}
/**
* Rebuild the graphics-state panel DOM for all currently tracked contexts.
* Shows the panel if it was hidden.
*/
build() {
this.#panel.hidden = false;
this.#panel.replaceChildren();
this.#gfxStateValueElements.clear();
this.#gfxStateNavElements.clear();
for (const [ctxLabel, state] of this.#ctxStates) {
const propEls = new Map();
this.#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-button";
prevBtn.ariaLabel = "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-button";
nextBtn.ariaLabel = "View newer saved state";
nextBtn.textContent = "→";
navContainer.append(prevBtn, pos, nextBtn);
title.append(titleLabel, navContainer);
section.append(title);
this.#gfxStateNavElements.set(ctxLabel, {
container: navContainer,
prevBtn,
pos,
nextBtn,
});
prevBtn.addEventListener("click", () => this.#navigate(ctxLabel, -1));
nextBtn.addEventListener("click", () => this.#navigate(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 } = this.#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 = this.#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);
}
this.#panel.append(section);
// Apply the correct state for the current view index (may be a saved
// frame).
this.#showState(ctxLabel);
this.#updateStackNav(ctxLabel);
}
}
/** Hide the panel. */
hide() {
this.#panel.hidden = true;
}
/**
* Scroll the panel to bring the section for the given context label into
* view.
*/
scrollToSection(label) {
this.#panel
.querySelector(`[data-ctx-label="${CSS.escape(label)}"]`)
?.scrollIntoView({ block: "nearest" });
}
/**
* Clear all tracked state and reset the panel DOM.
* Called when the debug view is reset between pages.
*/
clear() {
this.#ctxStates.clear();
this.#ctxStateStacks.clear();
this.#ctxStackViewIdx.clear();
this.#gfxStateValueElements.clear();
this.#gfxStateNavElements.clear();
this.#panel.replaceChildren();
}
#formatCtxValue(value) {
return Array.isArray(value) ? `[${value.join(", ")}]` : String(value);
}
// Shallow-copy a state Map (arrays and plain objects are cloned one level
// deep).
#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.
#applyPropEl(label, prop, value) {
const entry = this.#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 = this.#formatMatrixValue(value[k]);
}
return;
}
const text = this.#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.
#updatePropEl(label, prop, value) {
if (this.#ctxStackViewIdx.get(label) !== null) {
return;
}
this.#applyPropEl(label, prop, value);
}
// Re-render all value DOM elements for label using the currently-viewed
// state.
#showState(label) {
const viewIdx = this.#ctxStackViewIdx.get(label);
const stateToShow =
viewIdx === null
? this.#ctxStates.get(label)
: this.#ctxStateStacks.get(label)?.[viewIdx];
if (!stateToShow) {
return;
}
for (const [prop, value] of stateToShow) {
this.#applyPropEl(label, prop, value);
}
}
// Sync the stack-nav button states and position counter for a context.
#updateStackNav(label) {
const nav = this.#gfxStateNavElements.get(label);
if (!nav) {
return;
}
const stack = this.#ctxStateStacks.get(label) ?? [];
const viewIdx = this.#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}`;
}
// Navigate the save/restore stack view for a context.
// delta = -1 → older (prev) frame; +1 → newer (next) frame.
#navigate(label, delta) {
const stack = this.#ctxStateStacks.get(label) ?? [];
const viewIndex = this.#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;
}
this.#ctxStackViewIdx.set(label, newViewIndex);
this.#showState(label);
this.#updateStackNav(label);
}
#mEl(tag, ...children) {
const el = document.createElementNS(MATHML_NS, tag);
el.append(...children);
return el;
}
#formatMatrixValue(v) {
return Number.isInteger(v) ? String(v) : String(parseFloat(v.toFixed(4)));
}
#buildTransformMathML({ a, b, c, d, e, f }) {
const mnEls = {};
for (const [k, v] of Object.entries({ a, b, c, d, e, f })) {
mnEls[k] = this.#mEl("mn", this.#formatMatrixValue(v));
}
const math = this.#mEl(
"math",
this.#mEl(
"mrow",
this.#mEl("mo", "["),
this.#mEl(
"mtable",
this.#mEl(
"mtr",
this.#mEl("mtd", mnEls.a),
this.#mEl("mtd", mnEls.c),
this.#mEl("mtd", mnEls.e)
),
this.#mEl(
"mtr",
this.#mEl("mtd", mnEls.b),
this.#mEl("mtd", mnEls.d),
this.#mEl("mtd", mnEls.f)
),
this.#mEl(
"mtr",
this.#mEl("mtd", this.#mEl("mn", "0")),
this.#mEl("mtd", this.#mEl("mn", "0")),
this.#mEl("mtd", this.#mEl("mn", "1"))
)
),
this.#mEl("mo", "]")
)
);
return { math, mnEls };
}
}
export { CanvasContextDetailsView };

302
web/internal/debugger.css Normal file
View File

@ -0,0 +1,302 @@
/* 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 url(canvas_context_details_view.css);
@import url(draw_ops_view.css);
@import url(multiline_view.css);
@import url(page_view.css);
@import url(split_view.css);
@import url(tree_view.css);
:root {
color-scheme: light dark;
/* Backgrounds */
--bg-color: light-dark(#fff, #1e1e1e);
--surface-bg: light-dark(#f3f3f3, #252526);
--input-bg: light-dark(#fff, #3c3c3c);
--button-bg: light-dark(#f3f3f3, #3c3c3c);
--button-hover-bg: light-dark(#e0e0e0, #4a4a4a);
--clr-canvas-bg: var(--surface-bg);
/* Text */
--text-color: light-dark(#1e1e1e, #d4d4d4);
--muted-color: light-dark(#6e6e6e, #888);
--accent-color: light-dark(#0070c1, #9cdcfe);
/* Borders */
--border-color: light-dark(#e0e0e0, #3c3c3c);
--border-subtle-color: light-dark(#d0d0d0, #444);
--input-border-color: light-dark(#c8c8c8, #555);
/* Interactive states */
--hover-bg: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.05));
--hover-color: currentColor;
--paused-bg: light-dark(rgb(255 165 0 / 0.15), rgb(255 165 0 / 0.2));
--paused-outline-color: rgb(255 140 0 / 0.6);
--paused-color: currentColor;
/* Semantic */
--ref-color: light-dark(#007b6e, #4ec9b0);
--ref-hover-color: light-dark(#065, #89d9c8);
--changed-bg: transparent;
--changed-color: light-dark(#c00, #f66);
--match-bg: light-dark(rgb(255 200 0 / 0.35), rgb(255 200 0 / 0.25));
--match-outline-color: light-dark(rgb(200 140 0 / 0.8), rgb(255 200 0 / 0.6));
/* Syntax highlighting */
--string-color: light-dark(#a31515, #ce9178);
--number-color: light-dark(#098658, #b5cea8);
--bool-color: light-dark(#00f, #569cd6);
--null-color: light-dark(#767676, #808080);
--name-color: light-dark(#795e26, #dcdcaa);
--stream-color: light-dark(#af00db, #c586c0);
}
@media (forced-colors: active) {
:root {
/* Backgrounds */
--bg-color: Canvas;
--surface-bg: Canvas;
--input-bg: Field;
--button-bg: ButtonFace;
--button-hover-bg: Highlight;
--clr-canvas-bg: var(--surface-bg);
/* Text */
--text-color: CanvasText;
--muted-color: GrayText;
--accent-color: CanvasText;
/* Borders */
--border-color: ButtonBorder;
--border-subtle-color: ButtonBorder;
--input-border-color: ButtonBorder;
/* Interactive states */
--hover-bg: Highlight;
--hover-color: HighlightText;
--paused-bg: Mark;
--paused-outline-color: ButtonBorder;
--paused-color: MarkText;
/* Semantic */
--ref-color: LinkText;
--ref-hover-color: ActiveText;
--changed-bg: Mark;
--changed-color: MarkText;
--match-bg: Mark;
--match-outline-color: ButtonBorder;
/* Syntax highlighting — replaced by plain text in HCM */
--string-color: CanvasText;
--number-color: CanvasText;
--bool-color: CanvasText;
--null-color: GrayText;
--name-color: CanvasText;
--stream-color: CanvasText;
}
/* Opacity-only disabled style → explicit GrayText. */
button:disabled,
input:disabled {
color: GrayText;
border-color: GrayText;
opacity: 1;
}
}
* {
box-sizing: border-box;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
body.loading {
cursor: wait;
}
html {
height: 100%;
}
body {
font-family: "Courier New", Courier, monospace;
margin: 0;
padding: 16px;
background: var(--bg-color);
color: var(--text-color);
font-size: 13px;
line-height: 1.5;
display: flex;
flex-direction: column;
}
/* In debug mode the body must be viewport-height so #debug-view can fill it.
In tree mode body is auto-height so the tree can grow and the page scrolls. */
body:has(#debug-view:not([hidden])) {
height: 100%;
overflow: hidden;
}
#header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 12px;
h1 {
color: var(--accent-color);
font-size: 1.2em;
margin: 0;
}
#pdf-info {
font-family: system-ui, sans-serif;
font-size: 1.15em;
font-weight: 500;
color: var(--text-color);
}
}
#password-dialog {
background: var(--input-bg);
color: var(--text-color);
border: 1px solid var(--input-border-color);
border-radius: 6px;
padding: 20px;
min-width: 320px;
&::backdrop {
background: rgb(0 0 0 / 0.4);
}
p {
margin: 0 0 12px;
}
input {
display: block;
width: 100%;
margin-top: 4px;
background: var(--input-bg);
color: var(--text-color);
border: 1px solid var(--input-border-color);
border-radius: 3px;
padding: 4px 8px;
font-family: inherit;
font-size: inherit;
}
.password-dialog-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
button {
padding: 4px 14px;
border-radius: 3px;
border: 1px solid var(--input-border-color);
background: var(--button-bg);
color: inherit;
cursor: pointer;
font-family: inherit;
font-size: inherit;
&:hover {
background: var(--button-hover-bg);
}
}
}
}
#controls {
position: sticky;
top: 0;
z-index: 1;
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding: 10px 14px;
background: var(--surface-bg);
border-radius: 4px;
border: 1px solid var(--border-color);
label {
display: flex;
align-items: center;
gap: 4px;
color: var(--muted-color);
}
#github-link {
margin-inline-start: auto;
display: flex;
align-items: center;
color: var(--muted-color);
text-decoration: none;
&:hover {
color: var(--text-color);
}
svg {
width: 20px;
height: 20px;
fill: currentColor;
}
}
}
#goto-input {
background: var(--input-bg);
color: var(--text-color);
border: 1px solid var(--input-border-color);
border-radius: 3px;
padding: 2px 6px;
font-family: inherit;
font-size: inherit;
&:disabled {
opacity: 0.4;
}
&[aria-invalid="true"] {
border-color: var(--changed-color);
}
}
#status {
color: var(--muted-color);
font-style: italic;
}
#debug-button,
#debug-back-button {
padding: 4px 12px;
border-radius: 3px;
border: 1px solid var(--input-border-color);
background: var(--button-bg);
color: inherit;
cursor: pointer;
font-family: inherit;
font-size: inherit;
&:hover {
background: var(--button-hover-bg);
}
}

View File

@ -19,7 +19,7 @@ limitations under the License.
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PDF.js — Debugging tools</title>
<link rel="stylesheet" href="pdf_internal_viewer.css" />
<link rel="stylesheet" href="debugger.css" />
</head>
<body>
<div id="header">
@ -34,8 +34,8 @@ limitations under the License.
<span id="goto-input-hint" class="sr-only">
Enter a page number (e.g. 5), a reference as numR (e.g. 10R) or numRgen (e.g. 10R2). Press Enter to navigate.
</span>
<button id="debug-btn" hidden>Debug page</button>
<button id="debug-back-btn" hidden>← Back to tree</button>
<button id="debug-button" hidden>Debug page</button>
<button id="debug-back-button" hidden>← Back to tree</button>
<span id="status" role="status" aria-live="polite"> Select a PDF file to explore its internal structure. </span>
<a id="github-link" href="https://github.com/mozilla/pdf.js" target="_blank" rel="noopener noreferrer" aria-label="PDF.js on GitHub">
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
@ -49,25 +49,20 @@ limitations under the License.
<div id="debug-view" hidden>
<div id="render-panels">
<div id="op-left-col">
<div id="op-top-row">
<div id="op-list-panel">
<div id="op-list" role="listbox" aria-label="Operator list"></div>
</div>
<div id="op-gfx-state-resizer" role="separator" aria-orientation="vertical" tabindex="0" hidden></div>
<div id="gfx-state-panel" aria-label="Graphics state" hidden></div>
<div id="op-list-panel">
<div id="op-list" role="listbox" aria-label="Operator list"></div>
</div>
<div id="op-resizer" role="separator" aria-orientation="horizontal" tabindex="0"></div>
<div id="op-detail-panel"></div>
<div id="gfx-state-panel" aria-label="Graphics state" hidden></div>
</div>
<div id="render-resizer" role="separator" aria-orientation="vertical" tabindex="0"></div>
<div id="canvas-panel">
<div id="canvas-toolbar" role="toolbar" aria-label="Zoom controls">
<button id="zoom-out-btn" aria-label="Zoom out"></button>
<button id="zoom-out-button" aria-label="Zoom out"></button>
<span id="zoom-level" aria-live="polite"></span>
<button id="zoom-in-btn" aria-label="Zoom in">+</button>
<button id="redraw-btn" aria-label="Redraw page">Redraw</button>
<button id="step-btn" aria-label="Step one instruction" disabled><u>S</u>tep</button>
<button id="continue-btn" aria-label="Continue to next breakpoint" disabled><u>C</u>ontinue</button>
<button id="zoom-in-button" aria-label="Zoom in">+</button>
<button id="redraw-button" aria-label="Redraw page">Redraw</button>
<button id="step-button" aria-label="Step one instruction" disabled><u>S</u>tep</button>
<button id="continue-button" aria-label="Continue to next breakpoint" disabled><u>C</u>ontinue</button>
</div>
<div id="canvas-scroll">
<div id="canvas-wrapper">
@ -93,25 +88,25 @@ limitations under the License.
<!--#if GENERIC-->
<!--<script src="../build/pdf.mjs" type="module"></script>-->
<!--<script src="pdf_internal_viewer.mjs" type="module"></script>-->
<!--<script src="debugger.mjs" type="module"></script>-->
<!--#else-->
<script type="importmap">
{
"imports": {
"pdfjs/": "../src/",
"pdfjs-lib": "../src/pdf.js",
"pdfjs/": "../../src/",
"pdfjs-lib": "../../src/pdf.js",
"display-cmap_reader_factory": "../src/display/cmap_reader_factory.js",
"display-standard_fontdata_factory": "../src/display/standard_fontdata_factory.js",
"display-wasm_factory": "../src/display/wasm_factory.js",
"display-fetch_stream": "../src/display/fetch_stream.js",
"display-network": "../src/display/network.js",
"display-node_stream": "../src/display/stubs.js",
"display-node_utils": "../src/display/stubs.js"
"display-cmap_reader_factory": "../../src/display/cmap_reader_factory.js",
"display-standard_fontdata_factory": "../../src/display/standard_fontdata_factory.js",
"display-wasm_factory": "../../src/display/wasm_factory.js",
"display-fetch_stream": "../../src/display/fetch_stream.js",
"display-network": "../../src/display/network.js",
"display-node_stream": "../../src/display/stubs.js",
"display-node_utils": "../../src/display/stubs.js"
}
}
</script>
<script src="pdf_internal_viewer.js" type="module"></script>
<script src="debugger.js" type="module"></script>
<!--#endif-->
</body>
</html>

255
web/internal/debugger.js Normal file
View File

@ -0,0 +1,255 @@
/* 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, PasswordResponses } from "pdfjs-lib";
import { PageView } from "./page_view.js";
import { TreeView } from "./tree_view.js";
GlobalWorkerOptions.workerSrc =
typeof PDFJSDev === "undefined"
? "../../src/pdf.worker.js"
: "../build/pdf.worker.mjs";
// Parses "num" into { page: num }, or "numR"/"numRgen" into { ref: {num,gen} }.
// Returns null for invalid input.
function parseGoToInput(str) {
const match = str.trim().match(/^(\d+)(R(\d+)?)?$/i);
if (!match) {
return null;
}
if (!match[2]) {
return { page: parseInt(match[1], 10) };
}
return {
ref: {
num: parseInt(match[1], 10),
gen: match[3] !== undefined ? parseInt(match[3], 10) : 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 match = str.trim().match(/^(\d+)(?:R(\d+)?)?$/i);
if (!match) {
return null;
}
return {
num: parseInt(match[1], 10),
gen: match[2] !== undefined ? parseInt(match[2], 10) : 0,
};
}
let pdfDoc = null;
// Page number currently displayed in the tree (null when showing a
// ref/trailer).
let currentPage = null;
// 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);
}
// Cache frequently accessed elements.
const debugButton = document.getElementById("debug-button");
const debugBackButton = document.getElementById("debug-back-button");
const debugViewEl = document.getElementById("debug-view");
const treeEl = document.getElementById("tree");
const statusEl = document.getElementById("status");
const gotoInput = document.getElementById("goto-input");
const pdfInfoEl = document.getElementById("pdf-info");
const pageView = new PageView({ onMarkLoading: markLoading });
const treeView = new TreeView(treeEl, { onMarkLoading: markLoading });
async function loadTree(data, rootLabel = null) {
currentPage = typeof data.page === "number" ? data.page : null;
debugButton.hidden = currentPage === null;
debugBackButton.hidden = true;
pageView.reset();
debugViewEl.hidden = true;
treeEl.hidden = false;
await treeView.load(data, rootLabel, pdfDoc);
}
async function openDocument(source, name) {
statusEl.textContent = `Loading ${name}`;
pdfInfoEl.textContent = "";
treeView.clearCache();
if (pdfDoc) {
pageView.reset();
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: pageView.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 cancelButton = 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 cleanup = () => {
dialog.removeEventListener("close", onSubmit);
cancelButton.removeEventListener("click", onCancel);
};
const onSubmit = () => {
cleanup();
updateCallback(input.value);
};
const onCancel = () => {
cleanup();
dialog.close();
updateCallback(new Error("Password prompt cancelled."));
};
dialog.addEventListener("close", onSubmit, { once: true });
cancelButton.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) {
statusEl.textContent = `Error: ${err.message}`;
treeView.showError(err.message);
}
document.getElementById("file-input").value = "";
document
.getElementById("file-input")
.addEventListener("change", async ({ target }) => {
const file = 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) {
gotoInput.value = refStr;
await loadTree({ ref });
return;
}
}
if (pageStr) {
const page = parseInt(pageStr, 10);
if (Number.isInteger(page) && page >= 1 && page <= pdfDoc.numPages) {
gotoInput.value = pageStr;
await loadTree({ page });
return;
}
}
await loadTree({ ref: null }, "Trailer");
} catch (err) {
showError(err);
}
})();
gotoInput.addEventListener("keydown", async ({ key, target }) => {
if (key !== "Enter" || !pdfDoc) {
return;
}
if (target.value.trim() === "") {
target.removeAttribute("aria-invalid");
await loadTree({ ref: null }, "Trailer");
return;
}
const result = parseGoToInput(target.value);
if (!result) {
target.setAttribute("aria-invalid", "true");
return;
}
if (
result.page !== undefined &&
(result.page < 1 || result.page > pdfDoc.numPages)
) {
target.setAttribute("aria-invalid", "true");
return;
}
target.removeAttribute("aria-invalid");
await (result.page !== undefined
? loadTree({ page: result.page })
: loadTree({ ref: result.ref }));
});
gotoInput.addEventListener("input", ({ target }) => {
if (target.value.trim() === "") {
target.removeAttribute("aria-invalid");
}
});
debugButton.addEventListener("click", async () => {
debugButton.hidden = treeEl.hidden = true;
debugBackButton.hidden = debugViewEl.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).
await pageView.show(pdfDoc, currentPage);
});
debugBackButton.addEventListener("click", () => {
debugBackButton.hidden = debugViewEl.hidden = true;
debugButton.hidden = treeEl.hidden = false;
});

View File

@ -0,0 +1,205 @@
/* 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.
*/
/* Hidden color-picker input reused by all color swatches. */
.color-picker-input {
position: fixed;
opacity: 0;
pointer-events: none;
width: 0;
height: 0;
}
/* MultilineView instance used as the op-list panel in the debug view. */
.op-list-panel-wrapper {
flex: 7 1 0;
min-width: 0;
min-height: 0;
border-color: var(--border-color);
border-radius: 4px;
& > .mlc-goto-bar {
position: static;
}
& > .mlc-body > .mlc-inner {
padding: 8px 12px;
background: var(--surface-bg);
}
.mlc-line-nums-col {
padding-block: 8px;
}
}
#op-list-panel {
flex: 7 1 0;
overflow: auto;
min-width: 0;
min-height: 0;
padding: 8px 12px;
background: var(--surface-bg);
border-radius: 4px;
border: 1px solid var(--border-color);
}
#op-list {
min-width: max-content;
}
#op-detail-panel {
flex: 3 1 0;
container-type: size;
overflow: auto;
min-height: 0;
padding: 8px 12px;
background: var(--surface-bg);
border-radius: 4px;
border: 1px solid var(--border-color);
.detail-name {
color: var(--accent-color);
font-weight: bold;
margin-bottom: 4px;
}
.detail-empty {
color: var(--muted-color);
font-style: italic;
}
.detail-row {
display: flex;
align-items: center;
gap: 8px;
padding: 1px 0;
}
.detail-idx {
color: var(--muted-color);
flex-shrink: 0;
}
.detail-val {
color: var(--number-color);
white-space: pre-wrap;
word-break: break-all;
}
.detail-body {
display: flex;
flex-direction: row;
gap: 12px;
align-items: flex-start;
}
.detail-args-col {
flex: 1;
min-width: 0;
}
.detail-img-col {
flex-shrink: 0;
max-width: 45%;
overflow: hidden;
.image-preview {
height: 90cqh;
width: auto;
max-width: 100%;
margin-top: 0;
}
}
.path-preview {
flex-shrink: 0;
border: 1px solid var(--border-subtle-color);
border-radius: 3px;
background: var(--bg-color);
}
}
.op-line {
display: flex;
align-items: center;
gap: 0.5ch;
white-space: nowrap;
cursor: pointer;
&.selected {
text-decoration: underline;
}
&:hover {
background: var(--hover-bg);
color: var(--hover-color);
}
}
.op-name {
color: var(--accent-color);
font-weight: bold;
}
.op-arg {
color: var(--number-color);
}
.changed-value {
font-weight: bold;
background: var(--changed-bg);
color: var(--changed-color);
}
.bp-gutter {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
flex-shrink: 0;
cursor: pointer;
user-select: none;
&::before {
content: "●";
color: var(--changed-color);
font-size: 0.75em;
opacity: 0;
}
&:hover::before {
opacity: 0.4;
}
&.active::before {
opacity: 1;
}
}
.op-line.paused {
background: var(--paused-bg);
color: var(--paused-color);
outline: 1px solid var(--paused-outline-color);
outline-offset: -1px;
}
.color-swatch {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid var(--muted-color);
flex-shrink: 0;
}
@media (forced-colors: active) {
/* Opacity trick for breakpoint glyph visibility → use Canvas color to hide. */
.bp-gutter::before {
opacity: 1;
color: Canvas;
}
.bp-gutter:hover::before {
color: ButtonBorder;
}
.bp-gutter.active::before {
color: ButtonText;
}
/* Color swatch preserves the actual PDF color value. */
.color-swatch {
forced-color-adjust: none;
}
}

View File

@ -0,0 +1,659 @@
/* 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 };

View File

@ -0,0 +1,200 @@
/* 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.
*/
.mlc-scroll {
color-scheme: light dark;
--surface-bg: light-dark(#f3f3f3, #252526);
--input-bg: light-dark(#fff, #3c3c3c);
--button-bg: light-dark(#f3f3f3, #3c3c3c);
--button-hover-bg: light-dark(#e0e0e0, #4a4a4a);
--text-color: light-dark(#1e1e1e, #d4d4d4);
--muted-color: light-dark(#6e6e6e, #888);
--accent-color: light-dark(#0070c1, #9cdcfe);
--border-subtle-color: light-dark(#d0d0d0, #444);
--input-border-color: light-dark(#c8c8c8, #555);
--match-bg: light-dark(rgb(255 200 0 / 0.35), rgb(255 200 0 / 0.25));
--match-outline-color: light-dark(rgb(200 140 0 / 0.8), rgb(255 200 0 / 0.6));
display: flex;
flex-direction: column;
border: 1px solid var(--border-subtle-color);
border-radius: 3px;
overflow: hidden;
.mlc-load-sentinel {
height: 0;
}
/* Row wrapper that sits between the toolbar and the scrollable content.
Hosts the frozen line-number column and the actual scroll container. */
.mlc-body {
flex: 1;
display: flex;
flex-direction: row;
min-height: 0;
line-height: 1.8em;
}
/* The line-number column lives *outside* the scroll container so it is
never affected by horizontal or vertical scroll. Its scrollTop is kept
in sync with the adjacent scroll container via a JS scroll listener. */
.mlc-line-nums-col {
overflow: hidden;
flex-shrink: 0;
background: var(--surface-bg);
border-inline-end: 1px solid var(--border-subtle-color);
}
.mlc-inner {
flex: 1;
overflow: auto;
/* Disable scroll anchoring so manual scrollTop corrections aren't doubled. */
overflow-anchor: none;
}
.mlc-goto-bar {
position: sticky;
top: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 3px 4px;
background: var(--surface-bg);
border-bottom: 1px solid var(--border-subtle-color);
z-index: 1;
.mlc-search-group {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.mlc-search-input,
.mlc-goto {
font: inherit;
font-size: 0.85em;
padding: 2px 6px;
border: 1px solid var(--input-border-color);
border-radius: 3px;
background: var(--input-bg);
color: var(--text-color);
&:focus {
outline: 2px solid var(--accent-color);
outline-offset: 0;
}
&[aria-invalid="true"] {
border-color: red;
}
}
.mlc-search-input {
width: 140px;
}
.mlc-goto {
width: 110px;
margin-inline-start: auto;
}
.mlc-nav-button {
font: inherit;
font-size: 0.85em;
padding: 1px 6px;
border: 1px solid var(--input-border-color);
border-radius: 3px;
background: var(--button-bg);
color: var(--text-color);
cursor: pointer;
line-height: 1.4;
&:hover:not(:disabled) {
background: var(--button-hover-bg);
}
&:disabled {
opacity: 0.4;
cursor: default;
}
&[aria-pressed="true"] {
background: var(--accent-color);
color: light-dark(white, black);
border-color: var(--accent-color);
}
}
.mlc-match-info {
font-size: 0.8em;
color: var(--muted-color);
white-space: nowrap;
min-width: 4ch;
}
.mlc-check-label {
display: flex;
align-items: center;
gap: 3px;
font-size: 0.85em;
cursor: pointer;
white-space: nowrap;
}
}
.mlc-num-item {
display: block;
white-space: nowrap;
min-width: var(--line-num-width, 3ch);
padding-inline: 0.4em;
text-align: right;
font-family: monospace;
font-size: 0.8em;
color: var(--muted-color);
user-select: none;
}
.mlc-num-item.mlc-match {
background: var(--match-bg);
}
.mlc-match {
background: var(--match-bg);
outline: 1px solid var(--match-outline-color);
}
}
@media (forced-colors: active) {
.mlc-scroll {
--surface-bg: Canvas;
--input-bg: Field;
--button-bg: ButtonFace;
--button-hover-bg: Highlight;
--text-color: CanvasText;
--muted-color: GrayText;
--accent-color: CanvasText;
--border-subtle-color: ButtonBorder;
--input-border-color: ButtonBorder;
--match-bg: Mark;
--match-outline-color: ButtonBorder;
.mlc-search-input[aria-invalid="true"],
.mlc-goto[aria-invalid="true"] {
border-color: ButtonBorder;
}
}
}

View File

@ -0,0 +1,517 @@
/* 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.
*/
// Number of rows rendered per batch (IntersectionObserver batching).
const BATCH_SIZE = 500;
// Maximum rows kept in the DOM at once (two batches).
const MAX_RENDERED = BATCH_SIZE * 2;
let _idCounter = 0;
/**
* A scrollable multi-line panel combining:
* a frozen line-number column on the left,
* a scrollable content column on the right,
* a search / go-to-line toolbar at the top,
* IntersectionObserver-based virtual scroll.
*
* Usage:
* const mc = new MultilineView({ total, getText, makeLineEl });
* container.append(mc.element);
*/
class MultilineView {
// -- DOM elements --
#element;
#numCol;
#innerEl;
#pre;
#topSentinel;
#bottomSentinel;
#observer = null;
#onScroll = null;
#total;
#getText;
#makeLineEl;
#startIndex = 0;
#endIndex = 0;
#highlightedIndex = -1;
#searchMatches = [];
#currentMatchIdx = -1;
#searchInput;
#searchError;
#prevButton;
#nextButton;
#matchInfo;
#ignoreCaseCb;
#regexCb;
/**
* @param {object} opts
* @param {number} opts.total Total number of lines.
* @param {Function} opts.getText (i) => string used for search.
* @param {Function} opts.makeLineEl (i, isHighlighted) => HTMLElement.
* @param {string} [opts.lineClass] CSS class for the lines container.
* @param {HTMLElement} [opts.actions] Element prepended in the toolbar.
*/
constructor({ total, getText, makeLineEl, lineClass = "", actions = null }) {
this.#total = total;
this.#getText = getText;
this.#makeLineEl = makeLineEl;
// Root element.
this.#element = document.createElement("div");
this.#element.className = "mlc-scroll";
// Line-number column (frozen; scrollTop synced with the scroll pane).
this.#numCol = document.createElement("div");
this.#numCol.className = "mlc-line-nums-col";
this.#numCol.style.setProperty(
"--line-num-width",
`${String(total).length}ch`
);
// Scrollable content column.
this.#innerEl = document.createElement("div");
this.#innerEl.className = "mlc-inner";
this.#onScroll = () => {
this.#numCol.scrollTop = this.#innerEl.scrollTop;
};
this.#innerEl.addEventListener("scroll", this.#onScroll);
// Item container inside the scroll column.
this.#pre = document.createElement("div");
if (lineClass) {
this.#pre.className = lineClass;
}
this.#innerEl.append(this.#pre);
const body = document.createElement("div");
body.className = "mlc-body";
body.append(this.#numCol, this.#innerEl);
this.#element.append(this.#buildToolbar(actions), body);
// Sentinels bracket the rendered window inside #pre:
// topSentinel [startIndex .. endIndex) bottomSentinel
this.#topSentinel = document.createElement("div");
this.#topSentinel.className = "mlc-load-sentinel";
this.#bottomSentinel = document.createElement("div");
this.#bottomSentinel.className = "mlc-load-sentinel";
this.#endIndex = Math.min(BATCH_SIZE, total);
this.#pre.append(
this.#topSentinel,
this.#renderRange(0, this.#endIndex),
this.#bottomSentinel
);
this.#numCol.append(this.#renderNumRange(0, this.#endIndex));
if (total > BATCH_SIZE) {
this.#setupObserver();
}
}
/** The root element — append to the DOM to display the component. */
get element() {
return this.#element;
}
/** The inner content container (between the sentinels). Useful for setting
* ARIA attributes and attaching keyboard listeners. */
get inner() {
return this.#pre;
}
/**
* Scroll to ensure line i (0-based) is visible without changing the current
* search highlight. Useful for programmatic navigation (e.g. a debugger).
*/
scrollToLine(i) {
if (i < 0 || i >= this.#total) {
return;
}
if (i >= this.#startIndex && i < this.#endIndex) {
this.#scrollRenderedTargetIntoView(i);
} else {
this.#jumpToTarget(i);
}
}
destroy() {
this.#observer?.disconnect();
this.#observer = null;
if (this.#onScroll) {
this.#innerEl.removeEventListener("scroll", this.#onScroll);
this.#onScroll = null;
}
}
/**
* Scroll to line i (0-based) and mark it as the current search highlight.
* Pass i = -1 to clear the highlight.
*/
jumpToLine(i) {
this.#pre.querySelector(".mlc-match")?.classList.remove("mlc-match");
this.#numCol.querySelector(".mlc-match")?.classList.remove("mlc-match");
if (i < 0) {
this.#highlightedIndex = -1;
return;
}
if (i >= this.#total) {
return;
}
this.#highlightedIndex = i;
if (i >= this.#startIndex && i < this.#endIndex) {
this.#scrollRenderedTargetIntoView(i);
} else {
this.#jumpToTarget(i);
}
this.#pre.children[i - this.#startIndex + 1]?.classList.add("mlc-match");
this.#numCol.children[i - this.#startIndex]?.classList.add("mlc-match");
}
#renderRange(from, to) {
const frag = document.createDocumentFragment();
for (let i = from; i < to; i++) {
frag.append(this.#makeLineEl(i, i === this.#highlightedIndex));
}
return frag;
}
#renderNumRange(from, to) {
const frag = document.createDocumentFragment();
for (let i = from; i < to; i++) {
const item = document.createElement("div");
item.className = "mlc-num-item";
if (i === this.#highlightedIndex) {
item.classList.add("mlc-match");
}
item.textContent = String(i + 1);
frag.append(item);
}
return frag;
}
// Re-render a window centred on targetIndex and scroll to it.
#jumpToTarget(targetIndex) {
// Remove all rendered rows between the sentinels.
const firstRow = this.#topSentinel.nextSibling;
const lastRow = this.#bottomSentinel.previousSibling;
if (firstRow && lastRow && firstRow !== this.#bottomSentinel) {
const range = document.createRange();
range.setStartBefore(firstRow);
range.setEndAfter(lastRow);
range.deleteContents();
}
const half = Math.floor(MAX_RENDERED / 2);
this.#startIndex = Math.max(0, targetIndex - half);
this.#endIndex = Math.min(this.#total, this.#startIndex + MAX_RENDERED);
this.#startIndex = Math.max(0, this.#endIndex - MAX_RENDERED);
this.#topSentinel.after(
this.#renderRange(this.#startIndex, this.#endIndex)
);
this.#numCol.replaceChildren(
this.#renderNumRange(this.#startIndex, this.#endIndex)
);
this.#scrollRenderedTargetIntoView(targetIndex);
}
#scrollRenderedTargetIntoView(targetIndex) {
// #pre.children: [0]=topSentinel, [1..n]=rows, [n+1]=bottomSentinel
const targetEl = this.#pre.children[targetIndex - this.#startIndex + 1];
if (!targetEl) {
return;
}
const targetRect = targetEl.getBoundingClientRect();
const innerRect = this.#innerEl.getBoundingClientRect();
this.#innerEl.scrollTop +=
targetRect.top -
innerRect.top -
this.#innerEl.clientHeight / 2 +
targetEl.clientHeight / 2;
}
#setupObserver() {
const observer = (this.#observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
if (!entry.isIntersecting) {
continue;
}
if (entry.target === this.#bottomSentinel) {
this.#loadBottom();
} else {
this.#loadTop();
}
}
},
{ root: this.#innerEl, rootMargin: "200px" }
));
observer.observe(this.#topSentinel);
observer.observe(this.#bottomSentinel);
}
#loadBottom() {
const newEnd = Math.min(this.#endIndex + BATCH_SIZE, this.#total);
if (newEnd === this.#endIndex) {
return;
}
this.#bottomSentinel.before(this.#renderRange(this.#endIndex, newEnd));
this.#numCol.append(this.#renderNumRange(this.#endIndex, newEnd));
this.#endIndex = newEnd;
// Trim from top if the window exceeds MAX_RENDERED.
if (this.#endIndex - this.#startIndex > MAX_RENDERED) {
const removeCount = this.#endIndex - this.#startIndex - MAX_RENDERED;
const heightBefore = this.#pre.scrollHeight;
for (let i = 0; i < removeCount; i++) {
this.#topSentinel.nextElementSibling?.remove();
this.#numCol.firstElementChild?.remove();
}
this.#startIndex += removeCount;
// Compensate so visible content doesn't jump upward.
this.#innerEl.scrollTop -= heightBefore - this.#pre.scrollHeight;
}
}
#loadTop() {
if (this.#startIndex === 0) {
return;
}
const newStart = Math.max(0, this.#startIndex - BATCH_SIZE);
const scrollBefore = this.#innerEl.scrollTop;
const heightBefore = this.#pre.scrollHeight;
this.#topSentinel.after(this.#renderRange(newStart, this.#startIndex));
this.#numCol.prepend(this.#renderNumRange(newStart, this.#startIndex));
// Compensate so visible content doesn't jump downward.
this.#innerEl.scrollTop =
scrollBefore + (this.#pre.scrollHeight - heightBefore);
this.#startIndex = newStart;
// Trim from bottom if the window exceeds MAX_RENDERED.
if (this.#endIndex - this.#startIndex > MAX_RENDERED) {
const removeCount = this.#endIndex - this.#startIndex - MAX_RENDERED;
for (let i = 0; i < removeCount; i++) {
this.#bottomSentinel.previousElementSibling?.remove();
this.#numCol.lastElementChild?.remove();
}
this.#endIndex -= removeCount;
}
}
#buildToolbar(actions) {
const id = ++_idCounter;
const bar = document.createElement("div");
bar.className = "mlc-goto-bar";
const searchGroup = document.createElement("div");
searchGroup.className = "mlc-search-group";
const searchErrorId = `mlc-err-${id}`;
const searchInput = (this.#searchInput = document.createElement("input"));
searchInput.type = "search";
searchInput.className = "mlc-search-input";
searchInput.placeholder = "Search for\u2026";
searchInput.ariaLabel = "Search";
searchInput.setAttribute("aria-describedby", searchErrorId);
const searchError = (this.#searchError = document.createElement("span"));
searchError.id = searchErrorId;
searchError.className = "sr-only";
searchError.role = "alert";
const prevButton = (this.#prevButton = document.createElement("button"));
prevButton.className = "mlc-nav-button";
prevButton.textContent = "↑";
prevButton.ariaLabel = "Previous match";
prevButton.disabled = true;
const nextButton = (this.#nextButton = document.createElement("button"));
nextButton.className = "mlc-nav-button";
nextButton.textContent = "↓";
nextButton.ariaLabel = "Next match";
nextButton.disabled = true;
const matchInfo = (this.#matchInfo = document.createElement("span"));
matchInfo.className = "mlc-match-info";
const { label: ignoreCaseLabel, cb: ignoreCaseCb } =
this.#makeCheckboxLabel("Ignore case");
const { label: regexLabel, cb: regexCb } = this.#makeCheckboxLabel("Regex");
this.#ignoreCaseCb = ignoreCaseCb;
this.#regexCb = regexCb;
searchGroup.append(
searchInput,
searchError,
prevButton,
nextButton,
matchInfo,
ignoreCaseLabel,
regexLabel
);
const gotoInput = document.createElement("input");
gotoInput.type = "number";
gotoInput.className = "mlc-goto";
gotoInput.placeholder = "Go to line\u2026";
gotoInput.min = "1";
gotoInput.max = String(this.#total);
gotoInput.step = "1";
gotoInput.ariaLabel = "Go to line";
if (actions) {
bar.append(actions);
}
bar.append(searchGroup, gotoInput);
searchInput.addEventListener("input", () => this.#runSearch());
searchInput.addEventListener("keydown", ({ key, shiftKey }) => {
if (key === "Enter") {
this.#navigateMatch(shiftKey ? -1 : 1);
}
});
prevButton.addEventListener("click", () => this.#navigateMatch(-1));
nextButton.addEventListener("click", () => this.#navigateMatch(1));
this.#ignoreCaseCb.addEventListener("change", () => this.#runSearch());
this.#regexCb.addEventListener("change", () => this.#runSearch());
gotoInput.addEventListener("keydown", ({ key }) => {
if (key !== "Enter") {
return;
}
const value = gotoInput.value.trim();
const n = Number(value);
if (!value || !Number.isInteger(n) || n < 1 || n > this.#total) {
gotoInput.setAttribute("aria-invalid", "true");
return;
}
gotoInput.removeAttribute("aria-invalid");
this.jumpToLine(n - 1);
});
return bar;
}
#makeCheckboxLabel(text) {
const label = document.createElement("label");
label.className = "mlc-check-label";
const cb = document.createElement("input");
cb.type = "checkbox";
label.append(cb, ` ${text}`);
return { label, cb };
}
#updateMatchInfo() {
if (!this.#searchInput.value) {
this.#matchInfo.textContent = "";
this.#prevButton.disabled = this.#nextButton.disabled = true;
} else if (this.#searchMatches.length === 0) {
this.#matchInfo.textContent = "No results";
this.#prevButton.disabled = this.#nextButton.disabled = true;
} else {
this.#matchInfo.textContent = `${this.#currentMatchIdx + 1} / ${this.#searchMatches.length}`;
this.#prevButton.disabled = this.#nextButton.disabled = false;
}
}
#computeMatches() {
this.jumpToLine(-1);
this.#searchMatches = [];
this.#currentMatchIdx = -1;
const query = this.#searchInput.value;
if (!query) {
this.#updateMatchInfo();
return false;
}
let test;
if (this.#regexCb.checked) {
try {
const re = new RegExp(query, this.#ignoreCaseCb.checked ? "i" : "");
test = str => re.test(str);
this.#searchInput.removeAttribute("aria-invalid");
this.#searchError.textContent = "";
} catch {
this.#searchInput.setAttribute("aria-invalid", "true");
this.#searchError.textContent = "Invalid regular expression";
this.#updateMatchInfo();
return false;
}
} else {
const ignoreCase = this.#ignoreCaseCb.checked;
const needle = ignoreCase ? query.toLowerCase() : query;
test = str => (ignoreCase ? str.toLowerCase() : str).includes(needle);
}
this.#searchInput.removeAttribute("aria-invalid");
this.#searchError.textContent = "";
for (let i = 0, ii = this.#total; i < ii; i++) {
if (test(this.#getText(i))) {
this.#searchMatches.push(i);
}
}
return this.#searchMatches.length > 0;
}
#navigateMatch(delta) {
if (!this.#searchMatches.length) {
return;
}
this.#currentMatchIdx =
(this.#currentMatchIdx + delta + this.#searchMatches.length) %
this.#searchMatches.length;
this.jumpToLine(this.#searchMatches[this.#currentMatchIdx]);
this.#updateMatchInfo();
}
#runSearch() {
if (this.#computeMatches() && this.#searchMatches.length) {
this.#currentMatchIdx = 0;
this.jumpToLine(this.#searchMatches[0]);
}
this.#updateMatchInfo();
}
}
export { MultilineView };

139
web/internal/page_view.css Normal file
View File

@ -0,0 +1,139 @@
/* 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.
*/
#debug-view {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
&[hidden] {
display: none;
}
}
#render-panels {
flex: 1;
min-width: 0;
min-height: 0;
align-items: stretch;
--spc-resizer-color: var(--border-color);
--spc-resizer-hover-color: var(--accent-color);
}
#render-panels {
/* instructionsSplit (spc-column) takes 70% of the width next to canvas. */
> .spc-column {
flex: 1 1 0;
}
/* opTopSplit (spc-row) takes 70% of the instructions column height. */
> .spc-column > .spc-row {
flex: 7 1 0;
}
}
#gfx-state-panel {
flex: 3 1 0;
min-width: 20ch;
overflow: auto;
min-height: 0;
padding-block: 8px;
background: var(--surface-bg);
border-radius: 4px;
border: 1px solid var(--border-color);
}
#canvas-panel {
flex: 1 1 0;
display: flex;
flex-direction: column;
min-width: 0;
background: var(--surface-bg);
border-radius: 4px;
border: 1px solid var(--border-color);
}
#canvas-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 4px 8px;
border-bottom: 1px solid var(--border-color);
button {
padding: 1px 8px;
border-radius: 3px;
border: 1px solid var(--input-border-color);
background: var(--button-bg);
color: inherit;
cursor: pointer;
font-family: inherit;
font-size: 1.1em;
line-height: 1.4;
&:hover {
background: var(--button-hover-bg);
}
&:disabled {
opacity: 0.4;
cursor: default;
}
}
#zoom-level {
min-width: 4ch;
text-align: center;
}
}
#canvas-scroll {
flex: 1;
overflow: auto;
padding: 8px 12px;
min-height: 0;
background: var(--clr-canvas-bg);
display: flex;
flex-direction: column;
align-items: safe center;
gap: 12px;
}
#canvas-wrapper {
position: relative;
display: inline-block;
line-height: 0;
}
.temp-canvas-wrapper {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
cursor: pointer;
}
.temp-canvas-label {
font-size: 0.85em;
color: var(--muted-color);
font-style: italic;
}
.temp-canvas-wrapper canvas {
border: 1px solid var(--border-subtle-color);
zoom: calc(1 / var(--dpr, 1));
}
#render-canvas {
cursor: pointer;
}
#highlight-canvas {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}

625
web/internal/page_view.js Normal file
View File

@ -0,0 +1,625 @@
/* 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 { CanvasContextDetailsView } from "./canvas_context_details_view.js";
import { DOMCanvasFactory } from "pdfjs/display/canvas_factory.js";
import { DrawOpsView } from "./draw_ops_view.js";
import { SplitView } from "./split_view.js";
// Stepper for pausing/stepping through op list rendering.
// Implements the interface expected by InternalRenderTask (pdfBug mode).
class ViewerStepper {
#onStepped;
#continueCallback = null;
// Pass resumeAt to re-pause at a specific index (e.g. after a zoom).
constructor(onStepped, resumeAt = null) {
this.#onStepped = onStepped;
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;
this.#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 globalThis.StepperManager._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;
}
}
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 10;
const ZOOM_STEP = 1.25;
class PageView {
#pdfDoc = null;
#gfxStateComp;
#DebugCanvasFactoryClass;
#opsView;
#renderedPage = null;
#renderScale = null;
#currentRenderTask = null;
#currentOpList = null;
#debugViewGeneration = 0;
#onMarkLoading;
#prefersDark;
#onWindowResize;
#stepButton;
#continueButton;
#zoomLevelEl;
#zoomOutButton;
#zoomInButton;
#redrawButton;
#highlightCanvas;
#canvasScrollEl;
constructor({ onMarkLoading }) {
this.#onMarkLoading = onMarkLoading;
this.#prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
this.#gfxStateComp = new CanvasContextDetailsView(
document.getElementById("gfx-state-panel")
);
this.#stepButton = document.getElementById("step-button");
this.#continueButton = document.getElementById("continue-button");
this.#opsView = new DrawOpsView(
document.getElementById("op-list-panel"),
document.getElementById("op-detail-panel"),
{
onHighlight: i => this.#drawHighlight(i),
onClearHighlight: () => this.#clearHighlight(),
prefersDark: this.#prefersDark,
}
);
// 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,
_breakpoints: this.#opsView.breakpoints,
create() {
return globalThis.StepperManager._active;
},
};
// Keep --dpr in sync so CSS can scale temp canvases correctly.
this.#updateDPR();
this.#onWindowResize = () => this.#updateDPR();
window.addEventListener("resize", this.#onWindowResize);
this.#DebugCanvasFactoryClass = this.#makeDebugCanvasFactory();
this.#setupSplits();
this.#zoomLevelEl = document.getElementById("zoom-level");
this.#zoomOutButton = document.getElementById("zoom-out-button");
this.#zoomInButton = document.getElementById("zoom-in-button");
this.#redrawButton = document.getElementById("redraw-button");
this.#highlightCanvas = document.getElementById("highlight-canvas");
this.#canvasScrollEl = document.getElementById("canvas-scroll");
this.#setupEventListeners();
}
// Expose DebugCanvasFactory class so caller can pass to getDocument().
get DebugCanvasFactory() {
return this.#DebugCanvasFactoryClass;
}
// Show the debug view for a given page.
async show(pdfDoc, pageNum) {
this.#pdfDoc = pdfDoc;
if (this.#currentOpList === null) {
await this.#showRenderView(pageNum);
}
}
// Reset all debug state (call when navigating to tree or loading new doc).
reset() {
this.#debugViewGeneration++;
this.#currentRenderTask?.cancel();
this.#currentRenderTask = null;
this.#renderedPage?.cleanup();
this.#renderedPage = this.#renderScale = this.#currentOpList = null;
this.#clearPausedState();
this.#opsView.clear();
this.#gfxStateComp.clear();
this.#pdfDoc?.canvasFactory.clear();
const mainCanvas = document.getElementById("render-canvas");
mainCanvas.width = mainCanvas.height = 0;
this.#highlightCanvas.width = this.#highlightCanvas.height = 0;
this.#zoomLevelEl.textContent = "";
this.#zoomOutButton.disabled = false;
this.#zoomInButton.disabled = false;
this.#redrawButton.disabled = true;
}
#updateDPR() {
document.documentElement.style.setProperty(
"--dpr",
window.devicePixelRatio || 1
);
}
#makeDebugCanvasFactory() {
const gfxStateComp = this.#gfxStateComp;
// Custom CanvasFactory that tracks temporary canvases created during
// rendering. When stepping, each temporary canvas is shown below the main
// page canvas to inspect intermediate compositing targets (masks, etc).
return 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 = gfxStateComp.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", () =>
gfxStateComp.scrollToSection(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);
}
};
}
#setupSplits() {
// Build the three SplitView instances that make up the debug view layout.
// Inner splits are created first so outer splits can wrap the new
// containers.
// Layout: splitHor(splitVer(splitHor(op-list, gfx-state), op-detail),
// canvas)
// Inner row split: op-list on the left, gfx-state on the right (hidden by
// default).
const opTopSplit = new SplitView(
document.getElementById("op-list-panel"),
document.getElementById("gfx-state-panel"),
{ direction: "row", minSize: 60 }
);
// Column split: op-list+gfx-state on top, op-detail on the bottom.
const instructionsSplit = new SplitView(
opTopSplit.element,
document.getElementById("op-detail-panel"),
{ direction: "column", minSize: 40 }
);
// Outer row split: instructions column on the left, canvas on the right.
const renderSplit = new SplitView(
instructionsSplit.element,
document.getElementById("canvas-panel"),
{ direction: "row", minSize: 100, onResize: () => this.#renderCanvas() }
);
const renderPanels = document.getElementById("render-panels");
renderPanels.replaceWith(renderSplit.element);
renderSplit.element.id = "render-panels";
}
#setupEventListeners() {
this.#zoomInButton.addEventListener("click", () =>
this.#zoomRenderCanvas(
Math.min(
MAX_ZOOM,
(this.#renderScale ?? this.#getFitScale()) * ZOOM_STEP
)
)
);
this.#zoomOutButton.addEventListener("click", () =>
this.#zoomRenderCanvas(
Math.max(
MIN_ZOOM,
(this.#renderScale ?? this.#getFitScale()) / ZOOM_STEP
)
)
);
this.#redrawButton.addEventListener("click", async () => {
if (!this.#renderedPage || !this.#currentOpList) {
return;
}
this.#clearPausedState();
// Reset recorded bboxes so they get re-recorded for the modified op
// list.
this.#renderedPage.recordedBBoxes = null;
if (this.#opsView.breakpoints.size > 0) {
globalThis.StepperManager._active = new ViewerStepper(i =>
this.#onStepped(i)
);
}
await this.#renderCanvas();
});
this.#stepButton.addEventListener("click", () => {
globalThis.StepperManager._active?.stepNext();
});
this.#continueButton.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();
}
});
}
#onStepped(i) {
this.#opsView.markPaused(i);
this.#stepButton.disabled = this.#continueButton.disabled = false;
this.#gfxStateComp.build();
}
#clearPausedState() {
this.#opsView.clearPaused();
globalThis.StepperManager._active = null;
this.#stepButton.disabled = this.#continueButton.disabled = true;
this.#gfxStateComp.hide();
}
#getFitScale() {
return (
(this.#canvasScrollEl.clientWidth - 24) /
this.#renderedPage.getViewport({ scale: 1 }).width
);
}
#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 stepper = globalThis.StepperManager._active;
let resumeAt = null;
if (stepper !== null) {
resumeAt =
stepper.currentIdx >= 0 ? stepper.currentIdx : stepper.nextBreakPoint;
}
this.#clearPausedState();
this.#renderScale = newScale;
if (resumeAt !== null) {
globalThis.StepperManager._active = new ViewerStepper(
i => this.#onStepped(i),
resumeAt
);
}
return this.#renderCanvas();
}
async #renderCanvas() {
if (!this.#renderedPage) {
return null;
}
// Cancel any in-progress render before starting a new one.
this.#currentRenderTask?.cancel();
this.#currentRenderTask = null;
const highlight = this.#highlightCanvas;
const dpr = window.devicePixelRatio || 1;
const scale = this.#renderScale ?? this.#getFitScale();
this.#zoomLevelEl.textContent = `${Math.round(scale * 100)}%`;
this.#zoomOutButton.disabled = scale <= MIN_ZOOM;
this.#zoomInButton.disabled = scale >= MAX_ZOOM;
const viewport = this.#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", () =>
this.#gfxStateComp.scrollToSection("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).
this.#pdfDoc?.canvasFactory.showAll();
} else {
// Starting a fresh non-stepping render: remove leftover temp canvases.
this.#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 = !this.#renderedPage.recordedBBoxes;
const renderTask = this.#renderedPage.render({
canvasContext: this.#gfxStateComp.wrapCanvasGetContext(newCanvas, "Page"),
viewport,
recordOperations: firstRender,
});
this.#currentRenderTask = renderTask;
try {
await renderTask.promise;
} catch (err) {
if (err?.name === "RenderingCancelledException") {
return null;
}
throw err;
} finally {
if (this.#currentRenderTask === renderTask) {
this.#currentRenderTask = null;
}
}
// Render completed fully — stepping session is over.
this.#clearPausedState();
this.#pdfDoc?.canvasFactory.clear();
this.#redrawButton.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;
}
#drawHighlight(opIdx) {
const bboxes = this.#renderedPage?.recordedBBoxes;
if (!bboxes || opIdx >= bboxes.length || bboxes.isEmpty(opIdx)) {
this.#clearHighlight();
return;
}
const canvas = document.getElementById("render-canvas");
const highlight = this.#highlightCanvas;
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();
}
#clearHighlight() {
this.#highlightCanvas
.getContext("2d")
.clearRect(
0,
0,
this.#highlightCanvas.width,
this.#highlightCanvas.height
);
}
async #showRenderView(pageNum) {
const generation = this.#debugViewGeneration;
const opListEl = document.getElementById("op-list");
const spinner = document.createElement("div");
spinner.role = "status";
spinner.textContent = "Loading…";
opListEl.replaceChildren(spinner);
document.getElementById("op-detail-panel").replaceChildren();
this.#renderScale = null;
this.#onMarkLoading(1);
try {
this.#renderedPage = await this.#pdfDoc.getPage(pageNum);
if (this.#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 this.#renderCanvas();
if (this.#debugViewGeneration !== generation) {
return;
}
this.#currentOpList =
renderTask?.getOperatorList?.() ??
(await this.#renderedPage.getOperatorList());
if (this.#debugViewGeneration !== generation) {
return;
}
this.#opsView.load(this.#currentOpList, this.#renderedPage);
} catch (err) {
const errEl = document.createElement("div");
errEl.role = "alert";
errEl.textContent = `Error: ${err.message}`;
opListEl.replaceChildren(errEl);
} finally {
this.#onMarkLoading(-1);
}
}
}
export { PageView };

View File

@ -0,0 +1,71 @@
/* 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.
*/
/* Hide the resizer automatically when the adjacent panel is not visible. */
.spc-container > .spc-resizer:has(+ [hidden]),
.spc-container > [hidden] + .spc-resizer {
display: none;
}
.spc-container {
display: flex;
overflow: hidden;
> * {
min-width: 0;
min-height: 0;
}
> .spc-resizer {
flex-shrink: 0;
background: var(--spc-resizer-color, #ccc);
&:hover,
&.dragging {
background: var(--spc-resizer-hover-color, #888);
}
}
&.spc-row {
flex-direction: row;
> .spc-resizer {
width: 6px;
cursor: col-resize;
align-self: stretch;
}
}
&.spc-column {
flex-direction: column;
> .spc-resizer {
height: 6px;
cursor: row-resize;
align-self: stretch;
}
}
}
@media (forced-colors: active) {
.spc-container > .spc-resizer {
background: ButtonBorder;
&:hover,
&.dragging {
background: Highlight;
}
}
}

214
web/internal/split_view.js Normal file
View File

@ -0,0 +1,214 @@
/* 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.
*/
/**
* Wraps two elements with a drag-to-resize handle between them.
*
* @param {HTMLElement} firstEl
* @param {HTMLElement} secondEl
* @param {object} [options]
* @param {"row"|"column"} [options.direction="row"] Layout axis.
* @param {number} [options.minSize=40] Min px for each panel.
* @param {Function} [options.onResize] Called after each resize.
*/
class SplitView {
#container;
#resizer;
#isRow;
#minSize;
#onResize;
#onPointerDown = null;
#onKeyDown = null;
constructor(
firstEl,
secondEl,
{ direction = "row", minSize = 40, onResize } = {}
) {
this.#isRow = direction === "row";
this.#minSize = minSize;
this.#onResize = onResize;
const resizer = (this.#resizer = document.createElement("div"));
resizer.className = "spc-resizer";
resizer.role = "separator";
resizer.tabIndex = 0;
resizer.ariaOrientation = this.#isRow ? "vertical" : "horizontal";
resizer.ariaValueMin = 0;
resizer.ariaValueMax = 100;
resizer.ariaValueNow = 50;
this.#container = document.createElement("div");
this.#container.className = `spc-container spc-${direction}`;
this.#container.append(firstEl, resizer, secondEl);
this.#setupResizer();
}
get element() {
return this.#container;
}
destroy() {
if (this.#onPointerDown) {
this.#resizer.removeEventListener("pointerdown", this.#onPointerDown);
this.#onPointerDown = null;
}
if (this.#onKeyDown) {
this.#resizer.removeEventListener("keydown", this.#onKeyDown);
this.#onKeyDown = null;
}
}
// Always read the live first/last child so callers can swap panels in-place.
get #first() {
return this.#container.firstElementChild;
}
get #second() {
return this.#container.lastElementChild;
}
#dimension() {
return this.#isRow ? "width" : "height";
}
#updateAria(containerSize, resizerSize) {
const total = containerSize - resizerSize;
if (total <= 0) {
return;
}
const firstSize = this.#first.getBoundingClientRect()[this.#dimension()];
this.#resizer.ariaValueNow = Math.round((firstSize / total) * 100);
}
#clampFirstSize(total, requestedFirst) {
if (total <= 0) {
return 0;
}
if (total <= this.#minSize * 2) {
return Math.min(total, Math.max(0, requestedFirst));
}
return Math.max(
this.#minSize,
Math.min(total - this.#minSize, requestedFirst)
);
}
#resize(newFirst) {
const dimension = this.#dimension();
const containerSize = this.#container.getBoundingClientRect()[dimension];
const resizerSize = this.#resizer.getBoundingClientRect()[dimension];
this.#resizeWithMetrics(newFirst, containerSize, resizerSize);
}
#resizeWithMetrics(newFirst, containerSize, resizerSize) {
const total = containerSize - resizerSize;
const clamped = this.#clampFirstSize(total, newFirst);
this.#first.style.flexGrow = clamped;
this.#second.style.flexGrow = total - clamped;
this.#updateAria(containerSize, resizerSize);
}
#setupResizer() {
const axis = this.#isRow ? "clientX" : "clientY";
const cursor = this.#isRow ? "col-resize" : "row-resize";
this.#onPointerDown = e => {
if (e.button !== 0) {
return;
}
e.preventDefault();
const dimension = this.#dimension();
const containerSize = this.#container.getBoundingClientRect()[dimension];
const resizerSize = this.#resizer.getBoundingClientRect()[dimension];
const startPos = e[axis];
const startFirst = this.#first.getBoundingClientRect()[dimension];
this.#resizer.classList.add("dragging");
document.body.style.cursor = cursor;
const ac = new AbortController();
const { signal } = ac;
const cancelDrag = () => {
ac.abort();
this.#resizer.classList.remove("dragging");
document.body.style.cursor = "";
};
window.addEventListener(
"pointermove",
ev => {
this.#resizeWithMetrics(
startFirst + ev[axis] - startPos,
containerSize,
resizerSize
);
},
{ signal }
);
window.addEventListener(
"pointerup",
() => {
cancelDrag();
this.#updateAria(
containerSize,
this.#resizer.getBoundingClientRect()[dimension]
);
this.#onResize?.();
},
{ signal }
);
window.addEventListener("blur", cancelDrag, { signal });
};
this.#resizer.addEventListener("pointerdown", this.#onPointerDown);
this.#onKeyDown = e => {
let delta = 0;
if (
(this.#isRow && e.key === "ArrowLeft") ||
(!this.#isRow && e.key === "ArrowUp")
) {
delta = -(e.shiftKey ? 50 : 10);
} else if (
(this.#isRow && e.key === "ArrowRight") ||
(!this.#isRow && e.key === "ArrowDown")
) {
delta = e.shiftKey ? 50 : 10;
} else {
return;
}
e.preventDefault();
const dimension = this.#dimension();
const inlineCurrent = parseFloat(this.#first.style.flexGrow);
const currentFirst = isNaN(inlineCurrent)
? this.#first.getBoundingClientRect()[dimension]
: inlineCurrent;
this.#resize(currentFirst + delta);
this.#onResize?.();
};
this.#resizer.addEventListener("keydown", this.#onKeyDown);
}
}
export { SplitView };

185
web/internal/tree_view.css Normal file
View File

@ -0,0 +1,185 @@
/* 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.
*/
#tree.loading {
pointer-events: none;
}
#tree {
padding: 8px 12px;
background: var(--surface-bg);
border-radius: 4px;
border: 1px solid var(--border-color);
min-height: 60px;
.node {
display: block;
padding: 1px 0;
}
.key {
color: var(--accent-color);
}
.separator {
color: var(--muted-color);
}
[role="button"] {
display: inline-block;
width: 14px;
font-size: 0.7em;
color: var(--muted-color);
cursor: pointer;
user-select: none;
vertical-align: middle;
}
[role="group"] {
padding-left: 20px;
border-left: 1px dashed var(--border-subtle-color);
margin-left: 2px;
&.hidden {
display: none;
}
}
.ref {
color: var(--ref-color);
cursor: pointer;
text-decoration: underline dotted;
&:hover {
color: var(--ref-hover-color);
}
}
.str-value {
color: var(--string-color);
}
.num-value {
color: var(--number-color);
}
.bool-value {
color: var(--bool-color);
}
.null-value {
color: var(--null-color);
}
.name-value {
color: var(--name-color);
}
.bracket {
color: var(--muted-color);
cursor: pointer;
user-select: none;
&:hover {
color: light-dark(#444, #bbb);
}
}
.stream-label {
color: var(--stream-color);
font-style: italic;
}
[role="status"] {
color: var(--muted-color);
font-style: italic;
}
[role="alert"] {
color: var(--changed-color);
}
.bytes-content {
padding-left: 20px;
white-space: pre-wrap;
font-size: 1em;
opacity: 0.85;
color: var(--string-color);
}
.bytes-hex {
font-family: monospace;
color: var(--bool-color);
}
.image-preview {
display: block;
margin-top: 4px;
max-width: 40%;
height: auto;
image-rendering: pixelated;
border: 1px solid var(--border-subtle-color);
}
.token-cmd {
color: var(--accent-color);
font-weight: bold;
}
.token-num {
color: var(--number-color);
}
.token-str {
color: var(--string-color);
}
.token-name {
color: var(--name-color);
}
.token-bool {
color: var(--bool-color);
}
.token-null {
color: var(--null-color);
}
.token-ref {
color: var(--ref-color);
}
.token-array,
.token-dict {
color: var(--text-color);
}
/* Cap height when a MultilineView is embedded in the tree. */
.mlc-scroll {
max-height: 60vh;
}
}
/* Content-stream line styles. */
.content-stm-instruction {
display: block;
white-space: nowrap;
padding-inline-start: 0.5em;
}
.raw-bytes-stream {
color: var(--string-color);
}

846
web/internal/tree_view.js Normal file
View File

@ -0,0 +1,846 @@
/* 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 { MultilineView } from "./multiline_view.js";
const ARROW_COLLAPSED = "▶";
const ARROW_EXPANDED = "▼";
// Matches indirect object references such as "10 0 R".
const REF_RE = /^\d+ \d+ R$/;
/**
* Renders and manages the PDF internal structure tree.
*
* @param {HTMLElement} treeEl
* @param {object} options
* @param {Function} options.onMarkLoading Called with +1/-1 to track
* in-flight requests.
*/
class TreeView {
#treeEl;
#onMarkLoading;
// Cache for getRawData results, keyed by "num:gen". Cleared on each new
// document.
#refCache = new Map();
constructor(treeEl, { onMarkLoading }) {
this.#treeEl = treeEl;
this.#onMarkLoading = onMarkLoading;
this.#setupKeyboardNav();
}
// --- Public API ---
/**
* Fetch and render a tree for the given ref/page from doc.
* @param {{ ref?: object, page?: number }} data
* @param {string|null} rootLabel
* @param {PDFDocumentProxy} doc
*/
async load(data, rootLabel, doc) {
this.#treeEl.classList.add("loading");
this.#onMarkLoading(1);
try {
const rootNode = this.#renderNode(
rootLabel,
await doc.getRawData(data),
doc
);
this.#treeEl.replaceChildren(rootNode);
rootNode.querySelector("[role='button']")?.click();
const firstTreeItem = this.#treeEl.querySelector("[role='treeitem']");
if (firstTreeItem) {
firstTreeItem.tabIndex = 0;
}
} finally {
this.#treeEl.classList.remove("loading");
this.#onMarkLoading(-1);
}
}
/** Append a role=alert error node to the tree element. */
showError(message) {
this.#treeEl.append(this.#makeErrorNode(message));
}
/** Clear the ref cache (call when a new document is opened). */
clearCache() {
this.#refCache.clear();
}
// --- Private helpers ---
#moveFocus(from, to) {
if (!to) {
return;
}
if (from) {
from.tabIndex = -1;
}
to.tabIndex = 0;
to.focus();
}
#getVisibleItems() {
return Array.from(
this.#treeEl.querySelectorAll("[role='treeitem']")
).filter(item => {
let el = item.parentElement;
while (el && el !== this.#treeEl) {
if (el.role === "group" && el.classList.contains("hidden")) {
return false;
}
el = el.parentElement;
}
return true;
});
}
#makeErrorNode(message) {
const el = document.createElement("div");
el.role = "alert";
el.textContent = `Error: ${message}`;
return el;
}
#setupKeyboardNav() {
this.#treeEl.addEventListener("keydown", e => {
const { key } = e;
if (
key !== "ArrowDown" &&
key !== "ArrowUp" &&
key !== "ArrowRight" &&
key !== "ArrowLeft" &&
key !== "Home" &&
key !== "End"
) {
return;
}
e.preventDefault();
const focused =
document.activeElement instanceof HTMLElement &&
this.#treeEl.contains(document.activeElement)
? document.activeElement
: null;
// ArrowRight/Left operate on the focused treeitem directly without
// needing a full list of visible items.
if (key === "ArrowRight" || key === "ArrowLeft") {
if (!focused || focused.role !== "treeitem") {
return;
}
if (key === "ArrowRight") {
// Find the toggle button inside this treeitem (not inside a child
// group).
const toggle = focused.querySelector(":scope > [role='button']");
if (!toggle) {
return;
}
if (toggle.ariaExpanded === "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']");
this.#moveFocus(focused, firstChild);
}
} else {
// Collapsed or no children — move to parent treeitem.
const toggle = focused.querySelector(":scope > [role='button']");
if (toggle?.ariaExpanded === "true") {
toggle.click();
} else {
const parentGroup = focused.closest("[role='group']");
const parentItem = parentGroup?.closest("[role='treeitem']");
this.#moveFocus(focused, parentItem);
}
}
return;
}
// ArrowDown/Up/Home/End need the full ordered list of visible treeitems.
const visibleItems = this.#getVisibleItems();
if (visibleItems.length === 0) {
return;
}
const idx = visibleItems.indexOf(focused);
if (key === "ArrowDown") {
const next = visibleItems[idx >= 0 ? idx + 1 : 0];
this.#moveFocus(focused, next);
} else if (key === "ArrowUp") {
const prev = idx >= 0 ? visibleItems[idx - 1] : visibleItems.at(-1);
this.#moveFocus(focused, prev);
} else if (key === "Home") {
const first = visibleItems[0];
if (first !== focused) {
this.#moveFocus(focused, first);
}
} else if (key === "End") {
const last = visibleItems.at(-1);
if (last !== focused) {
this.#moveFocus(focused, last);
}
}
});
}
/** Create a bare div.node treeitem with an optional "key: " prefix. */
#makeNodeEl(key) {
const node = document.createElement("div");
node.className = "node";
node.role = "treeitem";
node.tabIndex = -1;
if (key !== null) {
node.append(
this.#makeSpan("key", key),
this.#makeSpan("separator", ": ")
);
}
return node;
}
/**
* Render one key/value pair as a <div class="node">.
* @param {string|null} key Dict key, array index, or null for root.
* @param {*} value
* @param {PDFDocumentProxy} doc
*/
#renderNode(key, value, doc) {
const node = this.#makeNodeEl(key);
node.append(this.#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).
*/
#buildChildren(value, doc, container) {
if (this.#isStream(value)) {
for (const [k, v] of Object.entries(value.dict)) {
container.append(this.#renderNode(k, v, doc));
}
if (this.#isImageStream(value)) {
container.append(this.#renderImageData(value.imageData));
} else if (this.#isFormXObjectStream(value)) {
const contentNode = this.#makeNodeEl("content");
const csLabel = `[Content Stream, ${value.instructions.length} instructions]`;
const csLabelEl = this.#makeSpan("stream-label", csLabel);
contentNode.append(
this.#makeExpandable(csLabelEl, csLabel, c =>
this.#buildContentStreamPanel(value, c, csLabelEl)
)
);
container.append(contentNode);
} else {
const byteNode = this.#makeNodeEl("bytes");
byteNode.append(
this.#makeSpan("stream-label", `<${value.bytes.length} raw bytes>`)
);
container.append(byteNode);
const bytesContentEl = document.createElement("div");
bytesContentEl.className = "bytes-content";
bytesContentEl.append(this.#formatBytes(value.bytes));
container.append(bytesContentEl);
}
} else if (Array.isArray(value)) {
value.forEach((v, i) =>
container.append(this.#renderNode(String(i), v, doc))
);
} else if (value !== null && typeof value === "object") {
for (const [k, v] of Object.entries(value)) {
container.append(this.#renderNode(k, v, doc));
}
} else {
container.append(this.#renderNode(null, value, doc));
}
}
/**
* Render a single content-stream token as a styled span.
*/
#renderToken(token) {
if (!token) {
return this.#makeSpan("token-null", "null");
}
switch (token.type) {
case "cmd":
return this.#makeSpan("token-cmd", token.value);
case "name":
return this.#makeSpan("token-name", "/" + token.value);
case "ref":
return this.#makeSpan("token-ref", `${token.num} ${token.gen} R`);
case "number":
return this.#makeSpan("token-num", String(token.value));
case "string":
return this.#makeSpan("token-str", JSON.stringify(token.value));
case "boolean":
return this.#makeSpan("token-bool", String(token.value));
case "null":
return this.#makeSpan("token-null", "null");
case "array": {
const span = document.createElement("span");
span.className = "token-array";
span.append(this.#makeSpan("bracket", "["));
for (const item of token.value) {
span.append(document.createTextNode(" "));
span.append(this.#renderToken(item));
}
span.append(document.createTextNode(" "));
span.append(this.#makeSpan("bracket", "]"));
return span;
}
case "dict": {
const span = document.createElement("span");
span.className = "token-dict";
span.append(this.#makeSpan("bracket", "<<"));
for (const [k, v] of Object.entries(token.value)) {
span.append(document.createTextNode(" "));
span.append(this.#makeSpan("token-name", `/${k}`));
span.append(document.createTextNode(" "));
span.append(this.#renderToken(v));
}
span.append(document.createTextNode(" "));
span.append(this.#makeSpan("bracket", ">>"));
return span;
}
default:
return this.#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.
*/
#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(t => this.#tokenToText(t)).join(" ")} ]`;
case "dict": {
const inner = Object.entries(token.value)
.map(([k, v]) => `/${k} ${this.#tokenToText(v)}`)
.join(" ");
return `<< ${inner} >>`;
}
default:
return String(token.value ?? token.type);
}
}
#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(t => this.#tokenToText(t));
if (instr.cmd !== null) {
parts.push(instr.cmd);
}
return parts.join(" ");
});
const mc = new MultilineView({
total,
lineClass: "content-stream",
getText: i => instrTexts[i],
actions,
makeLineEl: (i, isHighlighted) => {
const line = document.createElement("div");
line.className = "content-stm-instruction";
if (isHighlighted) {
line.classList.add("mlc-match");
}
// 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`;
}
const instr = instructions[i];
for (const arg of instr.args) {
content.append(this.#renderToken(arg));
content.append(document.createTextNode(" "));
}
if (instr.cmd !== null) {
const cmdEl = this.#makeSpan("token-cmd", instr.cmd);
const opsName = cmdNames[instr.cmd];
if (opsName) {
cmdEl.title = opsName;
}
content.append(cmdEl);
}
line.append(content);
return line;
},
});
container.append(mc.element);
return mc;
}
// Fills container with a raw-bytes virtual-scroll panel.
#buildRawBytesPanel(rawBytes, container, actions = null) {
const lines = rawBytes.split(/\r?\n|\r/);
if (lines.at(-1) === "") {
lines.pop();
}
const mc = new MultilineView({
total: lines.length,
lineClass: "content-stream raw-bytes-stream",
getText: i => lines[i],
actions,
makeLineEl: (i, isHighlighted) => {
const el = document.createElement("div");
el.className = "content-stm-instruction";
if (isHighlighted) {
el.classList.add("mlc-match");
}
el.append(this.#formatBytes(lines[i]));
return el;
},
});
container.append(mc.element);
return mc;
}
// Creates a "Parsed" toggle button. aria-pressed=true means the parsed view
// is currently active; clicking switches to the other view.
#makeParseToggleBtn(isParsed, onToggle) {
const btn = document.createElement("button");
btn.className = "mlc-nav-button";
btn.textContent = "Parsed";
btn.ariaPressed = 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.
#buildContentStreamPanel(val, container, labelEl = null) {
let isParsed = true;
let currentPanel = null;
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]`;
const rebuild = () => {
currentPanel?.destroy();
currentPanel = null;
container.replaceChildren();
if (labelEl) {
labelEl.textContent = isParsed ? parsedLabel : rawLabel;
}
const btn = this.#makeParseToggleBtn(isParsed, () => {
isParsed = !isParsed;
rebuild();
});
currentPanel = isParsed
? this.#buildInstructionLines(val, container, btn)
: this.#buildRawBytesPanel(rawBytes, container, btn);
};
rebuild();
}
/**
* Render Page content stream as an expandable panel with a Parsed/Raw toggle.
*/
#renderContentStream(val) {
const label = `[Content Stream, ${val.instructions.length} instructions]`;
const labelEl = this.#makeSpan("stream-label", label);
return this.#makeExpandable(labelEl, label, container =>
this.#buildContentStreamPanel(val, container, labelEl)
);
}
/**
* Render a value inline (primitive) or as an expandable widget.
* Returns a Node or DocumentFragment suitable for appendChild().
*/
#renderValue(value, doc) {
// Ref string ("10 0 R") lazy expandable via getRawData()
if (typeof value === "string" && REF_RE.test(value)) {
return this.#renderRef(value, doc);
}
// Ref object { num, gen } lazy expandable via getRawData()
if (this.#isRefObject(value)) {
return this.#renderRef(value, doc);
}
// PDF Name → /Name
if (this.#isPDFName(value)) {
return this.#makeSpan("name-value", `/${value.name}`);
}
// Content stream (Page Contents) → expandable with Parsed/Raw toggle
if (this.#isContentStream(value)) {
return this.#renderContentStream(value);
}
// Stream → expandable showing dict entries + byte count or image preview
if (this.#isStream(value)) {
return this.#renderExpandable("[Stream]", "stream-label", container =>
this.#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 this.#makeSpan("bracket", "{}");
}
return this.#renderExpandable(`{${keys.length}}`, "bracket", container =>
this.#buildChildren(value, doc, container)
);
}
// Array
if (Array.isArray(value)) {
if (value.length === 0) {
return this.#makeSpan("bracket", "[]");
}
return this.#renderExpandable(`[${value.length}]`, "bracket", container =>
this.#buildChildren(value, doc, container)
);
}
// Primitives
if (typeof value === "string") {
return this.#makeSpan("str-value", JSON.stringify(value));
}
if (typeof value === "number") {
return this.#makeSpan("num-value", String(value));
}
if (typeof value === "boolean") {
return this.#makeSpan("bool-value", String(value));
}
return this.#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".
*/
#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 = this.#refLabel(ref);
}
return this.#makeExpandable(
this.#makeSpan("ref", label),
`reference ${label}`,
childrenEl => {
const spinner = document.createElement("div");
spinner.role = "status";
spinner.textContent = "Loading…";
childrenEl.append(spinner);
this.#onMarkLoading(1);
if (!this.#refCache.has(cacheKey)) {
this.#refCache.set(cacheKey, doc.getRawData({ ref }));
}
this.#refCache
.get(cacheKey)
.then(result => {
childrenEl.replaceChildren();
this.#buildChildren(result, doc, childrenEl);
})
.catch(err =>
childrenEl.replaceChildren(this.#makeErrorNode(err.message))
)
.finally(() => this.#onMarkLoading(-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).
*/
#makeExpandable(labelEl, ariaLabel, onFirstOpen) {
const toggleEl = document.createElement("span");
toggleEl.textContent = ARROW_COLLAPSED;
toggleEl.role = "button";
toggleEl.tabIndex = 0;
toggleEl.ariaExpanded = "false";
toggleEl.ariaLabel = `Expand ${ariaLabel}`;
labelEl.ariaHidden = "true";
const childrenEl = document.createElement("div");
childrenEl.className = "hidden";
childrenEl.role = "group";
childrenEl.ariaLabel = `Contents of ${ariaLabel}`;
let open = false,
done = false;
const toggle = () => {
open = !open;
toggleEl.textContent = open ? ARROW_EXPANDED : ARROW_COLLAPSED;
toggleEl.ariaExpanded = 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.
*/
#renderExpandable(label, labelClass, buildFn) {
return this.#makeExpandable(
this.#makeSpan(labelClass, label),
label,
buildFn
);
}
/**
* Render image data (RGBA Uint8ClampedArray) into a <canvas> node.
*/
#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.ariaLabel = `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;
}
#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;
}
#formatBytes(str) {
const mostlyText = this.#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 <span> with the given class and text content.
#makeSpan(className, text) {
const span = document.createElement("span");
span.className = className;
span.textContent = text;
return span;
}
#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.
#isRefObject(val) {
return (
val !== null &&
typeof val === "object" &&
!Array.isArray(val) &&
typeof val.num === "number" &&
typeof val.gen === "number" &&
Object.keys(val).length === 2
);
}
#refLabel(ref) {
return ref.gen !== 0 ? `${ref.num}R${ref.gen}` : `${ref.num}R`;
}
// Page content streams:
// { contentStream: true, instructions, cmdNames, rawContents }.
#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).
#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)
);
}
#isImageStream(val) {
return (
this.#isStream(val) &&
Object.prototype.hasOwnProperty.call(val, "imageData")
);
}
#isFormXObjectStream(val) {
return this.#isStream(val) && val.contentStream === true;
}
}
export { TreeView };

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff