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

View File

@@ -0,0 +1,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,509 @@
/* 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";
/**
* 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();
// When true, suppress live DOM updates; build() re-reads state and resets it.
#frozen = false;
constructor(panelEl) {
this.#panel = panelEl;
}
/** Suppress live DOM updates until the next build() call. */
freeze() {
this.#frozen = true;
}
/**
* 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 and we're at a pause point, rebuild it
// so the new context section is added and its live-update entries are
// registered. Skip the rebuild while frozen (execution is in progress
// between pauses) — the next build() call from #onStepped() will pick it
// up.
if (!this.#frozen && 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;
}
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.#frozen = false;
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.title = "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.title = "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);
}
}
/**
* Remove the section for a single context from the panel and all internal
* state maps. Called when a temporary canvas is destroyed.
*/
removeContext(label) {
this.#ctxStates.delete(label);
this.#ctxStateStacks.delete(label);
this.#ctxStackViewIdx.delete(label);
this.#gfxStateValueElements.delete(label);
this.#gfxStateNavElements.delete(label);
this.#panel
.querySelector(
`.gfx-state-section[data-ctx-label="${CSS.escape(label)}"]`
)
?.remove();
}
/** Hide the panel and discard all DOM state so no live updates occur. */
hide() {
this.#panel.hidden = true;
this.#gfxStateValueElements.clear();
this.#gfxStateNavElements.clear();
this.#panel.replaceChildren();
}
/**
* 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 frozen (continuous execution)
// or when the user is browsing a saved state.
#updatePropEl(label, prop, value) {
if (this.#frozen || 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) {
if (this.#frozen) {
return;
}
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 };

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

@@ -0,0 +1,310 @@
/* 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(../text_layer_builder.css);
@import url(canvas_context_details_view.css);
@import url(draw_ops_view.css);
@import url(font_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);
--accent-fg: light-dark(white, #1e1e1e);
/* 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;
}
#tree-button,
#debug-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);
}
&:disabled {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
}

137
web/internal/debugger.html Normal file
View File

@@ -0,0 +1,137 @@
<!doctype html>
<!--
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.
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PDF.js — Debugging tools</title>
<link rel="stylesheet" href="debugger.css" />
</head>
<body>
<div id="header">
<h1>PDF.js — Debugging tools</h1>
<span id="pdf-info" aria-live="polite"></span>
</div>
<div id="controls" role="toolbar" aria-label="PDF viewer controls">
<label for="file-input">PDF file:</label>
<input type="file" id="file-input" accept=".pdf" />
<label for="goto-input">Go to:</label>
<input type="text" id="goto-input" placeholder="num, numR, numRgen" size="18" disabled aria-describedby="goto-input-hint" aria-invalid="false" />
<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="tree-button">Tree</button>
<button id="debug-button" disabled>Debug</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">
<path
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"
/>
</svg>
</a>
</div>
<div id="tree" role="tree" aria-label="PDF internal structure"></div>
<div id="debug-view" hidden>
<div id="render-panels">
<div id="op-left-col">
<div id="op-list-panel">
<div id="op-list" role="listbox" aria-label="Operator list"></div>
</div>
<div id="op-detail-panel"></div>
<div id="gfx-state-panel" aria-label="Graphics state" hidden></div>
</div>
<div id="font-panel" hidden></div>
<div id="canvas-panel">
<div id="canvas-toolbar" role="toolbar" aria-label="Zoom controls">
<div class="toolbar-left">
<button id="text-filter-button" title="Show only text drawing operations" aria-pressed="false">T</button>
<button id="text-layer-color-button" title="Text layer color">
<span id="text-layer-color-swatch"></span>
</button>
<input type="color" id="text-layer-color-input" hidden />
<button id="text-span-border-button" title="Show span borders" aria-pressed="false">
<svg
width="18"
height="14"
viewBox="0 0 18 14"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-dasharray="2 1.5"
aria-hidden="true"
>
<rect x="0.75" y="0.75" width="16.5" height="12.5" />
</svg>
</button>
<button id="font-view-button" title="Show fonts used on page" aria-pressed="false">F</button>
</div>
<div class="toolbar-center">
<button id="zoom-out-button" title="Zoom out"></button>
<span id="zoom-level" aria-live="polite"></span>
<button id="zoom-in-button" title="Zoom in">+</button>
</div>
<div class="toolbar-right">
<button id="redraw-button" title="Redraw page">Redraw</button>
<button id="step-button" title="Step one instruction" disabled><u>S</u>tep</button>
<button id="continue-button" title="Continue to next breakpoint" disabled><u>C</u>ontinue</button>
</div>
</div>
<div id="canvas-scroll">
<div id="canvas-wrapper">
<div class="canvas-checker">
<canvas id="render-canvas"></canvas>
</div>
<canvas id="highlight-canvas" aria-hidden="true"></canvas>
</div>
</div>
</div>
</div>
</div>
<dialog id="password-dialog" aria-labelledby="password-dialog-title">
<form method="dialog">
<p id="password-dialog-title"></p>
<label for="password-input">Password:</label>
<input type="password" id="password-input" autocomplete="current-password" />
<div class="password-dialog-buttons">
<button type="submit" id="password-submit">OK</button>
<button type="button" id="password-cancel">Cancel</button>
</div>
</form>
</dialog>
<!--#if GENERIC-->
<!--<script src="../build/pdf.mjs" type="module"></script>-->
<!--<script src="debugger.mjs" type="module"></script>-->
<!--#else-->
<script type="importmap">
{
"imports": {
"pdfjs/": "../../src/",
"pdfjs-lib": "../../src/pdf.js",
"display-binary_data_factory": "../../src/display/binary_data_factory.js",
"display-network_stream": "../../src/display/network_stream.js",
"display-node_utils": "../../src/display/stubs.js"
}
}
</script>
<script src="debugger.js" type="module"></script>
<!--#endif-->
</body>
</html>

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

@@ -0,0 +1,272 @@
/* 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 treeButton = document.getElementById("tree-button");
const debugButton = document.getElementById("debug-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.disabled = currentPage === null;
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.loadingTask.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,
fontExtraProperties: 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");
// Allow debugging via references, as well as page numbers.
if (result.page === undefined) {
try {
result.page =
pdfDoc.cachedPageNumber(result.ref) ??
(await pdfDoc.getPageIndex(result.ref)) + 1;
} catch {}
}
// If we're in debug view and navigating to a page, stay in debug view
// without switching to the tree at all.
if (!debugViewEl.hidden && result.page !== undefined) {
currentPage = result.page;
pageView.reset();
await pageView.show(pdfDoc, currentPage);
} else {
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 () => {
treeEl.hidden = true;
debugViewEl.hidden = false;
// Only render if not already loaded for this page; re-entering from the
// tree button keeps the existing debug state (op-list, canvas, breakpoints).
await pageView.show(pdfDoc, currentPage);
});
treeButton.addEventListener("click", () => {
debugViewEl.hidden = true;
treeEl.hidden = false;
});

View File

@@ -0,0 +1,222 @@
/* 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);
direction: ltr;
unicode-bidi: bidi-override;
}
.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.9em;
opacity: 0;
}
&:hover::before {
opacity: 0.4;
}
&[data-bp="pause"]::before {
opacity: 1;
}
&[data-bp="skip"]::before {
content: "✕";
opacity: 1;
}
}
.op-line.op-skipped > :not(.bp-gutter) {
opacity: 0.4;
}
.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[data-bp="pause"]::before {
color: ButtonText;
}
.bp-gutter[data-bp="skip"]::before {
color: ButtonText;
}
.op-line.op-skipped > :not(.bp-gutter) {
opacity: 1;
color: GrayText;
}
/* Color swatch preserves the actual PDF color value. */
.color-swatch {
forced-color-adjust: none;
}
}

