Merge pull request #20871 from calixteman/refactor_debugger

Split the new debugger into multiple files
This commit is contained in:
calixteman 2026-03-15 14:29:13 +01:00 committed by GitHub
commit a3a205d69b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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