View File

@@ -0,0 +1,764 @@
/* Copyright 2026 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ImageKind, OPS } from "pdfjs-lib";
import { makePathFromDrawOPS } from "pdfjs/display/display_utils.js";
import { MultilineView } from "./multiline_view.js";
// Reverse map: OPS numeric id → string name, built once from the OPS object.
const OPS_TO_NAME = Object.create(null);
for (const [name, id] of Object.entries(OPS)) {
OPS_TO_NAME[id] = name;
}
// Ops from the getTextContent() switch in evaluator.js — shown in the draw ops
// view when text-only filter is active, and used to determine step targets.
const TEXT_OP_IDS = new Set([
OPS.setFont,
OPS.setTextRise,
OPS.setHScale,
OPS.setLeading,
OPS.moveText,
OPS.setLeadingMoveText,
OPS.nextLine,
OPS.setTextMatrix,
OPS.setCharSpacing,
OPS.setWordSpacing,
OPS.beginText,
OPS.endText,
OPS.showSpacedText,
OPS.showText,
OPS.nextLineShowText,
OPS.nextLineSetSpacingShowText,
OPS.beginMarkedContent,
OPS.beginMarkedContentProps,
OPS.endMarkedContent,
]);
// Superset of TEXT_OP_IDS — all ops that must be executed (not skipped) during
// text-only rendering. The extra ops here are infrastructure (save/restore,
// transforms, XObject wrappers) that affect the graphics state but are not
// shown in the filtered op list.
const TEXT_EXEC_OP_IDS = new Set([
...TEXT_OP_IDS,
OPS.restore,
OPS.save,
OPS.dependency,
OPS.transform,
OPS.paintFormXObjectBegin,
OPS.paintFormXObjectEnd,
OPS.beginGroup,
OPS.endGroup,
OPS.setGState,
]);
const BreakpointType = {
PAUSE: 0,
SKIP: 1,
};
// Single hidden color input reused for all swatch pickers.
const colorPickerInput = document.createElement("input");
colorPickerInput.type = "color";
colorPickerInput.className = "color-picker-input";
// AbortController for the currently open color-picker session (if any).
let _colorPickerAc = null;
function ensureColorPickerInput() {
if (!colorPickerInput.isConnected) {
document.body.append(colorPickerInput);
}
}
function openColorPicker(hex, onPick) {
// Cancel any previous session that was dismissed without a change event.
_colorPickerAc?.abort();
ensureColorPickerInput();
colorPickerInput.value = hex;
const ac = new AbortController();
_colorPickerAc = ac;
colorPickerInput.addEventListener(
"input",
() => {
onPick(colorPickerInput.value);
},
{ signal: ac.signal }
);
colorPickerInput.addEventListener(
"change",
() => {
ac.abort();
},
{ once: true, signal: ac.signal }
);
colorPickerInput.click();
}
// Creates a color swatch. If `onPick` is provided the swatch is clickable and
// opens the browser color picker; onPick(newHex) is called on each change.
function makeColorSwatch(hex, onPick) {
const swatch = document.createElement("span");
swatch.className = "color-swatch";
swatch.style.background = hex;
if (onPick) {
swatch.role = "button";
swatch.tabIndex = 0;
swatch.ariaLabel = "Change color";
swatch.title = "Click to change color";
const activate = e => {
e.stopPropagation();
openColorPicker(hex, newHex => {
hex = newHex;
swatch.style.background = newHex;
onPick(newHex);
});
};
swatch.addEventListener("click", activate);
swatch.addEventListener("keydown", e => {
if (e.key !== "Enter" && e.key !== " ") {
return;
}
e.preventDefault();
activate(e);
});
}
return swatch;
}
// Formats a glyph items array as: "text" kerning "more text" …
function formatGlyphItems(items) {
const parts = [];
let str = "";
for (const item of items) {
if (typeof item === "number") {
if (str) {
parts.push(JSON.stringify(str));
str = "";
}
parts.push(String(Math.round(item * 100) / 100));
} else if (item?.unicode) {
str += item.unicode;
}
}
if (str) {
parts.push(JSON.stringify(str));
}
return parts.join(" ");
}
/**
* Format an operator argument for display.
* @param {*} arg The argument value.
* @param {boolean} full true → expand fully (detail panel);
* false → truncate for compact list display.
*/
function formatArg(arg, full) {
if (arg === null || arg === undefined) {
return full ? "null" : "";
}
if (typeof arg === "number") {
return Number.isInteger(arg)
? String(arg)
: String(Math.round(arg * 10000) / 10000);
}
if (typeof arg === "string") {
return JSON.stringify(arg);
}
if (typeof arg === "boolean") {
return String(arg);
}
if (ArrayBuffer.isView(arg)) {
if (!full && arg.length > 8) {
return `<${arg.length} values>`;
}
const fmt = n => (Number.isInteger(n) ? n : Math.round(n * 1000) / 1000);
return `[${Array.from(arg).map(fmt).join(" ")}]`;
}
if (Array.isArray(arg)) {
if (arg.length === 0) {
return "[]";
}
if (!full && arg.length > 4) {
return `[…${arg.length}]`;
}
return `[${arg.map(a => formatArg(a, full)).join(", ")}]`;
}
if (typeof arg === "object") {
if (!full) {
return "{…}";
}
return `{${Object.entries(arg)
.map(([k, v]) => `${k}: ${formatArg(v, true)}`)
.join(", ")}}`;
}
return String(arg);
}
class DrawOpDetailView {
#el;
#prefersDark;
constructor(detailPanelEl, { prefersDark }) {
this.#el = detailPanelEl;
this.#prefersDark = prefersDark;
}
show(
name,
args,
opIdx,
{ originalColors, renderedPage, selectedLine = null }
) {
const detailEl = this.#el;
detailEl.replaceChildren();
// Always build args into a .detail-args-col so it can be placed in a
// .detail-body alongside a path preview or image preview on the right.
const argsContainer = document.createElement("div");
argsContainer.className = "detail-args-col";
const header = document.createElement("div");
header.className = "detail-name";
header.textContent = name;
argsContainer.append(header);
if (!args || args.length === 0) {
const none = document.createElement("div");
none.className = "detail-empty";
none.textContent = "(no arguments)";
argsContainer.append(none);
detailEl.append(argsContainer);
return;
}
const imagePreviews = [];
for (let i = 0; i < args.length; i++) {
const row = document.createElement("div");
row.className = "detail-row";
const idx = document.createElement("span");
idx.className = "detail-idx";
idx.textContent = `[${i}]`;
const val = document.createElement("span");
val.className = "detail-val";
if (name === "showText" && i === 0 && Array.isArray(args[0])) {
val.textContent = formatGlyphItems(args[0]);
} else if (
name === "constructPath" &&
i === 0 &&
typeof args[0] === "number"
) {
val.textContent = OPS_TO_NAME[args[0]] ?? String(args[0]);
} else {
val.textContent = formatArg(args[i], true);
}
row.append(idx);
if (typeof args[i] === "string" && /^#[0-9a-f]{6}$/i.test(args[i])) {
const argIdx = i;
const originalHex = originalColors.get(opIdx);
if (originalHex && args[i] !== originalHex) {
val.classList.add("changed-value");
val.title = `Original: ${originalHex}`;
}
row.append(
makeColorSwatch(args[i], newHex => {
args[argIdx] = newHex;
val.textContent = JSON.stringify(newHex);
const changed = originalHex && newHex !== originalHex;
val.classList.toggle("changed-value", !!changed);
val.title = changed ? `Original: ${originalHex}` : "";
// Also update the swatch and arg span in the selected op list line.
const listSwatch = selectedLine?.querySelector(".color-swatch");
if (listSwatch) {
listSwatch.style.background = newHex;
}
const listArgSpan = selectedLine?.querySelector(".op-arg");
if (listArgSpan) {
listArgSpan.textContent = JSON.stringify(newHex);
listArgSpan.classList.toggle("changed-value", !!changed);
listArgSpan.title = changed ? `Original: ${originalHex}` : "";
}
})
);
}
row.append(val);
argsContainer.append(row);
if (typeof args[i] === "string" && args[i].startsWith("img_")) {
const preview = this.#makeImageArgPreview(args[i], renderedPage);
if (preview) {
imagePreviews.push(preview);
}
}
}
// Assemble the final layout: constructPath gets a path preview on the
// right; image ops get an image column on the right; others just use
// argsContainer.
if (name === "constructPath") {
// args[1] is [Float32Array|null], args[2] is [minX,minY,maxX,maxY]|null
const data = Array.isArray(args?.[1]) ? args[1][0] : null;
const body = document.createElement("div");
body.className = "detail-body";
body.append(
argsContainer,
this.#renderPathPreview(data, args?.[2] ?? null)
);
detailEl.append(body);
} else if (imagePreviews.length > 0) {
const imgCol = document.createElement("div");
imgCol.className = "detail-img-col";
imgCol.append(...imagePreviews);
const body = document.createElement("div");
body.className = "detail-body";
body.append(argsContainer, imgCol);
detailEl.append(body);
} else {
detailEl.append(argsContainer);
}
}
clear() {
this.#el.replaceChildren();
}
#renderPathPreview(data, minMax) {
const canvas = document.createElement("canvas");
canvas.className = "path-preview";
const [minX, minY, maxX, maxY] = minMax ?? [];
const pathW = maxX - minX || 1;
const pathH = maxY - minY || 1;
if (!data || !minMax || !(pathW > 0) || !(pathH > 0)) {
canvas.width = canvas.height = 1;
return canvas;
}
const PADDING = 10; // px
const dpr = window.devicePixelRatio || 1;
const drawW = Math.min(200, 200 * (pathW / pathH));
const drawH = Math.min(200, 200 * (pathH / pathW));
const scale = Math.min(drawW / pathW, drawH / pathH);
canvas.width = Math.round((drawW + PADDING * 2) * dpr);
canvas.height = Math.round((drawH + PADDING * 2) * dpr);
canvas.style.width = `${drawW + PADDING * 2}px`;
canvas.style.height = `${drawH + PADDING * 2}px`;
const ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr);
// PDF user space has Y pointing up; canvas has Y pointing down — flip Y.
ctx.translate(PADDING, PADDING + drawH);
ctx.scale(scale, -scale);
ctx.translate(-minX, -minY);
ctx.lineWidth = 1 / scale;
ctx.strokeStyle = this.#prefersDark.matches ? "#9cdcfe" : "#0070c1";
ctx.stroke(data instanceof Path2D ? data : makePathFromDrawOPS(data));
return canvas;
}
// Render an img_ argument value into a canvas preview using the decoded image
// stored in renderedPage.objs (or commonObjs for global images starting with
// g_). Handles ImageBitmap and raw pixel data with ImageKind values
// GRAYSCALE_1BPP, RGB_24BPP, and RGBA_32BPP.
#makeImageArgPreview(name, renderedPage) {
const objStore = name.startsWith("g_")
? renderedPage?.commonObjs
: renderedPage?.objs;
if (!objStore?.has(name)) {
return null;
}
const imgObj = objStore.get(name);
if (!imgObj) {
return null;
}
const { width, height } = imgObj;
const canvas = document.createElement("canvas");
canvas.className = "image-preview";
canvas.width = width;
canvas.height = height;
canvas.style.aspectRatio = `${width} / ${height}`;
canvas.ariaLabel = `${name} ${width}×${height}`;
const ctx = canvas.getContext("2d");
// Fast path: if the browser already decoded it as an ImageBitmap, draw it.
if (imgObj.bitmap instanceof ImageBitmap) {
ctx.drawImage(imgObj.bitmap, 0, 0);
return canvas;
}
// Slow path: convert raw pixel data to RGBA for putImageData.
const { data, kind } = imgObj;
let rgba;
if (kind === ImageKind.RGBA_32BPP) {
rgba = new Uint8ClampedArray(
data.buffer,
data.byteOffset,
data.byteLength
);
} else if (kind === ImageKind.RGB_24BPP) {
const pixels = width * height;
rgba = new Uint8ClampedArray(pixels * 4);
for (let i = 0, j = 0; i < pixels; i++, j += 3) {
rgba[i * 4] = data[j];
rgba[i * 4 + 1] = data[j + 1];
rgba[i * 4 + 2] = data[j + 2];
rgba[i * 4 + 3] = 255;
}
} else if (kind === ImageKind.GRAYSCALE_1BPP) {
const rowBytes = (width + 7) >> 3;
rgba = new Uint8ClampedArray(width * height * 4);
for (let row = 0; row < height; row++) {
const srcRow = row * rowBytes;
const dstRow = row * width * 4;
for (let col = 0; col < width; col++) {
const bit = (data[srcRow + (col >> 3)] >> (7 - (col & 7))) & 1;
const v = bit ? 255 : 0;
rgba[dstRow + col * 4] = v;
rgba[dstRow + col * 4 + 1] = v;
rgba[dstRow + col * 4 + 2] = v;
rgba[dstRow + col * 4 + 3] = 255;
}
}
} else {
return null;
}
ctx.putImageData(new ImageData(rgba, width, height), 0, 0);
return canvas;
}
}
class DrawOpsView {
#listPanelEl;
#detailView;
#multilineView = null;
// All op lines indexed by original op index.
#opLines = [];
// Plain-text representations for search, parallel to #opLines.
#opTexts = [];
// Currently visible lines (all lines, or text-only subset when filtering).
#visibleLines = [];
#textFilter = false;
#selectedLine = null;
// Map<opIndex, BreakpointType>
#breakpoints = new Map();
#originalColors = new Map();
#renderedPage = null;
#pausedAtIdx = null;
#onHighlight;
#onClearHighlight;
constructor(
opListPanelEl,
detailPanelEl,
{ onHighlight, onClearHighlight, prefersDark }
) {
this.#listPanelEl = opListPanelEl;
this.#detailView = new DrawOpDetailView(detailPanelEl, { prefersDark });
this.#onHighlight = onHighlight;
this.#onClearHighlight = onClearHighlight;
}
get breakpoints() {
return this.#breakpoints;
}
load(opList, renderedPage) {
this.#renderedPage = renderedPage;
this.#opLines = [];
this.#opTexts = [];
for (let i = 0; i < opList.fnArray.length; i++) {
const name = OPS_TO_NAME[opList.fnArray[i]] ?? `op${opList.fnArray[i]}`;
const args = opList.argsArray[i] ?? [];
const { line, text } = this.#buildLine(i, name, args);
this.#opLines.push(line);
this.#opTexts.push(text);
}
this.#rebuildMultilineView();
}
// Enable or disable the text-ops-only filter. Can be called at any time;
// rebuilds the list view in place when ops are already loaded.
setTextFilter(enabled) {
if (this.#textFilter === enabled) {
return;
}
this.#textFilter = enabled;
if (this.#opLines.length > 0) {
this.#rebuildMultilineView();
}
}
#rebuildMultilineView() {
// Compute the visible (possibly filtered) subset.
this.#visibleLines = this.#textFilter
? this.#opLines.filter(line => TEXT_OP_IDS.has(OPS[line.dataset.opName]))
: this.#opLines;
// Tear down the existing MultilineView (if any), keeping the placeholder.
const anchor = this.#multilineView?.element ?? this.#listPanelEl;
if (this.#multilineView) {
this.#multilineView.destroy();
this.#multilineView = null;
}
const multilineView = new MultilineView({
total: this.#visibleLines.length,
getText: i => this.#opTexts[+this.#visibleLines[i].dataset.opIdx],
makeLineEl: (i, isHighlighted) => {
this.#visibleLines[i].classList.toggle("mlc-match", isHighlighted);
return this.#visibleLines[i];
},
});
multilineView.element.classList.add("op-list-panel-wrapper");
multilineView.inner.id = "op-list";
multilineView.inner.role = "listbox";
multilineView.inner.ariaLabel = "Operator list";
multilineView.inner.addEventListener("keydown", e => {
const { key } = e;
const lines = this.#visibleLines;
if (!lines.length) {
return;
}
const focused = document.activeElement;
const currentIdx = lines.indexOf(focused);
let targetIdx = -1;
if (key === "ArrowDown") {
targetIdx = currentIdx < lines.length - 1 ? currentIdx + 1 : currentIdx;
} else if (key === "ArrowUp") {
targetIdx = currentIdx > 0 ? currentIdx - 1 : 0;
} else if (key === "Home") {
targetIdx = 0;
} else if (key === "End") {
targetIdx = lines.length - 1;
} else if (key === "Enter" || key === " ") {
if (currentIdx >= 0) {
lines[currentIdx].click();
e.preventDefault();
}
return;
} else {
return;
}
e.preventDefault();
if (targetIdx >= 0) {
lines[targetIdx].tabIndex = 0;
if (currentIdx >= 0 && currentIdx !== targetIdx) {
lines[currentIdx].tabIndex = -1;
}
multilineView.scrollToLine(targetIdx);
lines[targetIdx].focus();
}
});
anchor.replaceWith(multilineView.element);
this.#multilineView = multilineView;
}
clear() {
if (this.#multilineView) {
this.#multilineView.destroy();
this.#multilineView.element.replaceWith(this.#listPanelEl);
this.#multilineView = null;
}
document.getElementById("op-list").replaceChildren();
this.#detailView.clear();
this.#opLines = [];
this.#opTexts = [];
this.#visibleLines = [];
this.#selectedLine = null;
this.#originalColors.clear();
this.#breakpoints.clear();
this.#pausedAtIdx = this.#renderedPage = null;
}
markPaused(i) {
if (this.#pausedAtIdx !== null) {
this.#opLines[this.#pausedAtIdx]?.classList.remove("paused");
}
this.#pausedAtIdx = i;
this.#opLines[i]?.classList.add("paused");
// Scroll to the position of this op within the currently visible list.
const visibleIdx = this.#visibleLines.indexOf(this.#opLines[i]);
if (visibleIdx >= 0) {
this.#multilineView?.scrollToLine(visibleIdx);
}
}
clearPaused() {
if (this.#pausedAtIdx !== null) {
this.#opLines[this.#pausedAtIdx]?.classList.remove("paused");
this.#pausedAtIdx = null;
}
}
// The evaluator normalizes all color ops to setFillRGBColor /
// setStrokeRGBColor with args = ["#rrggbb"]. Return that hex string, or null.
#getOpColor(name, args) {
if (
(name === "setFillRGBColor" || name === "setStrokeRGBColor") &&
typeof args?.[0] === "string" &&
/^#[0-9a-f]{6}$/i.test(args[0])
) {
return args[0];
}
return null;
}
#buildLine(i, name, args) {
const line = document.createElement("div");
line.className = "op-line";
line.role = "option";
line.ariaSelected = "false";
line.tabIndex = i === 0 ? 0 : -1;
line.dataset.opName = name;
line.dataset.opIdx = i;
// Breakpoint gutter — click cycles: none → pause (●) → skip (✕) → none.
const gutter = document.createElement("span");
gutter.className = "bp-gutter";
gutter.role = "checkbox";
gutter.tabIndex = 0;
gutter.ariaLabel = "Breakpoint";
const initBpType = this.#breakpoints.get(i);
if (initBpType === BreakpointType.PAUSE) {
gutter.dataset.bp = "pause";
gutter.ariaChecked = "true";
} else if (initBpType === BreakpointType.SKIP) {
gutter.dataset.bp = "skip";
gutter.ariaChecked = "mixed";
line.classList.add("op-skipped");
} else {
gutter.ariaChecked = "false";
}
gutter.addEventListener("click", e => {
e.stopPropagation();
const current = this.#breakpoints.get(i);
if (current === undefined) {
this.#breakpoints.set(i, BreakpointType.PAUSE);
gutter.dataset.bp = "pause";
gutter.ariaChecked = "true";
} else if (current === BreakpointType.PAUSE) {
this.#breakpoints.set(i, BreakpointType.SKIP);
gutter.dataset.bp = "skip";
gutter.ariaChecked = "mixed";
line.classList.add("op-skipped");
} else {
this.#breakpoints.delete(i);
delete gutter.dataset.bp;
gutter.ariaChecked = "false";
line.classList.remove("op-skipped");
}
});
gutter.addEventListener("keydown", e => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
gutter.click();
}
});
line.append(gutter);
const nameEl = document.createElement("span");
nameEl.className = "op-name";
nameEl.textContent = name;
line.append(nameEl);
const rgb = this.#getOpColor(name, args);
let colorArgSpan = null;
if (rgb) {
this.#originalColors.set(i, rgb);
line.append(
makeColorSwatch(rgb, newHex => {
args[0] = newHex;
if (colorArgSpan) {
const changed = newHex !== rgb;
colorArgSpan.textContent = JSON.stringify(newHex);
colorArgSpan.classList.toggle("changed-value", changed);
colorArgSpan.title = changed ? `Original: ${rgb}` : "";
}
})
);
}
// Build arg spans and plain-text representation for search in one pass.
let text = name;
if (name === "showText" && Array.isArray(args[0])) {
const formatted = formatGlyphItems(args[0]);
const argEl = document.createElement("span");
argEl.className = "op-arg";
argEl.textContent = formatted;
line.append(argEl);
text += " " + formatted;
} else {
for (let j = 0; j < args.length; j++) {
const s =
name === "constructPath" && j === 0 && typeof args[0] === "number"
? (OPS_TO_NAME[args[0]] ?? String(args[0]))
: formatArg(args[j], false);
if (s) {
const argEl = document.createElement("span");
argEl.className = "op-arg";
argEl.textContent = s;
line.append(argEl);
if (rgb && j === 0) {
colorArgSpan = argEl;
}
text += " " + s;
}
}
}
line.addEventListener("pointerenter", () => this.#onHighlight(i));
line.addEventListener("pointerleave", () => this.#onClearHighlight());
line.addEventListener("click", () => {
if (this.#selectedLine) {
this.#selectedLine.classList.remove("selected");
this.#selectedLine.ariaSelected = "false";
this.#selectedLine.tabIndex = -1;
}
this.#selectedLine = line;
line.classList.add("selected");
line.ariaSelected = "true";
line.tabIndex = 0;
this.#detailView.show(name, args, i, {
originalColors: this.#originalColors,
renderedPage: this.#renderedPage,
selectedLine: line,
});
});
return { line, text };
}
}
export { BreakpointType, DrawOpsView, TEXT_EXEC_OP_IDS, TEXT_OP_IDS };

164
web/internal/font_view.css Normal file
View File

@@ -0,0 +1,164 @@
/* 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.
*/
#font-panel {
flex: 1 1 0;
overflow: auto;
min-width: 0;
min-height: 0;
background: var(--surface-bg);
border-radius: 4px;
border: 1px solid var(--border-color);
}
.font-list {
list-style: none;
margin: 0;
padding: 4px 0;
}
.font-item {
padding: 6px 10px;
border-bottom: 1px solid var(--border-subtle-color);
cursor: pointer;
&:last-child {
border-bottom: none;
}
&:hover {
background: var(--hover-bg);
}
&.selected {
background: color-mix(
in srgb,
var(--font-highlight-color, #0070c1) 12%,
transparent
);
}
}
.font-download-button {
box-sizing: border-box;
width: 24px;
height: 24px;
padding: 0;
border-radius: 3px;
border: 1px solid var(--input-border-color);
background: var(--button-bg);
color: inherit;
cursor: pointer;
&::before {
content: "";
display: block;
width: 14px;
height: 14px;
margin: auto;
background: currentColor;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z'/%3E%3Cpolyline points='17 21 17 13 7 13 7 21'/%3E%3Cpolyline points='7 3 7 8 15 8'/%3E%3C/svg%3E");
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
}
&:hover:not(:disabled) {
background: var(--button-hover-bg);
}
&:disabled {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
}
.font-name {
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.font-loaded-name {
font-size: 0.85em;
color: var(--muted-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}
.font-tags {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-top: 3px;
}
.font-tag {
font-size: 0.75em;
padding: 1px 5px;
border-radius: 3px;
background: var(--hover-bg);
color: var(--muted-color);
border: 1px solid var(--border-subtle-color);
}
.font-empty {
padding: 8px 10px;
color: var(--muted-color);
font-style: italic;
list-style: none;
}
#font-view-button {
font-family: Georgia, "Times New Roman", serif;
font-weight: bold;
font-style: normal;
}
.font-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-bottom: 1px solid var(--border-color);
}
.font-color-button {
box-sizing: border-box;
width: 24px;
height: 24px;
padding: 3px;
border-radius: 3px;
border: 1px solid var(--input-border-color);
background: var(--button-bg);
cursor: pointer;
&:hover {
background: var(--button-hover-bg);
}
}
.font-color-swatch {
display: block;
width: 100%;
height: 100%;
border-radius: 2px;
background: var(--font-highlight-color, #0070c1);
}

248
web/internal/font_view.js Normal file
View File

@@ -0,0 +1,248 @@
/* 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.
*/
const FONT_HIGHLIGHT_COLOR_KEY = "debugger.fontHighlightColor";
const DEFAULT_FONT_HIGHLIGHT_COLOR = "#0070c1";
// Maps MIME types to file extensions used when downloading fonts.
const MIMETYPE_TO_EXTENSION = new Map([
["font/opentype", "otf"],
["font/otf", "otf"],
["font/woff", "woff"],
["font/woff2", "woff2"],
["application/x-font-ttf", "ttf"],
["font/truetype", "ttf"],
["font/ttf", "ttf"],
["application/x-font-type1", "pfb"],
]);
// Maps MIME types reported by FontFaceObject to human-readable font format
// names.
const MIMETYPE_TO_FORMAT = new Map([
["font/opentype", "OpenType"],
["font/otf", "OpenType"],
["font/woff", "WOFF"],
["font/woff2", "WOFF2"],
["application/x-font-ttf", "TrueType"],
["font/truetype", "TrueType"],
["font/ttf", "TrueType"],
["application/x-font-type1", "Type1"],
]);
class FontView {
// Persistent map of all fonts seen since the document was opened,
// keyed by loadedName (= the PDF resource name / CSS font-family).
// Never cleared on page navigation so fonts cached in commonObjs
// (which only trigger fontAdded once per document) are always available.
#fontMap = new Map();
#container;
#list = (() => {
const ul = document.createElement("ul");
ul.className = "font-list";
return ul;
})();
#onSelect;
#selectedName = null;
#downloadBtn;
constructor(containerEl, { onSelect } = {}) {
this.#container = containerEl;
this.#onSelect = onSelect;
this.#container.append(this.#buildToolbar(), this.#list);
}
#buildToolbar() {
const toolbar = document.createElement("div");
toolbar.className = "font-toolbar";
// Color picker button + hidden input.
const colorInput = document.createElement("input");
colorInput.type = "color";
colorInput.hidden = true;
const colorSwatch = document.createElement("span");
colorSwatch.className = "font-color-swatch";
const colorBtn = document.createElement("button");
colorBtn.className = "font-color-button";
colorBtn.title = "Highlight color";
colorBtn.append(colorSwatch);
const applyColor = color => {
colorInput.value = color;
document.documentElement.style.setProperty(
"--font-highlight-color",
color
);
};
applyColor(
localStorage.getItem(FONT_HIGHLIGHT_COLOR_KEY) ??
DEFAULT_FONT_HIGHLIGHT_COLOR
);
colorBtn.addEventListener("click", () => colorInput.click());
colorInput.addEventListener("input", () => {
applyColor(colorInput.value);
localStorage.setItem(FONT_HIGHLIGHT_COLOR_KEY, colorInput.value);
});
// Download button — enabled only when a font with data is selected.
const downloadBtn = (this.#downloadBtn = document.createElement("button"));
downloadBtn.className = "font-download-button";
downloadBtn.title = "Download selected font";
downloadBtn.disabled = true;
downloadBtn.addEventListener("click", () => {
const font = this.#fontMap.get(this.#selectedName);
if (!font?.data) {
return;
}
const ext = MIMETYPE_TO_EXTENSION.get(font.mimetype) ?? "font";
const name = (font.name || font.loadedName).replaceAll(/[^\w-]/g, "_");
const blob = new Blob([font.data], { type: font.mimetype });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${name}.${ext}`;
a.click();
URL.revokeObjectURL(url);
});
toolbar.append(colorBtn, colorInput, downloadBtn);
return toolbar;
}
get element() {
return this.#container;
}
// Called by FontInspector.fontAdded whenever a font face is bound.
fontAdded(font) {
this.#fontMap.set(font.loadedName, font);
}
// Show the subset of known fonts that appear in the given op list.
// Uses setFont ops to determine which fonts are actually used on the page.
showForOpList({ fnArray, argsArray }, OPS) {
const usedNames = new Set();
for (let i = 0, len = fnArray.length; i < len; i++) {
if (fnArray[i] === OPS.setFont) {
usedNames.add(argsArray[i][0]);
}
}
const fonts = [];
for (const name of usedNames) {
const font = this.#fontMap.get(name);
if (font) {
fonts.push(font);
}
}
this.#render(fonts);
}
clear() {
this.#selectedName = null;
this.#downloadBtn.disabled = true;
this.#list.replaceChildren();
}
#render(fonts) {
if (fonts.length === 0) {
const li = document.createElement("li");
li.className = "font-empty";
li.textContent = "No fonts on this page.";
this.#list.replaceChildren(li);
return;
}
const frag = document.createDocumentFragment();
for (const font of fonts) {
const li = document.createElement("li");
li.className = "font-item";
li.dataset.loadedName = font.loadedName;
if (font.loadedName === this.#selectedName) {
li.classList.add("selected");
}
li.addEventListener("click", () => {
const next =
font.loadedName === this.#selectedName ? null : font.loadedName;
this.#selectedName = next;
for (const item of this.#list.querySelectorAll(".font-item")) {
item.classList.toggle("selected", item.dataset.loadedName === next);
}
const selectedFont = next ? this.#fontMap.get(next) : null;
this.#downloadBtn.disabled = !selectedFont?.data;
this.#onSelect?.(next);
});
const nameEl = document.createElement("div");
nameEl.className = "font-name";
nameEl.textContent = font.name || font.loadedName;
li.append(nameEl);
const tags = [];
const fmt = MIMETYPE_TO_FORMAT.get(font.mimetype);
if (fmt) {
tags.push(fmt);
}
if (font.isType3Font) {
tags.push("Type3");
}
if (font.bold) {
tags.push("Bold");
}
if (font.italic) {
tags.push("Italic");
}
if (font.vertical) {
tags.push("Vertical");
}
if (font.disableFontFace) {
tags.push("System");
}
if (font.missingFile) {
tags.push("Missing");
}
if (tags.length) {
const tagsEl = document.createElement("div");
tagsEl.className = "font-tags";
for (const tag of tags) {
const span = document.createElement("span");
span.className = "font-tag";
span.textContent = tag;
tagsEl.append(span);
}
li.append(tagsEl);
}
const loadedEl = document.createElement("div");
loadedEl.className = "font-loaded-name";
loadedEl.textContent = font.loadedName;
li.append(loadedEl);
frag.append(li);
}
this.#list.replaceChildren(frag);
}
}
export { FontView };

View File

@@ -0,0 +1,192 @@
/* 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-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;
height: 22px;
}
.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,575 @@
/* 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;
#ignoreCaseBtn;
#regexBtn;
/**
* @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);
}
// Remove `count` children from `parent` starting at `firstChild`, in one
// Range operation instead of N individual remove() calls.
#removeChildren(parent, firstChild, count, fromEnd = false) {
if (count <= 0 || !firstChild) {
return;
}
const range = document.createRange();
if (fromEnd) {
// Remove the last `count` children ending at firstChild
// (=lastChild here).
let startChild = firstChild;
for (let i = 1; i < count; i++) {
startChild = startChild.previousElementSibling;
if (!startChild) {
return;
}
}
range.setStartBefore(startChild);
range.setEndAfter(firstChild);
} else {
let endChild = firstChild;
for (let i = 1; i < count; i++) {
endChild = endChild.nextElementSibling;
if (!endChild) {
return;
}
}
range.setStartBefore(firstChild);
range.setEndAfter(endChild);
}
range.deleteContents();
}
#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;
this.#removeChildren(
this.#pre,
this.#topSentinel.nextElementSibling,
removeCount
);
this.#removeChildren(
this.#numCol,
this.#numCol.firstElementChild,
removeCount
);
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;
this.#removeChildren(
this.#pre,
this.#bottomSentinel.previousElementSibling,
removeCount,
true
);
this.#removeChildren(
this.#numCol,
this.#numCol.lastElementChild,
removeCount,
true
);
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.title = "Previous match";
prevButton.disabled = true;
const nextButton = (this.#nextButton = document.createElement("button"));
nextButton.className = "mlc-nav-button";
nextButton.textContent = "↓";
nextButton.title = "Next match";
nextButton.disabled = true;
const matchInfo = (this.#matchInfo = document.createElement("span"));
matchInfo.className = "mlc-match-info";
const ignoreCaseBtn = (this.#ignoreCaseBtn = this.#makeToggleButton(
"Aa",
"Ignore case"
));
const regexBtn = (this.#regexBtn = this.#makeToggleButton(".*", "Regex"));
searchGroup.append(
searchInput,
searchError,
prevButton,
nextButton,
ignoreCaseBtn,
regexBtn,
matchInfo
);
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.#ignoreCaseBtn.addEventListener("click", () => {
this.#ignoreCaseBtn.ariaPressed =
this.#ignoreCaseBtn.ariaPressed === "true" ? "false" : "true";
this.#runSearch();
});
this.#regexBtn.addEventListener("click", () => {
this.#regexBtn.ariaPressed =
this.#regexBtn.ariaPressed === "true" ? "false" : "true";
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;
}
#makeToggleButton(text, title) {
const btn = document.createElement("button");
btn.className = "mlc-nav-button";
btn.textContent = text;
btn.title = title;
btn.ariaPressed = "false";
return btn;
}
#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.#regexBtn.ariaPressed === "true") {
try {
const re = new RegExp(
query,
this.#ignoreCaseBtn.ariaPressed === "true" ? "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.#ignoreCaseBtn.ariaPressed === "true";
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 };

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

@@ -0,0 +1,243 @@
/* 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 half the width next to canvas. */
> .spc-column {
flex: 1 1 0;
}
/* canvasFontSplit (spc-row) takes the other half. */
> .spc-row {
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 {
--btn-h: calc(
1.1em * 1.4 + 4px
); /* font-size * line-height + 2×(padding+border) */
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 4px 8px;
border-bottom: 1px solid var(--border-color);
button {
box-sizing: border-box;
height: var(--btn-h);
padding: 0 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;
}
&[aria-pressed="true"] {
background: var(--accent-color);
color: var(--accent-fg, white);
border-color: var(--accent-color);
&:hover {
background: color-mix(
in srgb,
var(--accent-color),
var(--accent-fg, white) 15%
);
}
}
}
.toolbar-left:not(:has(#text-filter-button[aria-pressed="true"])) {
#text-layer-color-button,
#text-span-border-button,
#font-view-button {
display: none;
}
}
#text-span-border-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
width: var(--btn-h);
}
#text-layer-color-button {
padding: 3px;
width: var(--btn-h);
#text-layer-color-swatch {
display: block;
width: 100%;
height: 100%;
border-radius: 2px;
background: var(--text-layer-color, #c03030);
}
}
.toolbar-left,
.toolbar-right {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
}
.toolbar-right {
justify-content: flex-end;
}
.toolbar-center {
display: flex;
align-items: center;
gap: 6px;
}
#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;
/* Make text-layer spans visible in the debugger (normally transparent). */
.textLayer :is(span, br) {
color: color-mix(
in srgb,
var(--text-layer-color, #c03030) 70%,
transparent
);
}
.textLayer .font-highlighted {
background: color-mix(
in srgb,
var(--font-highlight-color, #0070c1) 25%,
transparent
);
}
&.show-span-borders .textLayer :is(span, br) {
outline: 1px solid
color-mix(in srgb, var(--text-layer-color, #c03030) 60%, black);
}
}
.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;
}
.canvas-checker {
display: inline-block;
line-height: 0;
background-image: conic-gradient(
light-dark(#cfcfd8, #42414d) 25%,
light-dark(white, #8f8f9d) 25% 50%,
light-dark(#cfcfd8, #42414d) 50% 75%,
light-dark(white, #8f8f9d) 75%
);
background-size: 30px 30px;
}
.temp-canvas-wrapper .canvas-checker {
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;
}

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

@@ -0,0 +1,888 @@
/* 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 {
BreakpointType,
DrawOpsView,
TEXT_EXEC_OP_IDS,
TEXT_OP_IDS,
} from "./draw_ops_view.js";
import { OPS, TextLayer } from "pdfjs-lib";
import { CanvasContextDetailsView } from "./canvas_context_details_view.js";
import { DOMCanvasFactory } from "pdfjs/display/canvas_factory.js";
import { FontView } from "./font_view.js";
import { SplitView } from "./split_view.js";
// Enable font inspection so TextLayer sets data-font-name on each span.
// fontAdded is called by FontFaceObject when loading fonts (via the pdfBug
// inspectFont callback in api.js) — we don't need it here, but it must exist
// to avoid a TypeError that would disrupt font loading and break canvas
// rendering.
globalThis.FontInspector = { enabled: true, fontAdded() {} };
// 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.
// In text-only mode, skip forward to the next text op.
stepNext() {
if (!this.#continueCallback) {
return;
}
let next = this.currentIdx + 1;
if (globalThis.StepperManager._textOnly) {
const count = globalThis.StepperManager._opCount();
while (next < count && !globalThis.StepperManager._isTextOp(next)) {
next++;
}
if (next >= count) {
next = null; // no more text ops; let rendering run to completion
}
}
this.nextBreakPoint = next;
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();
}
shouldSkip(i) {
return (
globalThis.StepperManager._breakpoints.get(i) === BreakpointType.SKIP ||
(globalThis.StepperManager._textOnly &&
!globalThis.StepperManager._isTextExecOp(i))
);
}
#findNextAfter(idx) {
let next = null;
for (const [bp, type] of globalThis.StepperManager._breakpoints) {
if (
type === BreakpointType.PAUSE &&
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;
#textFilterButton;
#textLayerColorInput;
#textSpanBorderButton;
#textFilter = false;
#highlightCanvas;
#canvasScrollEl;
#textLayerEl = null;
#textLayerInstance = null;
#fontView;
#fontViewButton;
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,
}
);
this.#fontView = new FontView(document.getElementById("font-panel"), {
onSelect: loadedName => {
if (!this.#textLayerEl) {
return;
}
for (const span of this.#textLayerEl.querySelectorAll(
".font-highlighted"
)) {
span.classList.remove("font-highlighted");
}
if (loadedName) {
for (const span of this.#textLayerEl.querySelectorAll(
`[data-font-name="${CSS.escape(loadedName)}"]`
)) {
span.classList.add("font-highlighted");
}
}
},
});
this.#fontViewButton = document.getElementById("font-view-button");
globalThis.FontInspector.fontAdded = font => this.#fontView.fontAdded(font);
// 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,
_textOnly: false,
// Returns true when op index i is a text op shown in the filtered list.
_isTextOp: i => TEXT_OP_IDS.has(this.#currentOpList?.fnArray[i]),
// Returns true when op index i must be executed (not skipped) in text
// mode.
_isTextExecOp: i => TEXT_EXEC_OP_IDS.has(this.#currentOpList?.fnArray[i]),
// Returns the total number of ops in the current op list.
_opCount: () => this.#currentOpList?.fnArray.length ?? 0,
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.#textFilterButton = document.getElementById("text-filter-button");
this.#textLayerColorInput = document.getElementById(
"text-layer-color-input"
);
this.#textSpanBorderButton = document.getElementById(
"text-span-border-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.#cancelTextLayer();
this.#currentRenderTask?.cancel();
this.#currentRenderTask = null;
this.#renderedPage?.cleanup();
this.#renderedPage = this.#renderScale = this.#currentOpList = null;
this.#clearPausedState();
this.#opsView.clear();
this.#fontView.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) {
const { wrapper, ctxLabel } = this.#alive[idx];
wrapper.remove();
gfxStateComp.removeContext(ctxLabel);
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}`;
const checker = document.createElement("div");
checker.className = "canvas-checker";
checker.append(canvasAndCtx.canvas);
wrapper.append(labelEl, checker);
const entry = { canvasAndCtx, wrapper, labelEl, ctxLabel };
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 }
);
// Row split: canvas on the left, font panel on the right (hidden by
// default).
const canvasFontSplit = new SplitView(
document.getElementById("canvas-panel"),
document.getElementById("font-panel"),
{ direction: "row", minSize: 150, onResize: () => this.#rerenderCanvas() }
);
// Outer row split: instructions column on the left, canvas+font on the
// right.
const renderSplit = new SplitView(
instructionsSplit.element,
canvasFontSplit.element,
{ direction: "row", minSize: 100, onResize: () => this.#rerenderCanvas() }
);
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.#textFilter || this.#opsView.breakpoints.size > 0) {
globalThis.StepperManager._active = new ViewerStepper(i =>
this.#onStepped(i)
);
}
await this.#renderCanvas();
});
this.#stepButton.addEventListener("click", () => {
if (globalThis.StepperManager._active) {
this.#gfxStateComp.freeze();
globalThis.StepperManager._active.stepNext();
}
});
this.#continueButton.addEventListener("click", () => {
if (globalThis.StepperManager._active) {
this.#gfxStateComp.freeze();
globalThis.StepperManager._active.continueToBreakpoint();
}
});
const TEXT_LAYER_COLOR_KEY = "debugger.textLayerColor";
const DEFAULT_TEXT_LAYER_COLOR = "#c03030";
const applyColor = color => {
this.#textLayerColorInput.value = color;
document.documentElement.style.setProperty("--text-layer-color", color);
};
applyColor(
localStorage.getItem(TEXT_LAYER_COLOR_KEY) ?? DEFAULT_TEXT_LAYER_COLOR
);
document
.getElementById("text-layer-color-button")
.addEventListener("click", () => this.#textLayerColorInput.click());
this.#textLayerColorInput.addEventListener("input", () => {
const color = this.#textLayerColorInput.value;
applyColor(color);
localStorage.setItem(TEXT_LAYER_COLOR_KEY, color);
});
const SPAN_BORDERS_KEY = "debugger.spanBorders";
const applySpanBorders = enabled => {
this.#textSpanBorderButton.setAttribute("aria-pressed", String(enabled));
document
.getElementById("canvas-wrapper")
.classList.toggle("show-span-borders", enabled);
};
applySpanBorders(localStorage.getItem(SPAN_BORDERS_KEY) === "true");
this.#textSpanBorderButton.addEventListener("click", () => {
const next =
this.#textSpanBorderButton.getAttribute("aria-pressed") !== "true";
applySpanBorders(next);
localStorage.setItem(SPAN_BORDERS_KEY, String(next));
});
this.#fontViewButton.addEventListener("click", () => {
const next = this.#fontViewButton.getAttribute("aria-pressed") !== "true";
this.#fontViewButton.setAttribute("aria-pressed", String(next));
const fontPanelEl = this.#fontView.element;
if (next && !fontPanelEl.style.flexGrow) {
// On first reveal, size the font panel to its SplitView minSize (150px)
// and give the canvas panel the remaining space.
// Both panels need flex-basis:0 for SplitView's pixel-weight math, so
// we must set both flexGrow values explicitly here.
const FONT_PANEL_MIN = 150;
const RESIZER_SIZE = 6;
const available =
fontPanelEl.parentElement.getBoundingClientRect().width -
RESIZER_SIZE;
fontPanelEl.style.flexGrow = FONT_PANEL_MIN;
document.getElementById("canvas-panel").style.flexGrow = Math.max(
100,
available - FONT_PANEL_MIN
);
}
fontPanelEl.hidden = !next;
this.#rerenderCanvas();
});
this.#textFilterButton.addEventListener("click", () => {
const pressed =
this.#textFilterButton.getAttribute("aria-pressed") === "true";
const next = !pressed;
this.#textFilterButton.setAttribute("aria-pressed", String(next));
this.#textFilter = next;
globalThis.StepperManager._textOnly = next;
this.#opsView.setTextFilter(next);
this.#redrawButton.click();
});
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();
this.#gfxStateComp.freeze();
stepper.stepNext();
} else if (e.key === "c") {
e.preventDefault();
this.#gfxStateComp.freeze();
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
);
}
// Re-render preserving any current pause position and text-only state.
// Used by both zoom and resize so neither loses stepper or filter state.
#rerenderCanvas() {
const stepper = globalThis.StepperManager._active;
let resumeAt = null;
if (stepper !== null) {
resumeAt =
stepper.currentIdx >= 0 ? stepper.currentIdx : stepper.nextBreakPoint;
}
this.#clearPausedState();
if (resumeAt !== null || this.#textFilter) {
globalThis.StepperManager._active = new ViewerStepper(
i => this.#onStepped(i),
resumeAt
);
}
return this.#renderCanvas();
}
#zoomRenderCanvas(newScale) {
this.#renderScale = newScale;
return this.#rerenderCanvas();
}
#cancelTextLayer() {
this.#textLayerInstance?.cancel();
this.#textLayerEl?.remove();
this.#textLayerInstance = null;
this.#textLayerEl = null;
}
async #buildTextLayer(scale) {
const container = document.createElement("div");
container.className = "textLayer";
// --total-scale-factor is required by text_layer_builder.css to compute
// font sizes. setLayerDimensions (called inside TextLayer) consumes it but
// never sets it, so we must provide it here.
container.style.setProperty("--total-scale-factor", scale);
container.style.setProperty("--scale-round-x", "1px");
container.style.setProperty("--scale-round-y", "1px");
document.getElementById("canvas-wrapper").append(container);
this.#textLayerEl = container;
const viewport = this.#renderedPage.getViewport({ scale });
const textLayer = new TextLayer({
textContentSource: this.#renderedPage.streamTextContent(),
container,
viewport,
});
this.#textLayerInstance = textLayer;
try {
await textLayer.render();
} catch (err) {
if (err?.name !== "AbortException") {
throw err;
}
}
}
async #renderCanvas() {
if (!this.#renderedPage) {
return null;
}
// Cancel any in-progress render before starting a new one.
// Hide the text layer immediately so it isn't visible at the wrong scale
// during the render; it is shown again once the canvas is ready.
if (this.#textLayerEl) {
this.#textLayerEl.style.visibility = "hidden";
}
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);
}
// In text-only mode, overlay the text layer on the finished canvas.
// If a layer already exists (e.g. after a zoom/resize), rescale it in place
// by updating the CSS variable and calling update() — no rebuild needed.
// If the filter is now off, discard any existing layer.
if (this.#textFilter) {
if (this.#textLayerInstance) {
this.#textLayerEl.style.setProperty("--total-scale-factor", scale);
this.#textLayerInstance.update({
viewport: this.#renderedPage.getViewport({ scale }),
});
this.#textLayerEl.style.visibility = "";
} else {
await this.#buildTextLayer(scale);
}
} else {
this.#cancelTextLayer();
}
// 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);
this.#fontView.showForOpList(this.#currentOpList, OPS);
// If text-only filter is active, re-render immediately using only text
// ops so the canvas matches the filtered op list.
if (this.#textFilter) {
if (this.#debugViewGeneration !== generation) {
return;
}
this.#renderedPage.recordedBBoxes = null;
globalThis.StepperManager._active = new ViewerStepper(i =>
this.#onStepped(i)
);
await this.#renderCanvas();
}
} 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;
}
}
}

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

@@ -0,0 +1,213 @@
/* 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 { MathClamp } from "pdfjs-lib";
/**
* 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 MathClamp(0, requestedFirst, total);
}
return MathClamp(total - this.#minSize, 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 };

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

@@ -0,0 +1,187 @@
/* 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);
white-space: preserve;
}
.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;
height: 22px;
}
.raw-bytes-stream {
color: var(--string-color);
}

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

@@ -0,0 +1,983 @@
/* 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.#isPSFunction(value)) {
for (const [k, v] of Object.entries(value.dict)) {
container.append(this.#renderNode(k, v, doc));
}
const srcNode = this.#makeNodeEl("source");
const srcLabel = `[PostScript, ${value.psLines.length} lines]`;
const srcLabelEl = this.#makeSpan("stream-label", srcLabel);
srcNode.append(
this.#makeExpandable(srcLabelEl, srcLabel, c =>
this.#buildPSFunctionPanel(value, c, srcLabelEl)
)
);
container.append(srcNode);
if (value.jsCode !== null) {
const jsNode = this.#makeNodeEl("js");
const jsLabel = "[JS equivalent]";
const jsLabelEl = this.#makeSpan("stream-label", jsLabel);
jsNode.append(
this.#makeExpandable(jsLabelEl, jsLabel, c =>
this.#buildJSCodePanel(value.jsCode, c)
)
);
container.append(jsNode);
}
return;
}
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 "brace":
return this.#makeSpan("bracket", token.value);
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 "brace":
return token.value;
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 a PostScript source panel (indented, token-coloured).
#buildPSSourcePanel(psLines, container, actions = null) {
const mc = new MultilineView({
total: psLines.length,
lineClass: "content-stream ps-source-stream",
getText: i => {
const { tokens } = psLines[i];
return tokens.map(t => this.#tokenToText(t)).join(" ");
},
actions,
makeLineEl: (i, isHighlighted) => {
const line = document.createElement("div");
line.className = "content-stm-instruction";
if (isHighlighted) {
line.classList.add("mlc-match");
}
const content = document.createElement("span");
const { indent, tokens } = psLines[i];
if (indent > 0) {
content.style.paddingInlineStart = `${indent * 1.5}em`;
}
for (let j = 0; j < tokens.length; j++) {
if (j > 0) {
content.append(document.createTextNode(" "));
}
content.append(this.#renderToken(tokens[j]));
}
line.append(content);
return line;
},
});
container.append(mc.element);
return mc;
}
// Fills container with a JS code panel (plain monospace lines).
#buildJSCodePanel(jsCode, container, actions = null) {
const lines = jsCode.split("\n");
while (lines.at(-1) === "") {
lines.pop();
}
const mc = new MultilineView({
total: lines.length,
lineClass: "content-stream js-code-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(document.createTextNode(lines[i]));
return el;
},
});
container.append(mc.element);
return mc;
}
// PS source panel with parsed/raw toggle and an expandable JS equivalent.
#buildPSFunctionPanel(val, container, labelEl = null) {
let isParsed = true;
let currentPanel = null;
const rawLines = val.source.split(/\r?\n|\r/);
if (rawLines.at(-1) === "") {
rawLines.pop();
}
const parsedLabel = `[PostScript, ${val.psLines.length} lines]`;
const rawLabel = `[PostScript, ${rawLines.length} raw 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.#buildPSSourcePanel(val.psLines, container, btn)
: this.#buildRawBytesPanel(val.source, container, btn);
};
rebuild();
}
// 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);
}
// PostScript Type 4 function stream
if (this.#isPSFunction(value)) {
return this.#renderExpandable(
"[PostScript Function]",
"stream-label",
container => this.#buildChildren(value, doc, container)
);
}
// 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.hasOwn(val, "dict") &&
(Object.hasOwn(val, "bytes") ||
Object.hasOwn(val, "imageData") ||
val.contentStream === true)
);
}
#isImageStream(val) {
return this.#isStream(val) && Object.hasOwn(val, "imageData");
}
#isFormXObjectStream(val) {
return this.#isStream(val) && val.contentStream === true;
}
// PostScript Type 4 function: { dict, psFunction: true, psLines, jsCode }.
#isPSFunction(val) {
return (
val !== null &&
typeof val === "object" &&
!Array.isArray(val) &&
Object.hasOwn(val, "dict") &&
val.psFunction === true
);
}
}
export { TreeView };