mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-07-13 02:25:47 +02:00
Merge pull request #20871 from calixteman/refactor_debugger
Split the new debugger into multiple files
This commit is contained in:
commit
a3a205d69b
@ -46,7 +46,7 @@ directory `build/chromium`.
|
|||||||
|
|
||||||
### PDF debugger
|
### PDF debugger
|
||||||
|
|
||||||
Browser the internal structure of a PDF document with https://mozilla.github.io/pdf.js/internal-viewer/web/pdf_internal_viewer.html
|
Browser the internal structure of a PDF document with https://mozilla.github.io/pdf.js/internal-viewer/web/debugger.html
|
||||||
|
|
||||||
## Getting the Code
|
## Getting the Code
|
||||||
|
|
||||||
|
|||||||
@ -2372,13 +2372,13 @@ gulp.task("check_l10n", function (done) {
|
|||||||
|
|
||||||
function createInternalViewerBundle(defines) {
|
function createInternalViewerBundle(defines) {
|
||||||
const viewerFileConfig = createWebpackConfig(defines, {
|
const viewerFileConfig = createWebpackConfig(defines, {
|
||||||
filename: "pdf_internal_viewer.mjs",
|
filename: "debugger.mjs",
|
||||||
library: {
|
library: {
|
||||||
type: "module",
|
type: "module",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return gulp
|
return gulp
|
||||||
.src("./web/pdf_internal_viewer.js", { encoding: false })
|
.src("./web/internal/debugger.js", { encoding: false })
|
||||||
.pipe(webpack2Stream(viewerFileConfig));
|
.pipe(webpack2Stream(viewerFileConfig));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2389,10 +2389,10 @@ function buildInternalViewer(defines, dir) {
|
|||||||
createMainBundle(defines).pipe(gulp.dest(dir + "build")),
|
createMainBundle(defines).pipe(gulp.dest(dir + "build")),
|
||||||
createWorkerBundle(defines).pipe(gulp.dest(dir + "build")),
|
createWorkerBundle(defines).pipe(gulp.dest(dir + "build")),
|
||||||
createInternalViewerBundle(defines).pipe(gulp.dest(dir + "web")),
|
createInternalViewerBundle(defines).pipe(gulp.dest(dir + "web")),
|
||||||
preprocessHTML("web/pdf_internal_viewer.html", defines).pipe(
|
preprocessHTML("web/internal/debugger.html", defines).pipe(
|
||||||
gulp.dest(dir + "web")
|
gulp.dest(dir + "web")
|
||||||
),
|
),
|
||||||
preprocessCSS("web/pdf_internal_viewer.css", defines)
|
preprocessCSS("web/internal/debugger.css", defines)
|
||||||
.pipe(
|
.pipe(
|
||||||
postcss([
|
postcss([
|
||||||
postcssDirPseudoClass(),
|
postcssDirPseudoClass(),
|
||||||
|
|||||||
85
web/internal/canvas_context_details_view.css
Normal file
85
web/internal/canvas_context_details_view.css
Normal 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;
|
||||||
|
}
|
||||||
533
web/internal/canvas_context_details_view.js
Normal file
533
web/internal/canvas_context_details_view.js
Normal file
@ -0,0 +1,533 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Properties of CanvasRenderingContext2D that we track while stepping.
|
||||||
|
const TRACKED_CTX_PROPS = new Set([
|
||||||
|
"direction",
|
||||||
|
"fillStyle",
|
||||||
|
"filter",
|
||||||
|
"font",
|
||||||
|
"globalAlpha",
|
||||||
|
"globalCompositeOperation",
|
||||||
|
"imageSmoothingEnabled",
|
||||||
|
"lineCap",
|
||||||
|
"lineDashOffset",
|
||||||
|
"lineJoin",
|
||||||
|
"lineWidth",
|
||||||
|
"miterLimit",
|
||||||
|
"strokeStyle",
|
||||||
|
"textAlign",
|
||||||
|
"textBaseline",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Methods that modify the current transform matrix.
|
||||||
|
const TRANSFORM_METHODS = new Set([
|
||||||
|
"resetTransform",
|
||||||
|
"rotate",
|
||||||
|
"scale",
|
||||||
|
"setTransform",
|
||||||
|
"transform",
|
||||||
|
"translate",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Maps every tracked context property to a function that reads its current
|
||||||
|
// value from a CanvasRenderingContext2D. Covers directly-readable properties
|
||||||
|
// (TRACKED_CTX_PROPS) and method-read ones (lineDash, transform).
|
||||||
|
const CTX_PROP_READERS = new Map([
|
||||||
|
...Array.from(TRACKED_CTX_PROPS, p => [p, ctx => ctx[p]]),
|
||||||
|
["lineDash", ctx => ctx.getLineDash()],
|
||||||
|
[
|
||||||
|
"transform",
|
||||||
|
ctx => {
|
||||||
|
const { a, b, c, d, e, f } = ctx.getTransform();
|
||||||
|
return { a, b, c, d, e, f };
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Color properties whose value is rendered as a swatch.
|
||||||
|
const COLOR_CTX_PROPS = new Set(["fillStyle", "shadowColor", "strokeStyle"]);
|
||||||
|
|
||||||
|
const MATHML_NS = "http://www.w3.org/1998/Math/MathML";
|
||||||
|
|
||||||
|
// Cached media queries used by drawCheckerboard.
|
||||||
|
const _prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const _prefersHCM = window.matchMedia("(forced-colors: active)");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a checkerboard pattern filling the canvas, to reveal transparency.
|
||||||
|
* Mirrors the pattern used in src/display/editor/stamp.js.
|
||||||
|
*/
|
||||||
|
function drawCheckerboard(ctx, width, height) {
|
||||||
|
const isHCM = _prefersHCM.matches;
|
||||||
|
const isDark = _prefersDark.matches;
|
||||||
|
let light, dark;
|
||||||
|
if (isHCM) {
|
||||||
|
light = "white";
|
||||||
|
dark = "black";
|
||||||
|
} else if (isDark) {
|
||||||
|
light = "#8f8f9d";
|
||||||
|
dark = "#42414d";
|
||||||
|
} else {
|
||||||
|
light = "white";
|
||||||
|
dark = "#cfcfd8";
|
||||||
|
}
|
||||||
|
const boxDim = 15;
|
||||||
|
const pattern =
|
||||||
|
typeof OffscreenCanvas !== "undefined"
|
||||||
|
? new OffscreenCanvas(boxDim * 2, boxDim * 2)
|
||||||
|
: Object.assign(document.createElement("canvas"), {
|
||||||
|
width: boxDim * 2,
|
||||||
|
height: boxDim * 2,
|
||||||
|
});
|
||||||
|
const patternCtx = pattern.getContext("2d");
|
||||||
|
if (!patternCtx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
patternCtx.fillStyle = light;
|
||||||
|
patternCtx.fillRect(0, 0, boxDim * 2, boxDim * 2);
|
||||||
|
patternCtx.fillStyle = dark;
|
||||||
|
patternCtx.fillRect(0, 0, boxDim, boxDim);
|
||||||
|
patternCtx.fillRect(boxDim, boxDim, boxDim, boxDim);
|
||||||
|
ctx.save();
|
||||||
|
const fillPattern = ctx.createPattern(pattern, "repeat");
|
||||||
|
if (!fillPattern) {
|
||||||
|
ctx.restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.fillStyle = fillPattern;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks and displays the CanvasRenderingContext2D graphics state for all
|
||||||
|
* contexts created during a stepped render.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} panelEl The #gfx-state-panel DOM element.
|
||||||
|
*/
|
||||||
|
class CanvasContextDetailsView {
|
||||||
|
#panel;
|
||||||
|
|
||||||
|
// Map<label, Map<prop, value>> — live graphics state per tracked context.
|
||||||
|
#ctxStates = new Map();
|
||||||
|
|
||||||
|
// Map<label, Array<Map<prop, value>>> — save() stack snapshots per context.
|
||||||
|
#ctxStateStacks = new Map();
|
||||||
|
|
||||||
|
// Map<label, number|null> — which stack frame is shown; null = live/current.
|
||||||
|
#ctxStackViewIdx = new Map();
|
||||||
|
|
||||||
|
// Map<label, Map<prop, {valEl, swatchEl?}>> — DOM elements for live updates.
|
||||||
|
#gfxStateValueElements = new Map();
|
||||||
|
|
||||||
|
// Map<label, {container, prevBtn, pos, nextBtn}> — stack-nav DOM elements.
|
||||||
|
#gfxStateNavElements = new Map();
|
||||||
|
|
||||||
|
constructor(panelEl) {
|
||||||
|
this.#panel = panelEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a CanvasRenderingContext2D to track its graphics state.
|
||||||
|
* Returns a Proxy that keeps internal state in sync and updates the DOM.
|
||||||
|
*/
|
||||||
|
wrapContext(ctx, label) {
|
||||||
|
const state = new Map();
|
||||||
|
for (const [prop, read] of CTX_PROP_READERS) {
|
||||||
|
state.set(prop, read(ctx));
|
||||||
|
}
|
||||||
|
this.#ctxStates.set(label, state);
|
||||||
|
this.#ctxStateStacks.set(label, []);
|
||||||
|
this.#ctxStackViewIdx.set(label, null);
|
||||||
|
// If the panel is already visible (stepping in progress), rebuild it so
|
||||||
|
// the new context section is added and its live-update entries are
|
||||||
|
// registered.
|
||||||
|
if (this.#gfxStateValueElements.size > 0) {
|
||||||
|
this.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Proxy(ctx, {
|
||||||
|
set: (target, prop, value) => {
|
||||||
|
target[prop] = value;
|
||||||
|
if (TRACKED_CTX_PROPS.has(prop)) {
|
||||||
|
state.set(prop, value);
|
||||||
|
this.#updatePropEl(label, prop, value);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
get: (target, prop) => {
|
||||||
|
const val = target[prop];
|
||||||
|
if (typeof val !== "function") {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
if (prop === "save") {
|
||||||
|
return (...args) => {
|
||||||
|
const result = val.apply(target, args);
|
||||||
|
this.#ctxStateStacks.get(label).push(this.#copyState(state));
|
||||||
|
this.#updateStackNav(label);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (prop === "restore") {
|
||||||
|
return (...args) => {
|
||||||
|
const result = val.apply(target, args);
|
||||||
|
for (const [p, read] of CTX_PROP_READERS) {
|
||||||
|
const v = read(target);
|
||||||
|
state.set(p, v);
|
||||||
|
this.#updatePropEl(label, p, v);
|
||||||
|
}
|
||||||
|
const stack = this.#ctxStateStacks.get(label);
|
||||||
|
if (stack.length > 0) {
|
||||||
|
stack.pop();
|
||||||
|
// If the viewed frame was just removed, fall back to current.
|
||||||
|
const viewIndex = this.#ctxStackViewIdx.get(label);
|
||||||
|
if (viewIndex !== null && viewIndex >= stack.length) {
|
||||||
|
this.#ctxStackViewIdx.set(label, null);
|
||||||
|
this.#showState(label);
|
||||||
|
}
|
||||||
|
this.#updateStackNav(label);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (prop === "setLineDash") {
|
||||||
|
return segments => {
|
||||||
|
val.call(target, segments);
|
||||||
|
const dash = target.getLineDash();
|
||||||
|
state.set("lineDash", dash);
|
||||||
|
this.#updatePropEl(label, "lineDash", dash);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (TRANSFORM_METHODS.has(prop)) {
|
||||||
|
return (...args) => {
|
||||||
|
const result = val.apply(target, args);
|
||||||
|
const { a, b, c, d, e, f } = target.getTransform();
|
||||||
|
const tf = { a, b, c, d, e, f };
|
||||||
|
state.set("transform", tf);
|
||||||
|
this.#updatePropEl(label, "transform", tf);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return val.bind(target);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override canvas.getContext to return a tracked proxy for "2d" contexts.
|
||||||
|
* Caches the proxy so repeated getContext("2d") calls return the same
|
||||||
|
* wrapper.
|
||||||
|
*/
|
||||||
|
wrapCanvasGetContext(canvas, label) {
|
||||||
|
let wrappedCtx = null;
|
||||||
|
const origGetContext = canvas.getContext.bind(canvas);
|
||||||
|
canvas.getContext = (type, ...args) => {
|
||||||
|
const ctx = origGetContext(type, ...args);
|
||||||
|
if (type !== "2d") {
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
if (!wrappedCtx) {
|
||||||
|
if (
|
||||||
|
globalThis.StepperManager._active !== null &&
|
||||||
|
args[0]?.alpha !== false
|
||||||
|
) {
|
||||||
|
drawCheckerboard(ctx, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
wrappedCtx = this.wrapContext(ctx, label);
|
||||||
|
}
|
||||||
|
return wrappedCtx;
|
||||||
|
};
|
||||||
|
return canvas.getContext("2d");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild the graphics-state panel DOM for all currently tracked contexts.
|
||||||
|
* Shows the panel if it was hidden.
|
||||||
|
*/
|
||||||
|
build() {
|
||||||
|
this.#panel.hidden = false;
|
||||||
|
this.#panel.replaceChildren();
|
||||||
|
this.#gfxStateValueElements.clear();
|
||||||
|
this.#gfxStateNavElements.clear();
|
||||||
|
|
||||||
|
for (const [ctxLabel, state] of this.#ctxStates) {
|
||||||
|
const propEls = new Map();
|
||||||
|
this.#gfxStateValueElements.set(ctxLabel, propEls);
|
||||||
|
|
||||||
|
const section = document.createElement("div");
|
||||||
|
section.className = "gfx-state-section";
|
||||||
|
section.dataset.ctxLabel = ctxLabel;
|
||||||
|
|
||||||
|
// Title row with label and stack-navigation arrows.
|
||||||
|
const title = document.createElement("div");
|
||||||
|
title.className = "gfx-state-title";
|
||||||
|
|
||||||
|
const titleLabel = document.createElement("span");
|
||||||
|
titleLabel.textContent = ctxLabel;
|
||||||
|
|
||||||
|
const navContainer = document.createElement("span");
|
||||||
|
navContainer.className = "gfx-state-stack-nav";
|
||||||
|
navContainer.hidden = true;
|
||||||
|
|
||||||
|
const prevBtn = document.createElement("button");
|
||||||
|
prevBtn.className = "gfx-state-stack-button";
|
||||||
|
prevBtn.ariaLabel = "View older saved state";
|
||||||
|
prevBtn.textContent = "←";
|
||||||
|
|
||||||
|
const pos = document.createElement("span");
|
||||||
|
pos.className = "gfx-state-stack-pos";
|
||||||
|
|
||||||
|
const nextBtn = document.createElement("button");
|
||||||
|
nextBtn.className = "gfx-state-stack-button";
|
||||||
|
nextBtn.ariaLabel = "View newer saved state";
|
||||||
|
nextBtn.textContent = "→";
|
||||||
|
|
||||||
|
navContainer.append(prevBtn, pos, nextBtn);
|
||||||
|
title.append(titleLabel, navContainer);
|
||||||
|
section.append(title);
|
||||||
|
|
||||||
|
this.#gfxStateNavElements.set(ctxLabel, {
|
||||||
|
container: navContainer,
|
||||||
|
prevBtn,
|
||||||
|
pos,
|
||||||
|
nextBtn,
|
||||||
|
});
|
||||||
|
|
||||||
|
prevBtn.addEventListener("click", () => this.#navigate(ctxLabel, -1));
|
||||||
|
nextBtn.addEventListener("click", () => this.#navigate(ctxLabel, +1));
|
||||||
|
|
||||||
|
for (const [prop, value] of state) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "gfx-state-row";
|
||||||
|
|
||||||
|
const key = document.createElement("span");
|
||||||
|
key.className = "gfx-state-key";
|
||||||
|
key.textContent = prop;
|
||||||
|
|
||||||
|
row.append(key);
|
||||||
|
|
||||||
|
if (prop === "transform") {
|
||||||
|
const { math, mnEls } = this.#buildTransformMathML(value);
|
||||||
|
row.append(math);
|
||||||
|
propEls.set(prop, { valEl: math, swatchEl: null, mnEls });
|
||||||
|
} else {
|
||||||
|
const val = document.createElement("span");
|
||||||
|
val.className = "gfx-state-val";
|
||||||
|
const text = this.#formatCtxValue(value);
|
||||||
|
val.textContent = text;
|
||||||
|
val.title = text;
|
||||||
|
let swatchEl = null;
|
||||||
|
if (COLOR_CTX_PROPS.has(prop)) {
|
||||||
|
swatchEl = document.createElement("span");
|
||||||
|
swatchEl.className = "color-swatch";
|
||||||
|
swatchEl.style.background = String(value);
|
||||||
|
row.append(swatchEl);
|
||||||
|
}
|
||||||
|
row.append(val);
|
||||||
|
propEls.set(prop, { valEl: val, swatchEl });
|
||||||
|
}
|
||||||
|
section.append(row);
|
||||||
|
}
|
||||||
|
this.#panel.append(section);
|
||||||
|
|
||||||
|
// Apply the correct state for the current view index (may be a saved
|
||||||
|
// frame).
|
||||||
|
this.#showState(ctxLabel);
|
||||||
|
this.#updateStackNav(ctxLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hide the panel. */
|
||||||
|
hide() {
|
||||||
|
this.#panel.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll the panel to bring the section for the given context label into
|
||||||
|
* view.
|
||||||
|
*/
|
||||||
|
scrollToSection(label) {
|
||||||
|
this.#panel
|
||||||
|
.querySelector(`[data-ctx-label="${CSS.escape(label)}"]`)
|
||||||
|
?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all tracked state and reset the panel DOM.
|
||||||
|
* Called when the debug view is reset between pages.
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
this.#ctxStates.clear();
|
||||||
|
this.#ctxStateStacks.clear();
|
||||||
|
this.#ctxStackViewIdx.clear();
|
||||||
|
this.#gfxStateValueElements.clear();
|
||||||
|
this.#gfxStateNavElements.clear();
|
||||||
|
this.#panel.replaceChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
#formatCtxValue(value) {
|
||||||
|
return Array.isArray(value) ? `[${value.join(", ")}]` : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shallow-copy a state Map (arrays and plain objects are cloned one level
|
||||||
|
// deep).
|
||||||
|
#copyState(state) {
|
||||||
|
const clone = v => {
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
return [...v];
|
||||||
|
}
|
||||||
|
if (typeof v === "object" && v !== null) {
|
||||||
|
return { ...v };
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
return new Map([...state].map(([k, v]) => [k, clone(v)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a single (label, prop, value) update to the DOM unconditionally.
|
||||||
|
#applyPropEl(label, prop, value) {
|
||||||
|
const entry = this.#gfxStateValueElements.get(label)?.get(prop);
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.mnEls) {
|
||||||
|
for (const k of ["a", "b", "c", "d", "e", "f"]) {
|
||||||
|
entry.mnEls[k].textContent = this.#formatMatrixValue(value[k]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = this.#formatCtxValue(value);
|
||||||
|
entry.valEl.textContent = text;
|
||||||
|
entry.valEl.title = text;
|
||||||
|
if (entry.swatchEl) {
|
||||||
|
entry.swatchEl.style.background = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update DOM for a live setter — skipped when the user is browsing a saved
|
||||||
|
// state so that live updates don't overwrite the frozen view.
|
||||||
|
#updatePropEl(label, prop, value) {
|
||||||
|
if (this.#ctxStackViewIdx.get(label) !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#applyPropEl(label, prop, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render all value DOM elements for label using the currently-viewed
|
||||||
|
// state.
|
||||||
|
#showState(label) {
|
||||||
|
const viewIdx = this.#ctxStackViewIdx.get(label);
|
||||||
|
const stateToShow =
|
||||||
|
viewIdx === null
|
||||||
|
? this.#ctxStates.get(label)
|
||||||
|
: this.#ctxStateStacks.get(label)?.[viewIdx];
|
||||||
|
if (!stateToShow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [prop, value] of stateToShow) {
|
||||||
|
this.#applyPropEl(label, prop, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync the stack-nav button states and position counter for a context.
|
||||||
|
#updateStackNav(label) {
|
||||||
|
const nav = this.#gfxStateNavElements.get(label);
|
||||||
|
if (!nav) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stack = this.#ctxStateStacks.get(label) ?? [];
|
||||||
|
const viewIdx = this.#ctxStackViewIdx.get(label);
|
||||||
|
nav.container.hidden = stack.length === 0;
|
||||||
|
if (stack.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nav.prevBtn.disabled = viewIdx === 0;
|
||||||
|
nav.nextBtn.disabled = viewIdx === null;
|
||||||
|
nav.pos.textContent =
|
||||||
|
viewIdx === null ? "cur" : `${viewIdx + 1}/${stack.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate the save/restore stack view for a context.
|
||||||
|
// delta = -1 → older (prev) frame; +1 → newer (next) frame.
|
||||||
|
#navigate(label, delta) {
|
||||||
|
const stack = this.#ctxStateStacks.get(label) ?? [];
|
||||||
|
const viewIndex = this.#ctxStackViewIdx.get(label);
|
||||||
|
let newViewIndex;
|
||||||
|
if (delta < 0) {
|
||||||
|
newViewIndex = viewIndex === null ? stack.length - 1 : viewIndex - 1;
|
||||||
|
if (newViewIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (viewIndex === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newViewIndex = viewIndex >= stack.length - 1 ? null : viewIndex + 1;
|
||||||
|
}
|
||||||
|
this.#ctxStackViewIdx.set(label, newViewIndex);
|
||||||
|
this.#showState(label);
|
||||||
|
this.#updateStackNav(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mEl(tag, ...children) {
|
||||||
|
const el = document.createElementNS(MATHML_NS, tag);
|
||||||
|
el.append(...children);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
#formatMatrixValue(v) {
|
||||||
|
return Number.isInteger(v) ? String(v) : String(parseFloat(v.toFixed(4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#buildTransformMathML({ a, b, c, d, e, f }) {
|
||||||
|
const mnEls = {};
|
||||||
|
for (const [k, v] of Object.entries({ a, b, c, d, e, f })) {
|
||||||
|
mnEls[k] = this.#mEl("mn", this.#formatMatrixValue(v));
|
||||||
|
}
|
||||||
|
const math = this.#mEl(
|
||||||
|
"math",
|
||||||
|
this.#mEl(
|
||||||
|
"mrow",
|
||||||
|
this.#mEl("mo", "["),
|
||||||
|
this.#mEl(
|
||||||
|
"mtable",
|
||||||
|
this.#mEl(
|
||||||
|
"mtr",
|
||||||
|
this.#mEl("mtd", mnEls.a),
|
||||||
|
this.#mEl("mtd", mnEls.c),
|
||||||
|
this.#mEl("mtd", mnEls.e)
|
||||||
|
),
|
||||||
|
this.#mEl(
|
||||||
|
"mtr",
|
||||||
|
this.#mEl("mtd", mnEls.b),
|
||||||
|
this.#mEl("mtd", mnEls.d),
|
||||||
|
this.#mEl("mtd", mnEls.f)
|
||||||
|
),
|
||||||
|
this.#mEl(
|
||||||
|
"mtr",
|
||||||
|
this.#mEl("mtd", this.#mEl("mn", "0")),
|
||||||
|
this.#mEl("mtd", this.#mEl("mn", "0")),
|
||||||
|
this.#mEl("mtd", this.#mEl("mn", "1"))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
this.#mEl("mo", "]")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return { math, mnEls };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CanvasContextDetailsView };
|
||||||
302
web/internal/debugger.css
Normal file
302
web/internal/debugger.css
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import url(canvas_context_details_view.css);
|
||||||
|
@import url(draw_ops_view.css);
|
||||||
|
@import url(multiline_view.css);
|
||||||
|
@import url(page_view.css);
|
||||||
|
@import url(split_view.css);
|
||||||
|
@import url(tree_view.css);
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
|
||||||
|
/* Backgrounds */
|
||||||
|
--bg-color: light-dark(#fff, #1e1e1e);
|
||||||
|
--surface-bg: light-dark(#f3f3f3, #252526);
|
||||||
|
--input-bg: light-dark(#fff, #3c3c3c);
|
||||||
|
--button-bg: light-dark(#f3f3f3, #3c3c3c);
|
||||||
|
--button-hover-bg: light-dark(#e0e0e0, #4a4a4a);
|
||||||
|
--clr-canvas-bg: var(--surface-bg);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-color: light-dark(#1e1e1e, #d4d4d4);
|
||||||
|
--muted-color: light-dark(#6e6e6e, #888);
|
||||||
|
--accent-color: light-dark(#0070c1, #9cdcfe);
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border-color: light-dark(#e0e0e0, #3c3c3c);
|
||||||
|
--border-subtle-color: light-dark(#d0d0d0, #444);
|
||||||
|
--input-border-color: light-dark(#c8c8c8, #555);
|
||||||
|
|
||||||
|
/* Interactive states */
|
||||||
|
--hover-bg: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.05));
|
||||||
|
--hover-color: currentColor;
|
||||||
|
--paused-bg: light-dark(rgb(255 165 0 / 0.15), rgb(255 165 0 / 0.2));
|
||||||
|
--paused-outline-color: rgb(255 140 0 / 0.6);
|
||||||
|
--paused-color: currentColor;
|
||||||
|
|
||||||
|
/* Semantic */
|
||||||
|
--ref-color: light-dark(#007b6e, #4ec9b0);
|
||||||
|
--ref-hover-color: light-dark(#065, #89d9c8);
|
||||||
|
--changed-bg: transparent;
|
||||||
|
--changed-color: light-dark(#c00, #f66);
|
||||||
|
--match-bg: light-dark(rgb(255 200 0 / 0.35), rgb(255 200 0 / 0.25));
|
||||||
|
--match-outline-color: light-dark(rgb(200 140 0 / 0.8), rgb(255 200 0 / 0.6));
|
||||||
|
|
||||||
|
/* Syntax highlighting */
|
||||||
|
--string-color: light-dark(#a31515, #ce9178);
|
||||||
|
--number-color: light-dark(#098658, #b5cea8);
|
||||||
|
--bool-color: light-dark(#00f, #569cd6);
|
||||||
|
--null-color: light-dark(#767676, #808080);
|
||||||
|
--name-color: light-dark(#795e26, #dcdcaa);
|
||||||
|
--stream-color: light-dark(#af00db, #c586c0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
:root {
|
||||||
|
/* Backgrounds */
|
||||||
|
--bg-color: Canvas;
|
||||||
|
--surface-bg: Canvas;
|
||||||
|
--input-bg: Field;
|
||||||
|
--button-bg: ButtonFace;
|
||||||
|
--button-hover-bg: Highlight;
|
||||||
|
--clr-canvas-bg: var(--surface-bg);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-color: CanvasText;
|
||||||
|
--muted-color: GrayText;
|
||||||
|
--accent-color: CanvasText;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border-color: ButtonBorder;
|
||||||
|
--border-subtle-color: ButtonBorder;
|
||||||
|
--input-border-color: ButtonBorder;
|
||||||
|
|
||||||
|
/* Interactive states */
|
||||||
|
--hover-bg: Highlight;
|
||||||
|
--hover-color: HighlightText;
|
||||||
|
--paused-bg: Mark;
|
||||||
|
--paused-outline-color: ButtonBorder;
|
||||||
|
--paused-color: MarkText;
|
||||||
|
|
||||||
|
/* Semantic */
|
||||||
|
--ref-color: LinkText;
|
||||||
|
--ref-hover-color: ActiveText;
|
||||||
|
--changed-bg: Mark;
|
||||||
|
--changed-color: MarkText;
|
||||||
|
--match-bg: Mark;
|
||||||
|
--match-outline-color: ButtonBorder;
|
||||||
|
|
||||||
|
/* Syntax highlighting — replaced by plain text in HCM */
|
||||||
|
--string-color: CanvasText;
|
||||||
|
--number-color: CanvasText;
|
||||||
|
--bool-color: CanvasText;
|
||||||
|
--null-color: GrayText;
|
||||||
|
--name-color: CanvasText;
|
||||||
|
--stream-color: CanvasText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Opacity-only disabled style → explicit GrayText. */
|
||||||
|
button:disabled,
|
||||||
|
input:disabled {
|
||||||
|
color: GrayText;
|
||||||
|
border-color: GrayText;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
body.loading {
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In debug mode the body must be viewport-height so #debug-view can fill it.
|
||||||
|
In tree mode body is auto-height so the tree can grow and the page scrolls. */
|
||||||
|
body:has(#debug-view:not([hidden])) {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-size: 1.2em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pdf-info {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
font-size: 1.15em;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#password-dialog {
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
border: 1px solid var(--input-border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 20px;
|
||||||
|
min-width: 320px;
|
||||||
|
|
||||||
|
&::backdrop {
|
||||||
|
background: rgb(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
border: 1px solid var(--input-border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-dialog-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid var(--input-border-color);
|
||||||
|
background: var(--button-bg);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--button-hover-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#controls {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--surface-bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#github-link {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--muted-color);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#goto-input {
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
border: 1px solid var(--input-border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
&[aria-invalid="true"] {
|
||||||
|
border-color: var(--changed-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
color: var(--muted-color);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
#debug-button,
|
||||||
|
#debug-back-button {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid var(--input-border-color);
|
||||||
|
background: var(--button-bg);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--button-hover-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,7 +19,7 @@ limitations under the License.
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>PDF.js — Debugging tools</title>
|
<title>PDF.js — Debugging tools</title>
|
||||||
<link rel="stylesheet" href="pdf_internal_viewer.css" />
|
<link rel="stylesheet" href="debugger.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="header">
|
<div id="header">
|
||||||
@ -34,8 +34,8 @@ limitations under the License.
|
|||||||
<span id="goto-input-hint" class="sr-only">
|
<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.
|
Enter a page number (e.g. 5), a reference as numR (e.g. 10R) or numRgen (e.g. 10R2). Press Enter to navigate.
|
||||||
</span>
|
</span>
|
||||||
<button id="debug-btn" hidden>Debug page</button>
|
<button id="debug-button" hidden>Debug page</button>
|
||||||
<button id="debug-back-btn" hidden>← Back to tree</button>
|
<button id="debug-back-button" hidden>← Back to tree</button>
|
||||||
<span id="status" role="status" aria-live="polite"> Select a PDF file to explore its internal structure. </span>
|
<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">
|
<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">
|
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||||
@ -49,25 +49,20 @@ limitations under the License.
|
|||||||
<div id="debug-view" hidden>
|
<div id="debug-view" hidden>
|
||||||
<div id="render-panels">
|
<div id="render-panels">
|
||||||
<div id="op-left-col">
|
<div id="op-left-col">
|
||||||
<div id="op-top-row">
|
|
||||||
<div id="op-list-panel">
|
<div id="op-list-panel">
|
||||||
<div id="op-list" role="listbox" aria-label="Operator list"></div>
|
<div id="op-list" role="listbox" aria-label="Operator list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="op-gfx-state-resizer" role="separator" aria-orientation="vertical" tabindex="0" hidden></div>
|
<div id="op-detail-panel"></div>
|
||||||
<div id="gfx-state-panel" aria-label="Graphics state" hidden></div>
|
<div id="gfx-state-panel" aria-label="Graphics state" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="op-resizer" role="separator" aria-orientation="horizontal" tabindex="0"></div>
|
|
||||||
<div id="op-detail-panel"></div>
|
|
||||||
</div>
|
|
||||||
<div id="render-resizer" role="separator" aria-orientation="vertical" tabindex="0"></div>
|
|
||||||
<div id="canvas-panel">
|
<div id="canvas-panel">
|
||||||
<div id="canvas-toolbar" role="toolbar" aria-label="Zoom controls">
|
<div id="canvas-toolbar" role="toolbar" aria-label="Zoom controls">
|
||||||
<button id="zoom-out-btn" aria-label="Zoom out">−</button>
|
<button id="zoom-out-button" aria-label="Zoom out">−</button>
|
||||||
<span id="zoom-level" aria-live="polite"></span>
|
<span id="zoom-level" aria-live="polite"></span>
|
||||||
<button id="zoom-in-btn" aria-label="Zoom in">+</button>
|
<button id="zoom-in-button" aria-label="Zoom in">+</button>
|
||||||
<button id="redraw-btn" aria-label="Redraw page">Redraw</button>
|
<button id="redraw-button" aria-label="Redraw page">Redraw</button>
|
||||||
<button id="step-btn" aria-label="Step one instruction" disabled><u>S</u>tep</button>
|
<button id="step-button" aria-label="Step one instruction" disabled><u>S</u>tep</button>
|
||||||
<button id="continue-btn" aria-label="Continue to next breakpoint" disabled><u>C</u>ontinue</button>
|
<button id="continue-button" aria-label="Continue to next breakpoint" disabled><u>C</u>ontinue</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="canvas-scroll">
|
<div id="canvas-scroll">
|
||||||
<div id="canvas-wrapper">
|
<div id="canvas-wrapper">
|
||||||
@ -93,25 +88,25 @@ limitations under the License.
|
|||||||
|
|
||||||
<!--#if GENERIC-->
|
<!--#if GENERIC-->
|
||||||
<!--<script src="../build/pdf.mjs" type="module"></script>-->
|
<!--<script src="../build/pdf.mjs" type="module"></script>-->
|
||||||
<!--<script src="pdf_internal_viewer.mjs" type="module"></script>-->
|
<!--<script src="debugger.mjs" type="module"></script>-->
|
||||||
<!--#else-->
|
<!--#else-->
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
"pdfjs/": "../src/",
|
"pdfjs/": "../../src/",
|
||||||
"pdfjs-lib": "../src/pdf.js",
|
"pdfjs-lib": "../../src/pdf.js",
|
||||||
|
|
||||||
"display-cmap_reader_factory": "../src/display/cmap_reader_factory.js",
|
"display-cmap_reader_factory": "../../src/display/cmap_reader_factory.js",
|
||||||
"display-standard_fontdata_factory": "../src/display/standard_fontdata_factory.js",
|
"display-standard_fontdata_factory": "../../src/display/standard_fontdata_factory.js",
|
||||||
"display-wasm_factory": "../src/display/wasm_factory.js",
|
"display-wasm_factory": "../../src/display/wasm_factory.js",
|
||||||
"display-fetch_stream": "../src/display/fetch_stream.js",
|
"display-fetch_stream": "../../src/display/fetch_stream.js",
|
||||||
"display-network": "../src/display/network.js",
|
"display-network": "../../src/display/network.js",
|
||||||
"display-node_stream": "../src/display/stubs.js",
|
"display-node_stream": "../../src/display/stubs.js",
|
||||||
"display-node_utils": "../src/display/stubs.js"
|
"display-node_utils": "../../src/display/stubs.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="pdf_internal_viewer.js" type="module"></script>
|
<script src="debugger.js" type="module"></script>
|
||||||
<!--#endif-->
|
<!--#endif-->
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
255
web/internal/debugger.js
Normal file
255
web/internal/debugger.js
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getDocument, GlobalWorkerOptions, PasswordResponses } from "pdfjs-lib";
|
||||||
|
import { PageView } from "./page_view.js";
|
||||||
|
import { TreeView } from "./tree_view.js";
|
||||||
|
|
||||||
|
GlobalWorkerOptions.workerSrc =
|
||||||
|
typeof PDFJSDev === "undefined"
|
||||||
|
? "../../src/pdf.worker.js"
|
||||||
|
: "../build/pdf.worker.mjs";
|
||||||
|
|
||||||
|
// Parses "num" into { page: num }, or "numR"/"numRgen" into { ref: {num,gen} }.
|
||||||
|
// Returns null for invalid input.
|
||||||
|
function parseGoToInput(str) {
|
||||||
|
const match = str.trim().match(/^(\d+)(R(\d+)?)?$/i);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!match[2]) {
|
||||||
|
return { page: parseInt(match[1], 10) };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ref: {
|
||||||
|
num: parseInt(match[1], 10),
|
||||||
|
gen: match[3] !== undefined ? parseInt(match[3], 10) : 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses "num", "numR" or "numRgen" into { num, gen }, or returns null.
|
||||||
|
// Used for URL hash param parsing where a bare number means a ref, not a page.
|
||||||
|
function parseRefInput(str) {
|
||||||
|
const match = str.trim().match(/^(\d+)(?:R(\d+)?)?$/i);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
num: parseInt(match[1], 10),
|
||||||
|
gen: match[2] !== undefined ? parseInt(match[2], 10) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let pdfDoc = null;
|
||||||
|
|
||||||
|
// Page number currently displayed in the tree (null when showing a
|
||||||
|
// ref/trailer).
|
||||||
|
let currentPage = null;
|
||||||
|
|
||||||
|
// Count of in-flight getRawData calls; drives the body "loading" cursor.
|
||||||
|
let loadingCount = 0;
|
||||||
|
function markLoading(delta) {
|
||||||
|
loadingCount += delta;
|
||||||
|
document.body.classList.toggle("loading", loadingCount > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache frequently accessed elements.
|
||||||
|
const debugButton = document.getElementById("debug-button");
|
||||||
|
const debugBackButton = document.getElementById("debug-back-button");
|
||||||
|
const debugViewEl = document.getElementById("debug-view");
|
||||||
|
const treeEl = document.getElementById("tree");
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const gotoInput = document.getElementById("goto-input");
|
||||||
|
const pdfInfoEl = document.getElementById("pdf-info");
|
||||||
|
|
||||||
|
const pageView = new PageView({ onMarkLoading: markLoading });
|
||||||
|
|
||||||
|
const treeView = new TreeView(treeEl, { onMarkLoading: markLoading });
|
||||||
|
|
||||||
|
async function loadTree(data, rootLabel = null) {
|
||||||
|
currentPage = typeof data.page === "number" ? data.page : null;
|
||||||
|
debugButton.hidden = currentPage === null;
|
||||||
|
debugBackButton.hidden = true;
|
||||||
|
pageView.reset();
|
||||||
|
debugViewEl.hidden = true;
|
||||||
|
treeEl.hidden = false;
|
||||||
|
await treeView.load(data, rootLabel, pdfDoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDocument(source, name) {
|
||||||
|
statusEl.textContent = `Loading ${name}…`;
|
||||||
|
pdfInfoEl.textContent = "";
|
||||||
|
treeView.clearCache();
|
||||||
|
|
||||||
|
if (pdfDoc) {
|
||||||
|
pageView.reset();
|
||||||
|
await pdfDoc.destroy();
|
||||||
|
pdfDoc = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingTask = getDocument({
|
||||||
|
...source,
|
||||||
|
cMapUrl:
|
||||||
|
typeof PDFJSDev === "undefined" ? "../external/bcmaps/" : "../web/cmaps/",
|
||||||
|
iccUrl:
|
||||||
|
typeof PDFJSDev === "undefined" ? "../external/iccs/" : "../web/iccs/",
|
||||||
|
standardFontDataUrl:
|
||||||
|
typeof PDFJSDev === "undefined"
|
||||||
|
? "../external/standard_fonts/"
|
||||||
|
: "../web/standard_fonts/",
|
||||||
|
wasmUrl: "../web/wasm/",
|
||||||
|
useWorkerFetch: true,
|
||||||
|
pdfBug: true,
|
||||||
|
CanvasFactory: pageView.DebugCanvasFactory,
|
||||||
|
});
|
||||||
|
loadingTask.onPassword = (updateCallback, reason) => {
|
||||||
|
const dialog = document.getElementById("password-dialog");
|
||||||
|
const title = document.getElementById("password-dialog-title");
|
||||||
|
const input = document.getElementById("password-input");
|
||||||
|
const cancelButton = document.getElementById("password-cancel");
|
||||||
|
|
||||||
|
title.textContent =
|
||||||
|
reason === PasswordResponses.INCORRECT_PASSWORD
|
||||||
|
? "Incorrect password. Please try again:"
|
||||||
|
: "This PDF is password-protected. Please enter the password:";
|
||||||
|
input.value = "";
|
||||||
|
dialog.showModal();
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
dialog.removeEventListener("close", onSubmit);
|
||||||
|
cancelButton.removeEventListener("click", onCancel);
|
||||||
|
};
|
||||||
|
const onSubmit = () => {
|
||||||
|
cleanup();
|
||||||
|
updateCallback(input.value);
|
||||||
|
};
|
||||||
|
const onCancel = () => {
|
||||||
|
cleanup();
|
||||||
|
dialog.close();
|
||||||
|
updateCallback(new Error("Password prompt cancelled."));
|
||||||
|
};
|
||||||
|
|
||||||
|
dialog.addEventListener("close", onSubmit, { once: true });
|
||||||
|
cancelButton.addEventListener("click", onCancel, { once: true });
|
||||||
|
};
|
||||||
|
pdfDoc = await loadingTask.promise;
|
||||||
|
const plural = pdfDoc.numPages !== 1 ? "s" : "";
|
||||||
|
pdfInfoEl.textContent = `${name} — ${pdfDoc.numPages} page${plural}`;
|
||||||
|
statusEl.textContent = "";
|
||||||
|
gotoInput.disabled = false;
|
||||||
|
gotoInput.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(err) {
|
||||||
|
statusEl.textContent = `Error: ${err.message}`;
|
||||||
|
treeView.showError(err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("file-input").value = "";
|
||||||
|
|
||||||
|
document
|
||||||
|
.getElementById("file-input")
|
||||||
|
.addEventListener("change", async ({ target }) => {
|
||||||
|
const file = target.files[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await openDocument({ data: await file.arrayBuffer() }, file.name);
|
||||||
|
await loadTree({ ref: null }, "Trailer");
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const searchParams = new URLSearchParams(location.search);
|
||||||
|
const hashParams = new URLSearchParams(location.hash.slice(1));
|
||||||
|
const fileUrl = searchParams.get("file");
|
||||||
|
if (!fileUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await openDocument({ url: fileUrl }, fileUrl.split("/").pop());
|
||||||
|
const refStr = hashParams.get("ref");
|
||||||
|
const pageStr = hashParams.get("page");
|
||||||
|
if (refStr) {
|
||||||
|
const ref = parseRefInput(refStr);
|
||||||
|
if (ref) {
|
||||||
|
gotoInput.value = refStr;
|
||||||
|
await loadTree({ ref });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pageStr) {
|
||||||
|
const page = parseInt(pageStr, 10);
|
||||||
|
if (Number.isInteger(page) && page >= 1 && page <= pdfDoc.numPages) {
|
||||||
|
gotoInput.value = pageStr;
|
||||||
|
await loadTree({ page });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadTree({ ref: null }, "Trailer");
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
gotoInput.addEventListener("keydown", async ({ key, target }) => {
|
||||||
|
if (key !== "Enter" || !pdfDoc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.value.trim() === "") {
|
||||||
|
target.removeAttribute("aria-invalid");
|
||||||
|
await loadTree({ ref: null }, "Trailer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = parseGoToInput(target.value);
|
||||||
|
if (!result) {
|
||||||
|
target.setAttribute("aria-invalid", "true");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
result.page !== undefined &&
|
||||||
|
(result.page < 1 || result.page > pdfDoc.numPages)
|
||||||
|
) {
|
||||||
|
target.setAttribute("aria-invalid", "true");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.removeAttribute("aria-invalid");
|
||||||
|
await (result.page !== undefined
|
||||||
|
? loadTree({ page: result.page })
|
||||||
|
: loadTree({ ref: result.ref }));
|
||||||
|
});
|
||||||
|
|
||||||
|
gotoInput.addEventListener("input", ({ target }) => {
|
||||||
|
if (target.value.trim() === "") {
|
||||||
|
target.removeAttribute("aria-invalid");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
debugButton.addEventListener("click", async () => {
|
||||||
|
debugButton.hidden = treeEl.hidden = true;
|
||||||
|
debugBackButton.hidden = debugViewEl.hidden = false;
|
||||||
|
// Only render if not already loaded for this page; re-entering from the
|
||||||
|
// back button keeps the existing debug state (op-list, canvas, breakpoints).
|
||||||
|
await pageView.show(pdfDoc, currentPage);
|
||||||
|
});
|
||||||
|
|
||||||
|
debugBackButton.addEventListener("click", () => {
|
||||||
|
debugBackButton.hidden = debugViewEl.hidden = true;
|
||||||
|
debugButton.hidden = treeEl.hidden = false;
|
||||||
|
});
|
||||||
205
web/internal/draw_ops_view.css
Normal file
205
web/internal/draw_ops_view.css
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Hidden color-picker input reused by all color swatches. */
|
||||||
|
.color-picker-input {
|
||||||
|
position: fixed;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MultilineView instance used as the op-list panel in the debug view. */
|
||||||
|
.op-list-panel-wrapper {
|
||||||
|
flex: 7 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
& > .mlc-goto-bar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .mlc-body > .mlc-inner {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--surface-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mlc-line-nums-col {
|
||||||
|
padding-block: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#op-list-panel {
|
||||||
|
flex: 7 1 0;
|
||||||
|
overflow: auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--surface-bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
#op-list {
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
#op-detail-panel {
|
||||||
|
flex: 3 1 0;
|
||||||
|
container-type: size;
|
||||||
|
overflow: auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--surface-bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
.detail-name {
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.detail-empty {
|
||||||
|
color: var(--muted-color);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
.detail-idx {
|
||||||
|
color: var(--muted-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.detail-val {
|
||||||
|
color: var(--number-color);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.detail-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.detail-args-col {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.detail-img-col {
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: 45%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
height: 90cqh;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.path-preview {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--border-subtle-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.op-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5ch;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
color: var(--hover-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.op-name {
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.op-arg {
|
||||||
|
color: var(--number-color);
|
||||||
|
}
|
||||||
|
.changed-value {
|
||||||
|
font-weight: bold;
|
||||||
|
background: var(--changed-bg);
|
||||||
|
color: var(--changed-color);
|
||||||
|
}
|
||||||
|
.bp-gutter {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "●";
|
||||||
|
color: var(--changed-color);
|
||||||
|
font-size: 0.75em;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::before {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.op-line.paused {
|
||||||
|
background: var(--paused-bg);
|
||||||
|
color: var(--paused-color);
|
||||||
|
outline: 1px solid var(--paused-outline-color);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
.color-swatch {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--muted-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
/* Opacity trick for breakpoint glyph visibility → use Canvas color to hide. */
|
||||||
|
.bp-gutter::before {
|
||||||
|
opacity: 1;
|
||||||
|
color: Canvas;
|
||||||
|
}
|
||||||
|
.bp-gutter:hover::before {
|
||||||
|
color: ButtonBorder;
|
||||||
|
}
|
||||||
|
.bp-gutter.active::before {
|
||||||
|
color: ButtonText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color swatch preserves the actual PDF color value. */
|
||||||
|
.color-swatch {
|
||||||
|
forced-color-adjust: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
659
web/internal/draw_ops_view.js
Normal file
659
web/internal/draw_ops_view.js
Normal file
@ -0,0 +1,659 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ImageKind, OPS } from "pdfjs-lib";
|
||||||
|
import { makePathFromDrawOPS } from "pdfjs/display/display_utils.js";
|
||||||
|
import { MultilineView } from "./multiline_view.js";
|
||||||
|
|
||||||
|
// Reverse map: OPS numeric id → string name, built once from the OPS object.
|
||||||
|
const OPS_TO_NAME = Object.create(null);
|
||||||
|
for (const [name, id] of Object.entries(OPS)) {
|
||||||
|
OPS_TO_NAME[id] = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single hidden color input reused for all swatch pickers.
|
||||||
|
const colorPickerInput = document.createElement("input");
|
||||||
|
colorPickerInput.type = "color";
|
||||||
|
colorPickerInput.className = "color-picker-input";
|
||||||
|
|
||||||
|
// AbortController for the currently open color-picker session (if any).
|
||||||
|
let _colorPickerAc = null;
|
||||||
|
|
||||||
|
function ensureColorPickerInput() {
|
||||||
|
if (!colorPickerInput.isConnected) {
|
||||||
|
document.body.append(colorPickerInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openColorPicker(hex, onPick) {
|
||||||
|
// Cancel any previous session that was dismissed without a change event.
|
||||||
|
_colorPickerAc?.abort();
|
||||||
|
ensureColorPickerInput();
|
||||||
|
colorPickerInput.value = hex;
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
_colorPickerAc = ac;
|
||||||
|
colorPickerInput.addEventListener(
|
||||||
|
"input",
|
||||||
|
() => {
|
||||||
|
onPick(colorPickerInput.value);
|
||||||
|
},
|
||||||
|
{ signal: ac.signal }
|
||||||
|
);
|
||||||
|
colorPickerInput.addEventListener(
|
||||||
|
"change",
|
||||||
|
() => {
|
||||||
|
ac.abort();
|
||||||
|
},
|
||||||
|
{ once: true, signal: ac.signal }
|
||||||
|
);
|
||||||
|
colorPickerInput.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a color swatch. If `onPick` is provided the swatch is clickable and
|
||||||
|
// opens the browser color picker; onPick(newHex) is called on each change.
|
||||||
|
function makeColorSwatch(hex, onPick) {
|
||||||
|
const swatch = document.createElement("span");
|
||||||
|
swatch.className = "color-swatch";
|
||||||
|
swatch.style.background = hex;
|
||||||
|
if (onPick) {
|
||||||
|
swatch.role = "button";
|
||||||
|
swatch.tabIndex = 0;
|
||||||
|
swatch.ariaLabel = "Change color";
|
||||||
|
swatch.title = "Click to change color";
|
||||||
|
const activate = e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openColorPicker(hex, newHex => {
|
||||||
|
hex = newHex;
|
||||||
|
swatch.style.background = newHex;
|
||||||
|
onPick(newHex);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
swatch.addEventListener("click", activate);
|
||||||
|
swatch.addEventListener("keydown", e => {
|
||||||
|
if (e.key !== "Enter" && e.key !== " ") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
activate(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return swatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formats a glyph items array as: "text" kerning "more text" …
|
||||||
|
function formatGlyphItems(items) {
|
||||||
|
const parts = [];
|
||||||
|
let str = "";
|
||||||
|
for (const item of items) {
|
||||||
|
if (typeof item === "number") {
|
||||||
|
if (str) {
|
||||||
|
parts.push(JSON.stringify(str));
|
||||||
|
str = "";
|
||||||
|
}
|
||||||
|
parts.push(String(Math.round(item * 100) / 100));
|
||||||
|
} else if (item?.unicode) {
|
||||||
|
str += item.unicode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (str) {
|
||||||
|
parts.push(JSON.stringify(str));
|
||||||
|
}
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an operator argument for display.
|
||||||
|
* @param {*} arg The argument value.
|
||||||
|
* @param {boolean} full true → expand fully (detail panel);
|
||||||
|
* false → truncate for compact list display.
|
||||||
|
*/
|
||||||
|
function formatArg(arg, full) {
|
||||||
|
if (arg === null || arg === undefined) {
|
||||||
|
return full ? "null" : "";
|
||||||
|
}
|
||||||
|
if (typeof arg === "number") {
|
||||||
|
return Number.isInteger(arg)
|
||||||
|
? String(arg)
|
||||||
|
: String(Math.round(arg * 10000) / 10000);
|
||||||
|
}
|
||||||
|
if (typeof arg === "string") {
|
||||||
|
return JSON.stringify(arg);
|
||||||
|
}
|
||||||
|
if (typeof arg === "boolean") {
|
||||||
|
return String(arg);
|
||||||
|
}
|
||||||
|
if (ArrayBuffer.isView(arg)) {
|
||||||
|
if (!full && arg.length > 8) {
|
||||||
|
return `<${arg.length} values>`;
|
||||||
|
}
|
||||||
|
const fmt = n => (Number.isInteger(n) ? n : Math.round(n * 1000) / 1000);
|
||||||
|
return `[${Array.from(arg).map(fmt).join(" ")}]`;
|
||||||
|
}
|
||||||
|
if (Array.isArray(arg)) {
|
||||||
|
if (arg.length === 0) {
|
||||||
|
return "[]";
|
||||||
|
}
|
||||||
|
if (!full && arg.length > 4) {
|
||||||
|
return `[…${arg.length}]`;
|
||||||
|
}
|
||||||
|
return `[${arg.map(a => formatArg(a, full)).join(", ")}]`;
|
||||||
|
}
|
||||||
|
if (typeof arg === "object") {
|
||||||
|
if (!full) {
|
||||||
|
return "{…}";
|
||||||
|
}
|
||||||
|
return `{${Object.entries(arg)
|
||||||
|
.map(([k, v]) => `${k}: ${formatArg(v, true)}`)
|
||||||
|
.join(", ")}}`;
|
||||||
|
}
|
||||||
|
return String(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DrawOpDetailView {
|
||||||
|
#el;
|
||||||
|
|
||||||
|
#prefersDark;
|
||||||
|
|
||||||
|
constructor(detailPanelEl, { prefersDark }) {
|
||||||
|
this.#el = detailPanelEl;
|
||||||
|
this.#prefersDark = prefersDark;
|
||||||
|
}
|
||||||
|
|
||||||
|
show(
|
||||||
|
name,
|
||||||
|
args,
|
||||||
|
opIdx,
|
||||||
|
{ originalColors, renderedPage, selectedLine = null }
|
||||||
|
) {
|
||||||
|
const detailEl = this.#el;
|
||||||
|
detailEl.replaceChildren();
|
||||||
|
|
||||||
|
// Always build args into a .detail-args-col so it can be placed in a
|
||||||
|
// .detail-body alongside a path preview or image preview on the right.
|
||||||
|
const argsContainer = document.createElement("div");
|
||||||
|
argsContainer.className = "detail-args-col";
|
||||||
|
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "detail-name";
|
||||||
|
header.textContent = name;
|
||||||
|
argsContainer.append(header);
|
||||||
|
|
||||||
|
if (!args || args.length === 0) {
|
||||||
|
const none = document.createElement("div");
|
||||||
|
none.className = "detail-empty";
|
||||||
|
none.textContent = "(no arguments)";
|
||||||
|
argsContainer.append(none);
|
||||||
|
detailEl.append(argsContainer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagePreviews = [];
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "detail-row";
|
||||||
|
const idx = document.createElement("span");
|
||||||
|
idx.className = "detail-idx";
|
||||||
|
idx.textContent = `[${i}]`;
|
||||||
|
const val = document.createElement("span");
|
||||||
|
val.className = "detail-val";
|
||||||
|
if (name === "showText" && i === 0 && Array.isArray(args[0])) {
|
||||||
|
val.textContent = formatGlyphItems(args[0]);
|
||||||
|
} else if (
|
||||||
|
name === "constructPath" &&
|
||||||
|
i === 0 &&
|
||||||
|
typeof args[0] === "number"
|
||||||
|
) {
|
||||||
|
val.textContent = OPS_TO_NAME[args[0]] ?? String(args[0]);
|
||||||
|
} else {
|
||||||
|
val.textContent = formatArg(args[i], true);
|
||||||
|
}
|
||||||
|
row.append(idx);
|
||||||
|
if (typeof args[i] === "string" && /^#[0-9a-f]{6}$/i.test(args[i])) {
|
||||||
|
const argIdx = i;
|
||||||
|
const originalHex = originalColors.get(opIdx);
|
||||||
|
if (originalHex && args[i] !== originalHex) {
|
||||||
|
val.classList.add("changed-value");
|
||||||
|
val.title = `Original: ${originalHex}`;
|
||||||
|
}
|
||||||
|
row.append(
|
||||||
|
makeColorSwatch(args[i], newHex => {
|
||||||
|
args[argIdx] = newHex;
|
||||||
|
val.textContent = JSON.stringify(newHex);
|
||||||
|
const changed = originalHex && newHex !== originalHex;
|
||||||
|
val.classList.toggle("changed-value", !!changed);
|
||||||
|
val.title = changed ? `Original: ${originalHex}` : "";
|
||||||
|
// Also update the swatch and arg span in the selected op list line.
|
||||||
|
const listSwatch = selectedLine?.querySelector(".color-swatch");
|
||||||
|
if (listSwatch) {
|
||||||
|
listSwatch.style.background = newHex;
|
||||||
|
}
|
||||||
|
const listArgSpan = selectedLine?.querySelector(".op-arg");
|
||||||
|
if (listArgSpan) {
|
||||||
|
listArgSpan.textContent = JSON.stringify(newHex);
|
||||||
|
listArgSpan.classList.toggle("changed-value", !!changed);
|
||||||
|
listArgSpan.title = changed ? `Original: ${originalHex}` : "";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
row.append(val);
|
||||||
|
argsContainer.append(row);
|
||||||
|
if (typeof args[i] === "string" && args[i].startsWith("img_")) {
|
||||||
|
const preview = this.#makeImageArgPreview(args[i], renderedPage);
|
||||||
|
if (preview) {
|
||||||
|
imagePreviews.push(preview);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assemble the final layout: constructPath gets a path preview on the
|
||||||
|
// right; image ops get an image column on the right; others just use
|
||||||
|
// argsContainer.
|
||||||
|
if (name === "constructPath") {
|
||||||
|
// args[1] is [Float32Array|null], args[2] is [minX,minY,maxX,maxY]|null
|
||||||
|
const data = Array.isArray(args?.[1]) ? args[1][0] : null;
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "detail-body";
|
||||||
|
body.append(
|
||||||
|
argsContainer,
|
||||||
|
this.#renderPathPreview(data, args?.[2] ?? null)
|
||||||
|
);
|
||||||
|
detailEl.append(body);
|
||||||
|
} else if (imagePreviews.length > 0) {
|
||||||
|
const imgCol = document.createElement("div");
|
||||||
|
imgCol.className = "detail-img-col";
|
||||||
|
imgCol.append(...imagePreviews);
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "detail-body";
|
||||||
|
body.append(argsContainer, imgCol);
|
||||||
|
detailEl.append(body);
|
||||||
|
} else {
|
||||||
|
detailEl.append(argsContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.#el.replaceChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderPathPreview(data, minMax) {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.className = "path-preview";
|
||||||
|
|
||||||
|
const [minX, minY, maxX, maxY] = minMax ?? [];
|
||||||
|
const pathW = maxX - minX || 1;
|
||||||
|
const pathH = maxY - minY || 1;
|
||||||
|
if (!data || !minMax || !(pathW > 0) || !(pathH > 0)) {
|
||||||
|
canvas.width = canvas.height = 1;
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PADDING = 10; // px
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const drawW = Math.min(200, 200 * (pathW / pathH));
|
||||||
|
const drawH = Math.min(200, 200 * (pathH / pathW));
|
||||||
|
const scale = Math.min(drawW / pathW, drawH / pathH);
|
||||||
|
|
||||||
|
canvas.width = Math.round((drawW + PADDING * 2) * dpr);
|
||||||
|
canvas.height = Math.round((drawH + PADDING * 2) * dpr);
|
||||||
|
canvas.style.width = `${drawW + PADDING * 2}px`;
|
||||||
|
canvas.style.height = `${drawH + PADDING * 2}px`;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
// PDF user space has Y pointing up; canvas has Y pointing down — flip Y.
|
||||||
|
ctx.translate(PADDING, PADDING + drawH);
|
||||||
|
ctx.scale(scale, -scale);
|
||||||
|
ctx.translate(-minX, -minY);
|
||||||
|
|
||||||
|
ctx.lineWidth = 1 / scale;
|
||||||
|
ctx.strokeStyle = this.#prefersDark.matches ? "#9cdcfe" : "#0070c1";
|
||||||
|
ctx.stroke(data instanceof Path2D ? data : makePathFromDrawOPS(data));
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render an img_ argument value into a canvas preview using the decoded image
|
||||||
|
// stored in renderedPage.objs (or commonObjs for global images starting with
|
||||||
|
// g_). Handles ImageBitmap and raw pixel data with ImageKind values
|
||||||
|
// GRAYSCALE_1BPP, RGB_24BPP, and RGBA_32BPP.
|
||||||
|
#makeImageArgPreview(name, renderedPage) {
|
||||||
|
const objStore = name.startsWith("g_")
|
||||||
|
? renderedPage?.commonObjs
|
||||||
|
: renderedPage?.objs;
|
||||||
|
if (!objStore?.has(name)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const imgObj = objStore.get(name);
|
||||||
|
if (!imgObj) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { width, height } = imgObj;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.className = "image-preview";
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
canvas.style.aspectRatio = `${width} / ${height}`;
|
||||||
|
canvas.ariaLabel = `${name} ${width}×${height}`;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
// Fast path: if the browser already decoded it as an ImageBitmap, draw it.
|
||||||
|
if (imgObj.bitmap instanceof ImageBitmap) {
|
||||||
|
ctx.drawImage(imgObj.bitmap, 0, 0);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: convert raw pixel data to RGBA for putImageData.
|
||||||
|
const { data, kind } = imgObj;
|
||||||
|
let rgba;
|
||||||
|
if (kind === ImageKind.RGBA_32BPP) {
|
||||||
|
rgba = new Uint8ClampedArray(
|
||||||
|
data.buffer,
|
||||||
|
data.byteOffset,
|
||||||
|
data.byteLength
|
||||||
|
);
|
||||||
|
} else if (kind === ImageKind.RGB_24BPP) {
|
||||||
|
const pixels = width * height;
|
||||||
|
rgba = new Uint8ClampedArray(pixels * 4);
|
||||||
|
for (let i = 0, j = 0; i < pixels; i++, j += 3) {
|
||||||
|
rgba[i * 4] = data[j];
|
||||||
|
rgba[i * 4 + 1] = data[j + 1];
|
||||||
|
rgba[i * 4 + 2] = data[j + 2];
|
||||||
|
rgba[i * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
} else if (kind === ImageKind.GRAYSCALE_1BPP) {
|
||||||
|
const rowBytes = (width + 7) >> 3;
|
||||||
|
rgba = new Uint8ClampedArray(width * height * 4);
|
||||||
|
for (let row = 0; row < height; row++) {
|
||||||
|
const srcRow = row * rowBytes;
|
||||||
|
const dstRow = row * width * 4;
|
||||||
|
for (let col = 0; col < width; col++) {
|
||||||
|
const bit = (data[srcRow + (col >> 3)] >> (7 - (col & 7))) & 1;
|
||||||
|
const v = bit ? 255 : 0;
|
||||||
|
rgba[dstRow + col * 4] = v;
|
||||||
|
rgba[dstRow + col * 4 + 1] = v;
|
||||||
|
rgba[dstRow + col * 4 + 2] = v;
|
||||||
|
rgba[dstRow + col * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ctx.putImageData(new ImageData(rgba, width, height), 0, 0);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DrawOpsView {
|
||||||
|
#listPanelEl;
|
||||||
|
|
||||||
|
#detailView;
|
||||||
|
|
||||||
|
#multilineView = null;
|
||||||
|
|
||||||
|
#opLines = [];
|
||||||
|
|
||||||
|
#selectedLine = null;
|
||||||
|
|
||||||
|
#breakpoints = new Set();
|
||||||
|
|
||||||
|
#originalColors = new Map();
|
||||||
|
|
||||||
|
#renderedPage = null;
|
||||||
|
|
||||||
|
#pausedAtIdx = null;
|
||||||
|
|
||||||
|
#onHighlight;
|
||||||
|
|
||||||
|
#onClearHighlight;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
opListPanelEl,
|
||||||
|
detailPanelEl,
|
||||||
|
{ onHighlight, onClearHighlight, prefersDark }
|
||||||
|
) {
|
||||||
|
this.#listPanelEl = opListPanelEl;
|
||||||
|
this.#detailView = new DrawOpDetailView(detailPanelEl, { prefersDark });
|
||||||
|
this.#onHighlight = onHighlight;
|
||||||
|
this.#onClearHighlight = onClearHighlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
get breakpoints() {
|
||||||
|
return this.#breakpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
load(opList, renderedPage) {
|
||||||
|
this.#renderedPage = renderedPage;
|
||||||
|
this.#opLines = [];
|
||||||
|
const opTexts = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < opList.fnArray.length; i++) {
|
||||||
|
const name = OPS_TO_NAME[opList.fnArray[i]] ?? `op${opList.fnArray[i]}`;
|
||||||
|
const args = opList.argsArray[i] ?? [];
|
||||||
|
const { line, text } = this.#buildLine(i, name, args);
|
||||||
|
this.#opLines.push(line);
|
||||||
|
opTexts.push(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const multilineView = new MultilineView({
|
||||||
|
total: opList.fnArray.length,
|
||||||
|
getText: i => opTexts[i],
|
||||||
|
makeLineEl: (i, isHighlighted) => {
|
||||||
|
this.#opLines[i].classList.toggle("mlc-match", isHighlighted);
|
||||||
|
return this.#opLines[i];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
multilineView.element.classList.add("op-list-panel-wrapper");
|
||||||
|
multilineView.inner.id = "op-list";
|
||||||
|
multilineView.inner.role = "listbox";
|
||||||
|
multilineView.inner.ariaLabel = "Operator list";
|
||||||
|
|
||||||
|
multilineView.inner.addEventListener("keydown", e => {
|
||||||
|
const { key } = e;
|
||||||
|
const lines = this.#opLines;
|
||||||
|
if (!lines.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const focused = document.activeElement;
|
||||||
|
const currentIdx = lines.indexOf(focused);
|
||||||
|
let targetIdx = -1;
|
||||||
|
if (key === "ArrowDown") {
|
||||||
|
targetIdx = currentIdx < lines.length - 1 ? currentIdx + 1 : currentIdx;
|
||||||
|
} else if (key === "ArrowUp") {
|
||||||
|
targetIdx = currentIdx > 0 ? currentIdx - 1 : 0;
|
||||||
|
} else if (key === "Home") {
|
||||||
|
targetIdx = 0;
|
||||||
|
} else if (key === "End") {
|
||||||
|
targetIdx = lines.length - 1;
|
||||||
|
} else if (key === "Enter" || key === " ") {
|
||||||
|
if (currentIdx >= 0) {
|
||||||
|
lines[currentIdx].click();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
if (targetIdx >= 0) {
|
||||||
|
lines[targetIdx].tabIndex = 0;
|
||||||
|
if (currentIdx >= 0 && currentIdx !== targetIdx) {
|
||||||
|
lines[currentIdx].tabIndex = -1;
|
||||||
|
}
|
||||||
|
multilineView.scrollToLine(targetIdx);
|
||||||
|
lines[targetIdx].focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#listPanelEl.replaceWith(multilineView.element);
|
||||||
|
this.#multilineView = multilineView;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
if (this.#multilineView) {
|
||||||
|
this.#multilineView.destroy();
|
||||||
|
this.#multilineView.element.replaceWith(this.#listPanelEl);
|
||||||
|
this.#multilineView = null;
|
||||||
|
}
|
||||||
|
document.getElementById("op-list").replaceChildren();
|
||||||
|
this.#detailView.clear();
|
||||||
|
this.#opLines = [];
|
||||||
|
this.#selectedLine = null;
|
||||||
|
this.#originalColors.clear();
|
||||||
|
this.#breakpoints.clear();
|
||||||
|
this.#pausedAtIdx = this.#renderedPage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
markPaused(i) {
|
||||||
|
if (this.#pausedAtIdx !== null) {
|
||||||
|
this.#opLines[this.#pausedAtIdx]?.classList.remove("paused");
|
||||||
|
}
|
||||||
|
this.#pausedAtIdx = i;
|
||||||
|
this.#opLines[i]?.classList.add("paused");
|
||||||
|
this.#multilineView?.scrollToLine(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPaused() {
|
||||||
|
if (this.#pausedAtIdx !== null) {
|
||||||
|
this.#opLines[this.#pausedAtIdx]?.classList.remove("paused");
|
||||||
|
this.#pausedAtIdx = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The evaluator normalizes all color ops to setFillRGBColor /
|
||||||
|
// setStrokeRGBColor with args = ["#rrggbb"]. Return that hex string, or null.
|
||||||
|
#getOpColor(name, args) {
|
||||||
|
if (
|
||||||
|
(name === "setFillRGBColor" || name === "setStrokeRGBColor") &&
|
||||||
|
typeof args?.[0] === "string" &&
|
||||||
|
/^#[0-9a-f]{6}$/i.test(args[0])
|
||||||
|
) {
|
||||||
|
return args[0];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#buildLine(i, name, args) {
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.className = "op-line";
|
||||||
|
line.role = "option";
|
||||||
|
line.ariaSelected = "false";
|
||||||
|
line.tabIndex = i === 0 ? 0 : -1;
|
||||||
|
|
||||||
|
// Breakpoint gutter — click to toggle a red-bullet breakpoint.
|
||||||
|
const gutter = document.createElement("span");
|
||||||
|
gutter.className = "bp-gutter";
|
||||||
|
gutter.role = "checkbox";
|
||||||
|
gutter.tabIndex = 0;
|
||||||
|
gutter.ariaLabel = "Breakpoint";
|
||||||
|
const isInitiallyActive = this.#breakpoints.has(i);
|
||||||
|
gutter.ariaChecked = String(isInitiallyActive);
|
||||||
|
if (isInitiallyActive) {
|
||||||
|
gutter.classList.add("active");
|
||||||
|
}
|
||||||
|
gutter.addEventListener("click", e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (this.#breakpoints.has(i)) {
|
||||||
|
this.#breakpoints.delete(i);
|
||||||
|
gutter.classList.remove("active");
|
||||||
|
gutter.ariaChecked = "false";
|
||||||
|
} else {
|
||||||
|
this.#breakpoints.add(i);
|
||||||
|
gutter.classList.add("active");
|
||||||
|
gutter.ariaChecked = "true";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
gutter.addEventListener("keydown", e => {
|
||||||
|
if (e.key === " " || e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
gutter.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
line.append(gutter);
|
||||||
|
|
||||||
|
const nameEl = document.createElement("span");
|
||||||
|
nameEl.className = "op-name";
|
||||||
|
nameEl.textContent = name;
|
||||||
|
line.append(nameEl);
|
||||||
|
const rgb = this.#getOpColor(name, args);
|
||||||
|
let colorArgSpan = null;
|
||||||
|
if (rgb) {
|
||||||
|
this.#originalColors.set(i, rgb);
|
||||||
|
line.append(
|
||||||
|
makeColorSwatch(rgb, newHex => {
|
||||||
|
args[0] = newHex;
|
||||||
|
if (colorArgSpan) {
|
||||||
|
const changed = newHex !== rgb;
|
||||||
|
colorArgSpan.textContent = JSON.stringify(newHex);
|
||||||
|
colorArgSpan.classList.toggle("changed-value", changed);
|
||||||
|
colorArgSpan.title = changed ? `Original: ${rgb}` : "";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build arg spans and plain-text representation for search in one pass.
|
||||||
|
let text = name;
|
||||||
|
if (name === "showText" && Array.isArray(args[0])) {
|
||||||
|
const formatted = formatGlyphItems(args[0]);
|
||||||
|
const argEl = document.createElement("span");
|
||||||
|
argEl.className = "op-arg";
|
||||||
|
argEl.textContent = formatted;
|
||||||
|
line.append(argEl);
|
||||||
|
text += " " + formatted;
|
||||||
|
} else {
|
||||||
|
for (let j = 0; j < args.length; j++) {
|
||||||
|
const s =
|
||||||
|
name === "constructPath" && j === 0 && typeof args[0] === "number"
|
||||||
|
? (OPS_TO_NAME[args[0]] ?? String(args[0]))
|
||||||
|
: formatArg(args[j], false);
|
||||||
|
if (s) {
|
||||||
|
const argEl = document.createElement("span");
|
||||||
|
argEl.className = "op-arg";
|
||||||
|
argEl.textContent = s;
|
||||||
|
line.append(argEl);
|
||||||
|
if (rgb && j === 0) {
|
||||||
|
colorArgSpan = argEl;
|
||||||
|
}
|
||||||
|
text += " " + s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
line.addEventListener("pointerenter", () => this.#onHighlight(i));
|
||||||
|
line.addEventListener("pointerleave", () => this.#onClearHighlight());
|
||||||
|
line.addEventListener("click", () => {
|
||||||
|
if (this.#selectedLine) {
|
||||||
|
this.#selectedLine.classList.remove("selected");
|
||||||
|
this.#selectedLine.ariaSelected = "false";
|
||||||
|
this.#selectedLine.tabIndex = -1;
|
||||||
|
}
|
||||||
|
this.#selectedLine = line;
|
||||||
|
line.classList.add("selected");
|
||||||
|
line.ariaSelected = "true";
|
||||||
|
line.tabIndex = 0;
|
||||||
|
this.#detailView.show(name, args, i, {
|
||||||
|
originalColors: this.#originalColors,
|
||||||
|
renderedPage: this.#renderedPage,
|
||||||
|
selectedLine: line,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { line, text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DrawOpsView };
|
||||||
200
web/internal/multiline_view.css
Normal file
200
web/internal/multiline_view.css
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mlc-scroll {
|
||||||
|
color-scheme: light dark;
|
||||||
|
|
||||||
|
--surface-bg: light-dark(#f3f3f3, #252526);
|
||||||
|
--input-bg: light-dark(#fff, #3c3c3c);
|
||||||
|
--button-bg: light-dark(#f3f3f3, #3c3c3c);
|
||||||
|
--button-hover-bg: light-dark(#e0e0e0, #4a4a4a);
|
||||||
|
--text-color: light-dark(#1e1e1e, #d4d4d4);
|
||||||
|
--muted-color: light-dark(#6e6e6e, #888);
|
||||||
|
--accent-color: light-dark(#0070c1, #9cdcfe);
|
||||||
|
--border-subtle-color: light-dark(#d0d0d0, #444);
|
||||||
|
--input-border-color: light-dark(#c8c8c8, #555);
|
||||||
|
--match-bg: light-dark(rgb(255 200 0 / 0.35), rgb(255 200 0 / 0.25));
|
||||||
|
--match-outline-color: light-dark(rgb(200 140 0 / 0.8), rgb(255 200 0 / 0.6));
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--border-subtle-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.mlc-load-sentinel {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row wrapper that sits between the toolbar and the scrollable content.
|
||||||
|
Hosts the frozen line-number column and the actual scroll container. */
|
||||||
|
.mlc-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
min-height: 0;
|
||||||
|
line-height: 1.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The line-number column lives *outside* the scroll container so it is
|
||||||
|
never affected by horizontal or vertical scroll. Its scrollTop is kept
|
||||||
|
in sync with the adjacent scroll container via a JS scroll listener. */
|
||||||
|
.mlc-line-nums-col {
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--surface-bg);
|
||||||
|
border-inline-end: 1px solid var(--border-subtle-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mlc-inner {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
/* Disable scroll anchoring so manual scrollTop corrections aren't doubled. */
|
||||||
|
overflow-anchor: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mlc-goto-bar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 3px 4px;
|
||||||
|
background: var(--surface-bg);
|
||||||
|
border-bottom: 1px solid var(--border-subtle-color);
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
.mlc-search-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mlc-search-input,
|
||||||
|
.mlc-goto {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--input-border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 2px solid var(--accent-color);
|
||||||
|
outline-offset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[aria-invalid="true"] {
|
||||||
|
border-color: red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mlc-search-input {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mlc-goto {
|
||||||
|
width: 110px;
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mlc-nav-button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid var(--input-border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--button-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--button-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[aria-pressed="true"] {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: light-dark(white, black);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mlc-match-info {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--muted-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 4ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mlc-check-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mlc-num-item {
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: var(--line-num-width, 3ch);
|
||||||
|
padding-inline: 0.4em;
|
||||||
|
text-align: right;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--muted-color);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mlc-num-item.mlc-match {
|
||||||
|
background: var(--match-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mlc-match {
|
||||||
|
background: var(--match-bg);
|
||||||
|
outline: 1px solid var(--match-outline-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
.mlc-scroll {
|
||||||
|
--surface-bg: Canvas;
|
||||||
|
--input-bg: Field;
|
||||||
|
--button-bg: ButtonFace;
|
||||||
|
--button-hover-bg: Highlight;
|
||||||
|
--text-color: CanvasText;
|
||||||
|
--muted-color: GrayText;
|
||||||
|
--accent-color: CanvasText;
|
||||||
|
--border-subtle-color: ButtonBorder;
|
||||||
|
--input-border-color: ButtonBorder;
|
||||||
|
--match-bg: Mark;
|
||||||
|
--match-outline-color: ButtonBorder;
|
||||||
|
|
||||||
|
.mlc-search-input[aria-invalid="true"],
|
||||||
|
.mlc-goto[aria-invalid="true"] {
|
||||||
|
border-color: ButtonBorder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
517
web/internal/multiline_view.js
Normal file
517
web/internal/multiline_view.js
Normal file
@ -0,0 +1,517 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Number of rows rendered per batch (IntersectionObserver batching).
|
||||||
|
const BATCH_SIZE = 500;
|
||||||
|
// Maximum rows kept in the DOM at once (two batches).
|
||||||
|
const MAX_RENDERED = BATCH_SIZE * 2;
|
||||||
|
|
||||||
|
let _idCounter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scrollable multi-line panel combining:
|
||||||
|
* – a frozen line-number column on the left,
|
||||||
|
* – a scrollable content column on the right,
|
||||||
|
* – a search / go-to-line toolbar at the top,
|
||||||
|
* – IntersectionObserver-based virtual scroll.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const mc = new MultilineView({ total, getText, makeLineEl });
|
||||||
|
* container.append(mc.element);
|
||||||
|
*/
|
||||||
|
class MultilineView {
|
||||||
|
// -- DOM elements --
|
||||||
|
#element;
|
||||||
|
|
||||||
|
#numCol;
|
||||||
|
|
||||||
|
#innerEl;
|
||||||
|
|
||||||
|
#pre;
|
||||||
|
|
||||||
|
#topSentinel;
|
||||||
|
|
||||||
|
#bottomSentinel;
|
||||||
|
|
||||||
|
#observer = null;
|
||||||
|
|
||||||
|
#onScroll = null;
|
||||||
|
|
||||||
|
#total;
|
||||||
|
|
||||||
|
#getText;
|
||||||
|
|
||||||
|
#makeLineEl;
|
||||||
|
|
||||||
|
#startIndex = 0;
|
||||||
|
|
||||||
|
#endIndex = 0;
|
||||||
|
|
||||||
|
#highlightedIndex = -1;
|
||||||
|
|
||||||
|
#searchMatches = [];
|
||||||
|
|
||||||
|
#currentMatchIdx = -1;
|
||||||
|
|
||||||
|
#searchInput;
|
||||||
|
|
||||||
|
#searchError;
|
||||||
|
|
||||||
|
#prevButton;
|
||||||
|
|
||||||
|
#nextButton;
|
||||||
|
|
||||||
|
#matchInfo;
|
||||||
|
|
||||||
|
#ignoreCaseCb;
|
||||||
|
|
||||||
|
#regexCb;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {number} opts.total Total number of lines.
|
||||||
|
* @param {Function} opts.getText (i) => string used for search.
|
||||||
|
* @param {Function} opts.makeLineEl (i, isHighlighted) => HTMLElement.
|
||||||
|
* @param {string} [opts.lineClass] CSS class for the lines container.
|
||||||
|
* @param {HTMLElement} [opts.actions] Element prepended in the toolbar.
|
||||||
|
*/
|
||||||
|
constructor({ total, getText, makeLineEl, lineClass = "", actions = null }) {
|
||||||
|
this.#total = total;
|
||||||
|
this.#getText = getText;
|
||||||
|
this.#makeLineEl = makeLineEl;
|
||||||
|
|
||||||
|
// Root element.
|
||||||
|
this.#element = document.createElement("div");
|
||||||
|
this.#element.className = "mlc-scroll";
|
||||||
|
|
||||||
|
// Line-number column (frozen; scrollTop synced with the scroll pane).
|
||||||
|
this.#numCol = document.createElement("div");
|
||||||
|
this.#numCol.className = "mlc-line-nums-col";
|
||||||
|
this.#numCol.style.setProperty(
|
||||||
|
"--line-num-width",
|
||||||
|
`${String(total).length}ch`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scrollable content column.
|
||||||
|
this.#innerEl = document.createElement("div");
|
||||||
|
this.#innerEl.className = "mlc-inner";
|
||||||
|
this.#onScroll = () => {
|
||||||
|
this.#numCol.scrollTop = this.#innerEl.scrollTop;
|
||||||
|
};
|
||||||
|
this.#innerEl.addEventListener("scroll", this.#onScroll);
|
||||||
|
|
||||||
|
// Item container inside the scroll column.
|
||||||
|
this.#pre = document.createElement("div");
|
||||||
|
if (lineClass) {
|
||||||
|
this.#pre.className = lineClass;
|
||||||
|
}
|
||||||
|
this.#innerEl.append(this.#pre);
|
||||||
|
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "mlc-body";
|
||||||
|
body.append(this.#numCol, this.#innerEl);
|
||||||
|
|
||||||
|
this.#element.append(this.#buildToolbar(actions), body);
|
||||||
|
|
||||||
|
// Sentinels bracket the rendered window inside #pre:
|
||||||
|
// topSentinel [startIndex .. endIndex) bottomSentinel
|
||||||
|
this.#topSentinel = document.createElement("div");
|
||||||
|
this.#topSentinel.className = "mlc-load-sentinel";
|
||||||
|
this.#bottomSentinel = document.createElement("div");
|
||||||
|
this.#bottomSentinel.className = "mlc-load-sentinel";
|
||||||
|
|
||||||
|
this.#endIndex = Math.min(BATCH_SIZE, total);
|
||||||
|
this.#pre.append(
|
||||||
|
this.#topSentinel,
|
||||||
|
this.#renderRange(0, this.#endIndex),
|
||||||
|
this.#bottomSentinel
|
||||||
|
);
|
||||||
|
this.#numCol.append(this.#renderNumRange(0, this.#endIndex));
|
||||||
|
|
||||||
|
if (total > BATCH_SIZE) {
|
||||||
|
this.#setupObserver();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The root element — append to the DOM to display the component. */
|
||||||
|
get element() {
|
||||||
|
return this.#element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The inner content container (between the sentinels). Useful for setting
|
||||||
|
* ARIA attributes and attaching keyboard listeners. */
|
||||||
|
get inner() {
|
||||||
|
return this.#pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll to ensure line i (0-based) is visible without changing the current
|
||||||
|
* search highlight. Useful for programmatic navigation (e.g. a debugger).
|
||||||
|
*/
|
||||||
|
scrollToLine(i) {
|
||||||
|
if (i < 0 || i >= this.#total) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (i >= this.#startIndex && i < this.#endIndex) {
|
||||||
|
this.#scrollRenderedTargetIntoView(i);
|
||||||
|
} else {
|
||||||
|
this.#jumpToTarget(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.#observer?.disconnect();
|
||||||
|
this.#observer = null;
|
||||||
|
|
||||||
|
if (this.#onScroll) {
|
||||||
|
this.#innerEl.removeEventListener("scroll", this.#onScroll);
|
||||||
|
this.#onScroll = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll to line i (0-based) and mark it as the current search highlight.
|
||||||
|
* Pass i = -1 to clear the highlight.
|
||||||
|
*/
|
||||||
|
jumpToLine(i) {
|
||||||
|
this.#pre.querySelector(".mlc-match")?.classList.remove("mlc-match");
|
||||||
|
this.#numCol.querySelector(".mlc-match")?.classList.remove("mlc-match");
|
||||||
|
if (i < 0) {
|
||||||
|
this.#highlightedIndex = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (i >= this.#total) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#highlightedIndex = i;
|
||||||
|
if (i >= this.#startIndex && i < this.#endIndex) {
|
||||||
|
this.#scrollRenderedTargetIntoView(i);
|
||||||
|
} else {
|
||||||
|
this.#jumpToTarget(i);
|
||||||
|
}
|
||||||
|
this.#pre.children[i - this.#startIndex + 1]?.classList.add("mlc-match");
|
||||||
|
this.#numCol.children[i - this.#startIndex]?.classList.add("mlc-match");
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderRange(from, to) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (let i = from; i < to; i++) {
|
||||||
|
frag.append(this.#makeLineEl(i, i === this.#highlightedIndex));
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderNumRange(from, to) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (let i = from; i < to; i++) {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "mlc-num-item";
|
||||||
|
if (i === this.#highlightedIndex) {
|
||||||
|
item.classList.add("mlc-match");
|
||||||
|
}
|
||||||
|
item.textContent = String(i + 1);
|
||||||
|
frag.append(item);
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render a window centred on targetIndex and scroll to it.
|
||||||
|
#jumpToTarget(targetIndex) {
|
||||||
|
// Remove all rendered rows between the sentinels.
|
||||||
|
const firstRow = this.#topSentinel.nextSibling;
|
||||||
|
const lastRow = this.#bottomSentinel.previousSibling;
|
||||||
|
if (firstRow && lastRow && firstRow !== this.#bottomSentinel) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStartBefore(firstRow);
|
||||||
|
range.setEndAfter(lastRow);
|
||||||
|
range.deleteContents();
|
||||||
|
}
|
||||||
|
|
||||||
|
const half = Math.floor(MAX_RENDERED / 2);
|
||||||
|
this.#startIndex = Math.max(0, targetIndex - half);
|
||||||
|
this.#endIndex = Math.min(this.#total, this.#startIndex + MAX_RENDERED);
|
||||||
|
this.#startIndex = Math.max(0, this.#endIndex - MAX_RENDERED);
|
||||||
|
|
||||||
|
this.#topSentinel.after(
|
||||||
|
this.#renderRange(this.#startIndex, this.#endIndex)
|
||||||
|
);
|
||||||
|
this.#numCol.replaceChildren(
|
||||||
|
this.#renderNumRange(this.#startIndex, this.#endIndex)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.#scrollRenderedTargetIntoView(targetIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
#scrollRenderedTargetIntoView(targetIndex) {
|
||||||
|
// #pre.children: [0]=topSentinel, [1..n]=rows, [n+1]=bottomSentinel
|
||||||
|
const targetEl = this.#pre.children[targetIndex - this.#startIndex + 1];
|
||||||
|
if (!targetEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetRect = targetEl.getBoundingClientRect();
|
||||||
|
const innerRect = this.#innerEl.getBoundingClientRect();
|
||||||
|
this.#innerEl.scrollTop +=
|
||||||
|
targetRect.top -
|
||||||
|
innerRect.top -
|
||||||
|
this.#innerEl.clientHeight / 2 +
|
||||||
|
targetEl.clientHeight / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#setupObserver() {
|
||||||
|
const observer = (this.#observer = new IntersectionObserver(
|
||||||
|
entries => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isIntersecting) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.target === this.#bottomSentinel) {
|
||||||
|
this.#loadBottom();
|
||||||
|
} else {
|
||||||
|
this.#loadTop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root: this.#innerEl, rootMargin: "200px" }
|
||||||
|
));
|
||||||
|
observer.observe(this.#topSentinel);
|
||||||
|
observer.observe(this.#bottomSentinel);
|
||||||
|
}
|
||||||
|
|
||||||
|
#loadBottom() {
|
||||||
|
const newEnd = Math.min(this.#endIndex + BATCH_SIZE, this.#total);
|
||||||
|
if (newEnd === this.#endIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#bottomSentinel.before(this.#renderRange(this.#endIndex, newEnd));
|
||||||
|
this.#numCol.append(this.#renderNumRange(this.#endIndex, newEnd));
|
||||||
|
this.#endIndex = newEnd;
|
||||||
|
|
||||||
|
// Trim from top if the window exceeds MAX_RENDERED.
|
||||||
|
if (this.#endIndex - this.#startIndex > MAX_RENDERED) {
|
||||||
|
const removeCount = this.#endIndex - this.#startIndex - MAX_RENDERED;
|
||||||
|
const heightBefore = this.#pre.scrollHeight;
|
||||||
|
for (let i = 0; i < removeCount; i++) {
|
||||||
|
this.#topSentinel.nextElementSibling?.remove();
|
||||||
|
this.#numCol.firstElementChild?.remove();
|
||||||
|
}
|
||||||
|
this.#startIndex += removeCount;
|
||||||
|
// Compensate so visible content doesn't jump upward.
|
||||||
|
this.#innerEl.scrollTop -= heightBefore - this.#pre.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#loadTop() {
|
||||||
|
if (this.#startIndex === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newStart = Math.max(0, this.#startIndex - BATCH_SIZE);
|
||||||
|
const scrollBefore = this.#innerEl.scrollTop;
|
||||||
|
const heightBefore = this.#pre.scrollHeight;
|
||||||
|
this.#topSentinel.after(this.#renderRange(newStart, this.#startIndex));
|
||||||
|
this.#numCol.prepend(this.#renderNumRange(newStart, this.#startIndex));
|
||||||
|
// Compensate so visible content doesn't jump downward.
|
||||||
|
this.#innerEl.scrollTop =
|
||||||
|
scrollBefore + (this.#pre.scrollHeight - heightBefore);
|
||||||
|
this.#startIndex = newStart;
|
||||||
|
|
||||||
|
// Trim from bottom if the window exceeds MAX_RENDERED.
|
||||||
|
if (this.#endIndex - this.#startIndex > MAX_RENDERED) {
|
||||||
|
const removeCount = this.#endIndex - this.#startIndex - MAX_RENDERED;
|
||||||
|
for (let i = 0; i < removeCount; i++) {
|
||||||
|
this.#bottomSentinel.previousElementSibling?.remove();
|
||||||
|
this.#numCol.lastElementChild?.remove();
|
||||||
|
}
|
||||||
|
this.#endIndex -= removeCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#buildToolbar(actions) {
|
||||||
|
const id = ++_idCounter;
|
||||||
|
const bar = document.createElement("div");
|
||||||
|
bar.className = "mlc-goto-bar";
|
||||||
|
|
||||||
|
const searchGroup = document.createElement("div");
|
||||||
|
searchGroup.className = "mlc-search-group";
|
||||||
|
|
||||||
|
const searchErrorId = `mlc-err-${id}`;
|
||||||
|
|
||||||
|
const searchInput = (this.#searchInput = document.createElement("input"));
|
||||||
|
searchInput.type = "search";
|
||||||
|
searchInput.className = "mlc-search-input";
|
||||||
|
searchInput.placeholder = "Search for\u2026";
|
||||||
|
searchInput.ariaLabel = "Search";
|
||||||
|
searchInput.setAttribute("aria-describedby", searchErrorId);
|
||||||
|
|
||||||
|
const searchError = (this.#searchError = document.createElement("span"));
|
||||||
|
searchError.id = searchErrorId;
|
||||||
|
searchError.className = "sr-only";
|
||||||
|
searchError.role = "alert";
|
||||||
|
|
||||||
|
const prevButton = (this.#prevButton = document.createElement("button"));
|
||||||
|
prevButton.className = "mlc-nav-button";
|
||||||
|
prevButton.textContent = "↑";
|
||||||
|
prevButton.ariaLabel = "Previous match";
|
||||||
|
prevButton.disabled = true;
|
||||||
|
|
||||||
|
const nextButton = (this.#nextButton = document.createElement("button"));
|
||||||
|
nextButton.className = "mlc-nav-button";
|
||||||
|
nextButton.textContent = "↓";
|
||||||
|
nextButton.ariaLabel = "Next match";
|
||||||
|
nextButton.disabled = true;
|
||||||
|
|
||||||
|
const matchInfo = (this.#matchInfo = document.createElement("span"));
|
||||||
|
matchInfo.className = "mlc-match-info";
|
||||||
|
|
||||||
|
const { label: ignoreCaseLabel, cb: ignoreCaseCb } =
|
||||||
|
this.#makeCheckboxLabel("Ignore case");
|
||||||
|
const { label: regexLabel, cb: regexCb } = this.#makeCheckboxLabel("Regex");
|
||||||
|
this.#ignoreCaseCb = ignoreCaseCb;
|
||||||
|
this.#regexCb = regexCb;
|
||||||
|
|
||||||
|
searchGroup.append(
|
||||||
|
searchInput,
|
||||||
|
searchError,
|
||||||
|
prevButton,
|
||||||
|
nextButton,
|
||||||
|
matchInfo,
|
||||||
|
ignoreCaseLabel,
|
||||||
|
regexLabel
|
||||||
|
);
|
||||||
|
|
||||||
|
const gotoInput = document.createElement("input");
|
||||||
|
gotoInput.type = "number";
|
||||||
|
gotoInput.className = "mlc-goto";
|
||||||
|
gotoInput.placeholder = "Go to line\u2026";
|
||||||
|
gotoInput.min = "1";
|
||||||
|
gotoInput.max = String(this.#total);
|
||||||
|
gotoInput.step = "1";
|
||||||
|
gotoInput.ariaLabel = "Go to line";
|
||||||
|
|
||||||
|
if (actions) {
|
||||||
|
bar.append(actions);
|
||||||
|
}
|
||||||
|
bar.append(searchGroup, gotoInput);
|
||||||
|
|
||||||
|
searchInput.addEventListener("input", () => this.#runSearch());
|
||||||
|
searchInput.addEventListener("keydown", ({ key, shiftKey }) => {
|
||||||
|
if (key === "Enter") {
|
||||||
|
this.#navigateMatch(shiftKey ? -1 : 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
prevButton.addEventListener("click", () => this.#navigateMatch(-1));
|
||||||
|
nextButton.addEventListener("click", () => this.#navigateMatch(1));
|
||||||
|
this.#ignoreCaseCb.addEventListener("change", () => this.#runSearch());
|
||||||
|
this.#regexCb.addEventListener("change", () => this.#runSearch());
|
||||||
|
|
||||||
|
gotoInput.addEventListener("keydown", ({ key }) => {
|
||||||
|
if (key !== "Enter") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = gotoInput.value.trim();
|
||||||
|
const n = Number(value);
|
||||||
|
if (!value || !Number.isInteger(n) || n < 1 || n > this.#total) {
|
||||||
|
gotoInput.setAttribute("aria-invalid", "true");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gotoInput.removeAttribute("aria-invalid");
|
||||||
|
this.jumpToLine(n - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
#makeCheckboxLabel(text) {
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "mlc-check-label";
|
||||||
|
const cb = document.createElement("input");
|
||||||
|
cb.type = "checkbox";
|
||||||
|
label.append(cb, ` ${text}`);
|
||||||
|
return { label, cb };
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateMatchInfo() {
|
||||||
|
if (!this.#searchInput.value) {
|
||||||
|
this.#matchInfo.textContent = "";
|
||||||
|
this.#prevButton.disabled = this.#nextButton.disabled = true;
|
||||||
|
} else if (this.#searchMatches.length === 0) {
|
||||||
|
this.#matchInfo.textContent = "No results";
|
||||||
|
this.#prevButton.disabled = this.#nextButton.disabled = true;
|
||||||
|
} else {
|
||||||
|
this.#matchInfo.textContent = `${this.#currentMatchIdx + 1} / ${this.#searchMatches.length}`;
|
||||||
|
this.#prevButton.disabled = this.#nextButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#computeMatches() {
|
||||||
|
this.jumpToLine(-1);
|
||||||
|
this.#searchMatches = [];
|
||||||
|
this.#currentMatchIdx = -1;
|
||||||
|
|
||||||
|
const query = this.#searchInput.value;
|
||||||
|
if (!query) {
|
||||||
|
this.#updateMatchInfo();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let test;
|
||||||
|
if (this.#regexCb.checked) {
|
||||||
|
try {
|
||||||
|
const re = new RegExp(query, this.#ignoreCaseCb.checked ? "i" : "");
|
||||||
|
test = str => re.test(str);
|
||||||
|
this.#searchInput.removeAttribute("aria-invalid");
|
||||||
|
this.#searchError.textContent = "";
|
||||||
|
} catch {
|
||||||
|
this.#searchInput.setAttribute("aria-invalid", "true");
|
||||||
|
this.#searchError.textContent = "Invalid regular expression";
|
||||||
|
this.#updateMatchInfo();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const ignoreCase = this.#ignoreCaseCb.checked;
|
||||||
|
const needle = ignoreCase ? query.toLowerCase() : query;
|
||||||
|
test = str => (ignoreCase ? str.toLowerCase() : str).includes(needle);
|
||||||
|
}
|
||||||
|
this.#searchInput.removeAttribute("aria-invalid");
|
||||||
|
this.#searchError.textContent = "";
|
||||||
|
|
||||||
|
for (let i = 0, ii = this.#total; i < ii; i++) {
|
||||||
|
if (test(this.#getText(i))) {
|
||||||
|
this.#searchMatches.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.#searchMatches.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navigateMatch(delta) {
|
||||||
|
if (!this.#searchMatches.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#currentMatchIdx =
|
||||||
|
(this.#currentMatchIdx + delta + this.#searchMatches.length) %
|
||||||
|
this.#searchMatches.length;
|
||||||
|
this.jumpToLine(this.#searchMatches[this.#currentMatchIdx]);
|
||||||
|
this.#updateMatchInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
#runSearch() {
|
||||||
|
if (this.#computeMatches() && this.#searchMatches.length) {
|
||||||
|
this.#currentMatchIdx = 0;
|
||||||
|
this.jumpToLine(this.#searchMatches[0]);
|
||||||
|
}
|
||||||
|
this.#updateMatchInfo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MultilineView };
|
||||||
139
web/internal/page_view.css
Normal file
139
web/internal/page_view.css
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#debug-view {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
&[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#render-panels {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
--spc-resizer-color: var(--border-color);
|
||||||
|
--spc-resizer-hover-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
#render-panels {
|
||||||
|
/* instructionsSplit (spc-column) takes 70% of the width next to canvas. */
|
||||||
|
> .spc-column {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* opTopSplit (spc-row) takes 70% of the instructions column height. */
|
||||||
|
> .spc-column > .spc-row {
|
||||||
|
flex: 7 1 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#gfx-state-panel {
|
||||||
|
flex: 3 1 0;
|
||||||
|
min-width: 20ch;
|
||||||
|
overflow: auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding-block: 8px;
|
||||||
|
background: var(--surface-bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
#canvas-panel {
|
||||||
|
flex: 1 1 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--surface-bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
#canvas-toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid var(--input-border-color);
|
||||||
|
background: var(--button-bg);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1.1em;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--button-hover-bg);
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#zoom-level {
|
||||||
|
min-width: 4ch;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#canvas-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--clr-canvas-bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: safe center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
#canvas-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
.temp-canvas-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.temp-canvas-label {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--muted-color);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.temp-canvas-wrapper canvas {
|
||||||
|
border: 1px solid var(--border-subtle-color);
|
||||||
|
zoom: calc(1 / var(--dpr, 1));
|
||||||
|
}
|
||||||
|
#render-canvas {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#highlight-canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
625
web/internal/page_view.js
Normal file
625
web/internal/page_view.js
Normal file
@ -0,0 +1,625 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CanvasContextDetailsView } from "./canvas_context_details_view.js";
|
||||||
|
import { DOMCanvasFactory } from "pdfjs/display/canvas_factory.js";
|
||||||
|
import { DrawOpsView } from "./draw_ops_view.js";
|
||||||
|
import { SplitView } from "./split_view.js";
|
||||||
|
|
||||||
|
// Stepper for pausing/stepping through op list rendering.
|
||||||
|
// Implements the interface expected by InternalRenderTask (pdfBug mode).
|
||||||
|
class ViewerStepper {
|
||||||
|
#onStepped;
|
||||||
|
|
||||||
|
#continueCallback = null;
|
||||||
|
|
||||||
|
// Pass resumeAt to re-pause at a specific index (e.g. after a zoom).
|
||||||
|
constructor(onStepped, resumeAt = null) {
|
||||||
|
this.#onStepped = onStepped;
|
||||||
|
this.nextBreakPoint = resumeAt ?? this.#findNextAfter(-1);
|
||||||
|
this.currentIdx = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by executeOperatorList when execution reaches nextBreakPoint.
|
||||||
|
breakIt(i, continueCallback) {
|
||||||
|
this.currentIdx = i;
|
||||||
|
this.#continueCallback = continueCallback;
|
||||||
|
this.#onStepped(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance one instruction then pause again.
|
||||||
|
stepNext() {
|
||||||
|
if (!this.#continueCallback) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.nextBreakPoint = this.currentIdx + 1;
|
||||||
|
const cb = this.#continueCallback;
|
||||||
|
this.#continueCallback = null;
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue until the next breakpoint (or end).
|
||||||
|
continueToBreakpoint() {
|
||||||
|
if (!this.#continueCallback) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.nextBreakPoint = this.#findNextAfter(this.currentIdx);
|
||||||
|
const cb = this.#continueCallback;
|
||||||
|
this.#continueCallback = null;
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
|
||||||
|
#findNextAfter(idx) {
|
||||||
|
let next = null;
|
||||||
|
for (const bp of globalThis.StepperManager._breakpoints) {
|
||||||
|
if (bp > idx && (next === null || bp < next)) {
|
||||||
|
next = bp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by InternalRenderTask when the operator list grows (streaming).
|
||||||
|
updateOperatorList() {}
|
||||||
|
|
||||||
|
// Called by InternalRenderTask to initialise the stepper.
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
// Called by InternalRenderTask after recording bboxes (pdfBug mode).
|
||||||
|
setOperatorBBoxes() {}
|
||||||
|
|
||||||
|
getNextBreakPoint() {
|
||||||
|
return this.nextBreakPoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_ZOOM = 0.1;
|
||||||
|
const MAX_ZOOM = 10;
|
||||||
|
const ZOOM_STEP = 1.25;
|
||||||
|
|
||||||
|
class PageView {
|
||||||
|
#pdfDoc = null;
|
||||||
|
|
||||||
|
#gfxStateComp;
|
||||||
|
|
||||||
|
#DebugCanvasFactoryClass;
|
||||||
|
|
||||||
|
#opsView;
|
||||||
|
|
||||||
|
#renderedPage = null;
|
||||||
|
|
||||||
|
#renderScale = null;
|
||||||
|
|
||||||
|
#currentRenderTask = null;
|
||||||
|
|
||||||
|
#currentOpList = null;
|
||||||
|
|
||||||
|
#debugViewGeneration = 0;
|
||||||
|
|
||||||
|
#onMarkLoading;
|
||||||
|
|
||||||
|
#prefersDark;
|
||||||
|
|
||||||
|
#onWindowResize;
|
||||||
|
|
||||||
|
#stepButton;
|
||||||
|
|
||||||
|
#continueButton;
|
||||||
|
|
||||||
|
#zoomLevelEl;
|
||||||
|
|
||||||
|
#zoomOutButton;
|
||||||
|
|
||||||
|
#zoomInButton;
|
||||||
|
|
||||||
|
#redrawButton;
|
||||||
|
|
||||||
|
#highlightCanvas;
|
||||||
|
|
||||||
|
#canvasScrollEl;
|
||||||
|
|
||||||
|
constructor({ onMarkLoading }) {
|
||||||
|
this.#onMarkLoading = onMarkLoading;
|
||||||
|
this.#prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
|
||||||
|
this.#gfxStateComp = new CanvasContextDetailsView(
|
||||||
|
document.getElementById("gfx-state-panel")
|
||||||
|
);
|
||||||
|
|
||||||
|
this.#stepButton = document.getElementById("step-button");
|
||||||
|
this.#continueButton = document.getElementById("continue-button");
|
||||||
|
|
||||||
|
this.#opsView = new DrawOpsView(
|
||||||
|
document.getElementById("op-list-panel"),
|
||||||
|
document.getElementById("op-detail-panel"),
|
||||||
|
{
|
||||||
|
onHighlight: i => this.#drawHighlight(i),
|
||||||
|
onClearHighlight: () => this.#clearHighlight(),
|
||||||
|
prefersDark: this.#prefersDark,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Install a StepperManager so InternalRenderTask (pdfBug mode) picks it up.
|
||||||
|
// A new instance is set on each redraw; null means no stepping.
|
||||||
|
globalThis.StepperManager = {
|
||||||
|
get enabled() {
|
||||||
|
return globalThis.StepperManager._active !== null;
|
||||||
|
},
|
||||||
|
_active: null,
|
||||||
|
_breakpoints: this.#opsView.breakpoints,
|
||||||
|
create() {
|
||||||
|
return globalThis.StepperManager._active;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep --dpr in sync so CSS can scale temp canvases correctly.
|
||||||
|
this.#updateDPR();
|
||||||
|
this.#onWindowResize = () => this.#updateDPR();
|
||||||
|
window.addEventListener("resize", this.#onWindowResize);
|
||||||
|
|
||||||
|
this.#DebugCanvasFactoryClass = this.#makeDebugCanvasFactory();
|
||||||
|
|
||||||
|
this.#setupSplits();
|
||||||
|
|
||||||
|
this.#zoomLevelEl = document.getElementById("zoom-level");
|
||||||
|
this.#zoomOutButton = document.getElementById("zoom-out-button");
|
||||||
|
this.#zoomInButton = document.getElementById("zoom-in-button");
|
||||||
|
this.#redrawButton = document.getElementById("redraw-button");
|
||||||
|
this.#highlightCanvas = document.getElementById("highlight-canvas");
|
||||||
|
this.#canvasScrollEl = document.getElementById("canvas-scroll");
|
||||||
|
|
||||||
|
this.#setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose DebugCanvasFactory class so caller can pass to getDocument().
|
||||||
|
get DebugCanvasFactory() {
|
||||||
|
return this.#DebugCanvasFactoryClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the debug view for a given page.
|
||||||
|
async show(pdfDoc, pageNum) {
|
||||||
|
this.#pdfDoc = pdfDoc;
|
||||||
|
if (this.#currentOpList === null) {
|
||||||
|
await this.#showRenderView(pageNum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset all debug state (call when navigating to tree or loading new doc).
|
||||||
|
reset() {
|
||||||
|
this.#debugViewGeneration++;
|
||||||
|
this.#currentRenderTask?.cancel();
|
||||||
|
this.#currentRenderTask = null;
|
||||||
|
this.#renderedPage?.cleanup();
|
||||||
|
this.#renderedPage = this.#renderScale = this.#currentOpList = null;
|
||||||
|
this.#clearPausedState();
|
||||||
|
this.#opsView.clear();
|
||||||
|
this.#gfxStateComp.clear();
|
||||||
|
this.#pdfDoc?.canvasFactory.clear();
|
||||||
|
|
||||||
|
const mainCanvas = document.getElementById("render-canvas");
|
||||||
|
mainCanvas.width = mainCanvas.height = 0;
|
||||||
|
this.#highlightCanvas.width = this.#highlightCanvas.height = 0;
|
||||||
|
|
||||||
|
this.#zoomLevelEl.textContent = "";
|
||||||
|
this.#zoomOutButton.disabled = false;
|
||||||
|
this.#zoomInButton.disabled = false;
|
||||||
|
this.#redrawButton.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateDPR() {
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--dpr",
|
||||||
|
window.devicePixelRatio || 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#makeDebugCanvasFactory() {
|
||||||
|
const gfxStateComp = this.#gfxStateComp;
|
||||||
|
// Custom CanvasFactory that tracks temporary canvases created during
|
||||||
|
// rendering. When stepping, each temporary canvas is shown below the main
|
||||||
|
// page canvas to inspect intermediate compositing targets (masks, etc).
|
||||||
|
return class DebugCanvasFactory extends DOMCanvasFactory {
|
||||||
|
// Wrapper objects currently alive: { canvas, context, wrapper, label }.
|
||||||
|
#alive = [];
|
||||||
|
|
||||||
|
// getDocument passes { ownerDocument, enableHWA } to the constructor.
|
||||||
|
constructor({ ownerDocument, enableHWA } = {}) {
|
||||||
|
super({ ownerDocument: ownerDocument ?? document, enableHWA });
|
||||||
|
}
|
||||||
|
|
||||||
|
create(width, height) {
|
||||||
|
const canvasAndCtx = super.create(width, height);
|
||||||
|
const label = `Temp ${this.#alive.length + 1}`;
|
||||||
|
canvasAndCtx.context = gfxStateComp.wrapCanvasGetContext(
|
||||||
|
canvasAndCtx.canvas,
|
||||||
|
label
|
||||||
|
);
|
||||||
|
if (globalThis.StepperManager._active !== null) {
|
||||||
|
this.#attach(canvasAndCtx, width, height, label);
|
||||||
|
}
|
||||||
|
return canvasAndCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(canvasAndCtx, width, height) {
|
||||||
|
super.reset(canvasAndCtx, width, height);
|
||||||
|
const entry = this.#alive.find(e => e.canvasAndCtx === canvasAndCtx);
|
||||||
|
if (entry) {
|
||||||
|
entry.labelEl.textContent = `${entry.labelEl.textContent.split("—")[0].trim()} — ${width}×${height}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(canvasAndCtx) {
|
||||||
|
const idx = this.#alive.findIndex(e => e.canvasAndCtx === canvasAndCtx);
|
||||||
|
if (idx !== -1) {
|
||||||
|
this.#alive[idx].wrapper.remove();
|
||||||
|
this.#alive.splice(idx, 1);
|
||||||
|
}
|
||||||
|
super.destroy(canvasAndCtx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show all currently-alive canvases (called when stepping starts).
|
||||||
|
showAll() {
|
||||||
|
for (const entry of this.#alive) {
|
||||||
|
if (!entry.wrapper.isConnected) {
|
||||||
|
this.#attachWrapper(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all temporary canvases from the DOM and clear tracking state.
|
||||||
|
clear() {
|
||||||
|
for (const entry of this.#alive) {
|
||||||
|
entry.wrapper.remove();
|
||||||
|
entry.canvasAndCtx.canvas.width = 0;
|
||||||
|
entry.canvasAndCtx.canvas.height = 0;
|
||||||
|
}
|
||||||
|
this.#alive.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#attach(canvasAndCtx, width, height, ctxLabel) {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "temp-canvas-wrapper";
|
||||||
|
wrapper.addEventListener("click", () =>
|
||||||
|
gfxStateComp.scrollToSection(ctxLabel)
|
||||||
|
);
|
||||||
|
const labelEl = document.createElement("div");
|
||||||
|
labelEl.className = "temp-canvas-label";
|
||||||
|
labelEl.textContent = `${ctxLabel} — ${width}×${height}`;
|
||||||
|
wrapper.append(labelEl, canvasAndCtx.canvas);
|
||||||
|
const entry = { canvasAndCtx, wrapper, labelEl };
|
||||||
|
this.#alive.push(entry);
|
||||||
|
this.#attachWrapper(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
#attachWrapper(entry) {
|
||||||
|
document.getElementById("canvas-scroll").append(entry.wrapper);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#setupSplits() {
|
||||||
|
// Build the three SplitView instances that make up the debug view layout.
|
||||||
|
// Inner splits are created first so outer splits can wrap the new
|
||||||
|
// containers.
|
||||||
|
// Layout: splitHor(splitVer(splitHor(op-list, gfx-state), op-detail),
|
||||||
|
// canvas)
|
||||||
|
|
||||||
|
// Inner row split: op-list on the left, gfx-state on the right (hidden by
|
||||||
|
// default).
|
||||||
|
const opTopSplit = new SplitView(
|
||||||
|
document.getElementById("op-list-panel"),
|
||||||
|
document.getElementById("gfx-state-panel"),
|
||||||
|
{ direction: "row", minSize: 60 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Column split: op-list+gfx-state on top, op-detail on the bottom.
|
||||||
|
const instructionsSplit = new SplitView(
|
||||||
|
opTopSplit.element,
|
||||||
|
document.getElementById("op-detail-panel"),
|
||||||
|
{ direction: "column", minSize: 40 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Outer row split: instructions column on the left, canvas on the right.
|
||||||
|
const renderSplit = new SplitView(
|
||||||
|
instructionsSplit.element,
|
||||||
|
document.getElementById("canvas-panel"),
|
||||||
|
{ direction: "row", minSize: 100, onResize: () => this.#renderCanvas() }
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderPanels = document.getElementById("render-panels");
|
||||||
|
renderPanels.replaceWith(renderSplit.element);
|
||||||
|
renderSplit.element.id = "render-panels";
|
||||||
|
}
|
||||||
|
|
||||||
|
#setupEventListeners() {
|
||||||
|
this.#zoomInButton.addEventListener("click", () =>
|
||||||
|
this.#zoomRenderCanvas(
|
||||||
|
Math.min(
|
||||||
|
MAX_ZOOM,
|
||||||
|
(this.#renderScale ?? this.#getFitScale()) * ZOOM_STEP
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
this.#zoomOutButton.addEventListener("click", () =>
|
||||||
|
this.#zoomRenderCanvas(
|
||||||
|
Math.max(
|
||||||
|
MIN_ZOOM,
|
||||||
|
(this.#renderScale ?? this.#getFitScale()) / ZOOM_STEP
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.#redrawButton.addEventListener("click", async () => {
|
||||||
|
if (!this.#renderedPage || !this.#currentOpList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#clearPausedState();
|
||||||
|
// Reset recorded bboxes so they get re-recorded for the modified op
|
||||||
|
// list.
|
||||||
|
this.#renderedPage.recordedBBoxes = null;
|
||||||
|
if (this.#opsView.breakpoints.size > 0) {
|
||||||
|
globalThis.StepperManager._active = new ViewerStepper(i =>
|
||||||
|
this.#onStepped(i)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.#renderCanvas();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#stepButton.addEventListener("click", () => {
|
||||||
|
globalThis.StepperManager._active?.stepNext();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#continueButton.addEventListener("click", () => {
|
||||||
|
globalThis.StepperManager._active?.continueToBreakpoint();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", e => {
|
||||||
|
if (
|
||||||
|
e.target.matches("input, textarea, [contenteditable]") ||
|
||||||
|
e.altKey ||
|
||||||
|
e.ctrlKey ||
|
||||||
|
e.metaKey
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stepper = globalThis.StepperManager._active;
|
||||||
|
if (!stepper) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
stepper.stepNext();
|
||||||
|
} else if (e.key === "c") {
|
||||||
|
e.preventDefault();
|
||||||
|
stepper.continueToBreakpoint();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#onStepped(i) {
|
||||||
|
this.#opsView.markPaused(i);
|
||||||
|
this.#stepButton.disabled = this.#continueButton.disabled = false;
|
||||||
|
this.#gfxStateComp.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
#clearPausedState() {
|
||||||
|
this.#opsView.clearPaused();
|
||||||
|
globalThis.StepperManager._active = null;
|
||||||
|
this.#stepButton.disabled = this.#continueButton.disabled = true;
|
||||||
|
this.#gfxStateComp.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
#getFitScale() {
|
||||||
|
return (
|
||||||
|
(this.#canvasScrollEl.clientWidth - 24) /
|
||||||
|
this.#renderedPage.getViewport({ scale: 1 }).width
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#zoomRenderCanvas(newScale) {
|
||||||
|
// If zoomed again while a re-render is already running (not yet re-paused),
|
||||||
|
// pausedAtIdx is null but the active stepper still knows the target index.
|
||||||
|
const stepper = globalThis.StepperManager._active;
|
||||||
|
let resumeAt = null;
|
||||||
|
if (stepper !== null) {
|
||||||
|
resumeAt =
|
||||||
|
stepper.currentIdx >= 0 ? stepper.currentIdx : stepper.nextBreakPoint;
|
||||||
|
}
|
||||||
|
this.#clearPausedState();
|
||||||
|
this.#renderScale = newScale;
|
||||||
|
if (resumeAt !== null) {
|
||||||
|
globalThis.StepperManager._active = new ViewerStepper(
|
||||||
|
i => this.#onStepped(i),
|
||||||
|
resumeAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.#renderCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #renderCanvas() {
|
||||||
|
if (!this.#renderedPage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any in-progress render before starting a new one.
|
||||||
|
this.#currentRenderTask?.cancel();
|
||||||
|
this.#currentRenderTask = null;
|
||||||
|
|
||||||
|
const highlight = this.#highlightCanvas;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const scale = this.#renderScale ?? this.#getFitScale();
|
||||||
|
this.#zoomLevelEl.textContent = `${Math.round(scale * 100)}%`;
|
||||||
|
this.#zoomOutButton.disabled = scale <= MIN_ZOOM;
|
||||||
|
this.#zoomInButton.disabled = scale >= MAX_ZOOM;
|
||||||
|
const viewport = this.#renderedPage.getViewport({ scale: scale * dpr });
|
||||||
|
const cssW = `${viewport.width / dpr}px`;
|
||||||
|
const cssH = `${viewport.height / dpr}px`;
|
||||||
|
|
||||||
|
// Size the highlight canvas immediately so it stays in sync.
|
||||||
|
highlight.width = viewport.width;
|
||||||
|
highlight.height = viewport.height;
|
||||||
|
highlight.style.width = cssW;
|
||||||
|
highlight.style.height = cssH;
|
||||||
|
|
||||||
|
// Render into a fresh canvas. When stepping, insert it into the DOM
|
||||||
|
// immediately so the user sees each instruction drawn live. For normal
|
||||||
|
// renders, swap only after completion so there's no blank flash.
|
||||||
|
const newCanvas = document.createElement("canvas");
|
||||||
|
newCanvas.id = "render-canvas";
|
||||||
|
newCanvas.width = viewport.width;
|
||||||
|
newCanvas.height = viewport.height;
|
||||||
|
newCanvas.style.width = cssW;
|
||||||
|
newCanvas.style.height = cssH;
|
||||||
|
newCanvas.addEventListener("click", () =>
|
||||||
|
this.#gfxStateComp.scrollToSection("Page")
|
||||||
|
);
|
||||||
|
|
||||||
|
const isStepping = globalThis.StepperManager._active !== null;
|
||||||
|
if (isStepping) {
|
||||||
|
const oldCanvas = document.getElementById("render-canvas");
|
||||||
|
oldCanvas.width = oldCanvas.height = 0;
|
||||||
|
oldCanvas.replaceWith(newCanvas);
|
||||||
|
// Show any temporary canvases that survived from the previous render
|
||||||
|
// (e.g. after a zoom-while-stepping, the factory may already have
|
||||||
|
// entries).
|
||||||
|
this.#pdfDoc?.canvasFactory.showAll();
|
||||||
|
} else {
|
||||||
|
// Starting a fresh non-stepping render: remove leftover temp canvases.
|
||||||
|
this.#pdfDoc?.canvasFactory.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record bboxes only on the first render; they stay valid for subsequent
|
||||||
|
// re-renders because BBoxReader returns normalised [0, 1] fractions.
|
||||||
|
const firstRender = !this.#renderedPage.recordedBBoxes;
|
||||||
|
const renderTask = this.#renderedPage.render({
|
||||||
|
canvasContext: this.#gfxStateComp.wrapCanvasGetContext(newCanvas, "Page"),
|
||||||
|
viewport,
|
||||||
|
recordOperations: firstRender,
|
||||||
|
});
|
||||||
|
this.#currentRenderTask = renderTask;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await renderTask.promise;
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.name === "RenderingCancelledException") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
if (this.#currentRenderTask === renderTask) {
|
||||||
|
this.#currentRenderTask = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render completed fully — stepping session is over.
|
||||||
|
this.#clearPausedState();
|
||||||
|
this.#pdfDoc?.canvasFactory.clear();
|
||||||
|
this.#redrawButton.disabled = false;
|
||||||
|
|
||||||
|
if (!isStepping) {
|
||||||
|
// Swap the completed canvas in, replacing the previous one. Zero out the
|
||||||
|
// old canvas dimensions to release its GPU memory.
|
||||||
|
const oldCanvas = document.getElementById("render-canvas");
|
||||||
|
oldCanvas.width = oldCanvas.height = 0;
|
||||||
|
oldCanvas.replaceWith(newCanvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the task on first render so the caller can extract the operator
|
||||||
|
// list without a separate getOperatorList() call (dev/testing builds only).
|
||||||
|
return firstRender ? renderTask : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#drawHighlight(opIdx) {
|
||||||
|
const bboxes = this.#renderedPage?.recordedBBoxes;
|
||||||
|
if (!bboxes || opIdx >= bboxes.length || bboxes.isEmpty(opIdx)) {
|
||||||
|
this.#clearHighlight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canvas = document.getElementById("render-canvas");
|
||||||
|
const highlight = this.#highlightCanvas;
|
||||||
|
const cssW = parseFloat(canvas.style.width);
|
||||||
|
const cssH = parseFloat(canvas.style.height);
|
||||||
|
const x = bboxes.minX(opIdx) * cssW;
|
||||||
|
const y = bboxes.minY(opIdx) * cssH;
|
||||||
|
const w = (bboxes.maxX(opIdx) - bboxes.minX(opIdx)) * cssW;
|
||||||
|
const h = (bboxes.maxY(opIdx) - bboxes.minY(opIdx)) * cssH;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const ctx = highlight.getContext("2d");
|
||||||
|
ctx.clearRect(0, 0, highlight.width, highlight.height);
|
||||||
|
ctx.save();
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.fillStyle = "rgba(255, 165, 0, 0.3)";
|
||||||
|
ctx.strokeStyle = "rgba(255, 140, 0, 0.9)";
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.fillRect(x, y, w, h);
|
||||||
|
ctx.strokeRect(x, y, w, h);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
#clearHighlight() {
|
||||||
|
this.#highlightCanvas
|
||||||
|
.getContext("2d")
|
||||||
|
.clearRect(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
this.#highlightCanvas.width,
|
||||||
|
this.#highlightCanvas.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #showRenderView(pageNum) {
|
||||||
|
const generation = this.#debugViewGeneration;
|
||||||
|
const opListEl = document.getElementById("op-list");
|
||||||
|
|
||||||
|
const spinner = document.createElement("div");
|
||||||
|
spinner.role = "status";
|
||||||
|
spinner.textContent = "Loading…";
|
||||||
|
opListEl.replaceChildren(spinner);
|
||||||
|
document.getElementById("op-detail-panel").replaceChildren();
|
||||||
|
|
||||||
|
this.#renderScale = null;
|
||||||
|
this.#onMarkLoading(1);
|
||||||
|
try {
|
||||||
|
this.#renderedPage = await this.#pdfDoc.getPage(pageNum);
|
||||||
|
if (this.#debugViewGeneration !== generation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the page (records bboxes too). Reuse the operator list from
|
||||||
|
// the render task when available (dev/testing builds); fall back to a
|
||||||
|
// separate getOperatorList() call otherwise.
|
||||||
|
const renderTask = await this.#renderCanvas();
|
||||||
|
if (this.#debugViewGeneration !== generation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#currentOpList =
|
||||||
|
renderTask?.getOperatorList?.() ??
|
||||||
|
(await this.#renderedPage.getOperatorList());
|
||||||
|
if (this.#debugViewGeneration !== generation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#opsView.load(this.#currentOpList, this.#renderedPage);
|
||||||
|
} catch (err) {
|
||||||
|
const errEl = document.createElement("div");
|
||||||
|
errEl.role = "alert";
|
||||||
|
errEl.textContent = `Error: ${err.message}`;
|
||||||
|
opListEl.replaceChildren(errEl);
|
||||||
|
} finally {
|
||||||
|
this.#onMarkLoading(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PageView };
|
||||||
71
web/internal/split_view.css
Normal file
71
web/internal/split_view.css
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Hide the resizer automatically when the adjacent panel is not visible. */
|
||||||
|
.spc-container > .spc-resizer:has(+ [hidden]),
|
||||||
|
.spc-container > [hidden] + .spc-resizer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spc-container {
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .spc-resizer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--spc-resizer-color, #ccc);
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.dragging {
|
||||||
|
background: var(--spc-resizer-hover-color, #888);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.spc-row {
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
> .spc-resizer {
|
||||||
|
width: 6px;
|
||||||
|
cursor: col-resize;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.spc-column {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
> .spc-resizer {
|
||||||
|
height: 6px;
|
||||||
|
cursor: row-resize;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
.spc-container > .spc-resizer {
|
||||||
|
background: ButtonBorder;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.dragging {
|
||||||
|
background: Highlight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
214
web/internal/split_view.js
Normal file
214
web/internal/split_view.js
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps two elements with a drag-to-resize handle between them.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} firstEl
|
||||||
|
* @param {HTMLElement} secondEl
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {"row"|"column"} [options.direction="row"] Layout axis.
|
||||||
|
* @param {number} [options.minSize=40] Min px for each panel.
|
||||||
|
* @param {Function} [options.onResize] Called after each resize.
|
||||||
|
*/
|
||||||
|
class SplitView {
|
||||||
|
#container;
|
||||||
|
|
||||||
|
#resizer;
|
||||||
|
|
||||||
|
#isRow;
|
||||||
|
|
||||||
|
#minSize;
|
||||||
|
|
||||||
|
#onResize;
|
||||||
|
|
||||||
|
#onPointerDown = null;
|
||||||
|
|
||||||
|
#onKeyDown = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
firstEl,
|
||||||
|
secondEl,
|
||||||
|
{ direction = "row", minSize = 40, onResize } = {}
|
||||||
|
) {
|
||||||
|
this.#isRow = direction === "row";
|
||||||
|
this.#minSize = minSize;
|
||||||
|
this.#onResize = onResize;
|
||||||
|
|
||||||
|
const resizer = (this.#resizer = document.createElement("div"));
|
||||||
|
resizer.className = "spc-resizer";
|
||||||
|
resizer.role = "separator";
|
||||||
|
resizer.tabIndex = 0;
|
||||||
|
resizer.ariaOrientation = this.#isRow ? "vertical" : "horizontal";
|
||||||
|
resizer.ariaValueMin = 0;
|
||||||
|
resizer.ariaValueMax = 100;
|
||||||
|
resizer.ariaValueNow = 50;
|
||||||
|
|
||||||
|
this.#container = document.createElement("div");
|
||||||
|
this.#container.className = `spc-container spc-${direction}`;
|
||||||
|
this.#container.append(firstEl, resizer, secondEl);
|
||||||
|
|
||||||
|
this.#setupResizer();
|
||||||
|
}
|
||||||
|
|
||||||
|
get element() {
|
||||||
|
return this.#container;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.#onPointerDown) {
|
||||||
|
this.#resizer.removeEventListener("pointerdown", this.#onPointerDown);
|
||||||
|
this.#onPointerDown = null;
|
||||||
|
}
|
||||||
|
if (this.#onKeyDown) {
|
||||||
|
this.#resizer.removeEventListener("keydown", this.#onKeyDown);
|
||||||
|
this.#onKeyDown = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always read the live first/last child so callers can swap panels in-place.
|
||||||
|
get #first() {
|
||||||
|
return this.#container.firstElementChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
get #second() {
|
||||||
|
return this.#container.lastElementChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dimension() {
|
||||||
|
return this.#isRow ? "width" : "height";
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateAria(containerSize, resizerSize) {
|
||||||
|
const total = containerSize - resizerSize;
|
||||||
|
if (total <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const firstSize = this.#first.getBoundingClientRect()[this.#dimension()];
|
||||||
|
this.#resizer.ariaValueNow = Math.round((firstSize / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#clampFirstSize(total, requestedFirst) {
|
||||||
|
if (total <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (total <= this.#minSize * 2) {
|
||||||
|
return Math.min(total, Math.max(0, requestedFirst));
|
||||||
|
}
|
||||||
|
return Math.max(
|
||||||
|
this.#minSize,
|
||||||
|
Math.min(total - this.#minSize, requestedFirst)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#resize(newFirst) {
|
||||||
|
const dimension = this.#dimension();
|
||||||
|
const containerSize = this.#container.getBoundingClientRect()[dimension];
|
||||||
|
const resizerSize = this.#resizer.getBoundingClientRect()[dimension];
|
||||||
|
this.#resizeWithMetrics(newFirst, containerSize, resizerSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
#resizeWithMetrics(newFirst, containerSize, resizerSize) {
|
||||||
|
const total = containerSize - resizerSize;
|
||||||
|
const clamped = this.#clampFirstSize(total, newFirst);
|
||||||
|
this.#first.style.flexGrow = clamped;
|
||||||
|
this.#second.style.flexGrow = total - clamped;
|
||||||
|
this.#updateAria(containerSize, resizerSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
#setupResizer() {
|
||||||
|
const axis = this.#isRow ? "clientX" : "clientY";
|
||||||
|
const cursor = this.#isRow ? "col-resize" : "row-resize";
|
||||||
|
|
||||||
|
this.#onPointerDown = e => {
|
||||||
|
if (e.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const dimension = this.#dimension();
|
||||||
|
const containerSize = this.#container.getBoundingClientRect()[dimension];
|
||||||
|
const resizerSize = this.#resizer.getBoundingClientRect()[dimension];
|
||||||
|
const startPos = e[axis];
|
||||||
|
const startFirst = this.#first.getBoundingClientRect()[dimension];
|
||||||
|
|
||||||
|
this.#resizer.classList.add("dragging");
|
||||||
|
document.body.style.cursor = cursor;
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
const { signal } = ac;
|
||||||
|
|
||||||
|
const cancelDrag = () => {
|
||||||
|
ac.abort();
|
||||||
|
this.#resizer.classList.remove("dragging");
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(
|
||||||
|
"pointermove",
|
||||||
|
ev => {
|
||||||
|
this.#resizeWithMetrics(
|
||||||
|
startFirst + ev[axis] - startPos,
|
||||||
|
containerSize,
|
||||||
|
resizerSize
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
window.addEventListener(
|
||||||
|
"pointerup",
|
||||||
|
() => {
|
||||||
|
cancelDrag();
|
||||||
|
this.#updateAria(
|
||||||
|
containerSize,
|
||||||
|
this.#resizer.getBoundingClientRect()[dimension]
|
||||||
|
);
|
||||||
|
this.#onResize?.();
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
window.addEventListener("blur", cancelDrag, { signal });
|
||||||
|
};
|
||||||
|
this.#resizer.addEventListener("pointerdown", this.#onPointerDown);
|
||||||
|
|
||||||
|
this.#onKeyDown = e => {
|
||||||
|
let delta = 0;
|
||||||
|
if (
|
||||||
|
(this.#isRow && e.key === "ArrowLeft") ||
|
||||||
|
(!this.#isRow && e.key === "ArrowUp")
|
||||||
|
) {
|
||||||
|
delta = -(e.shiftKey ? 50 : 10);
|
||||||
|
} else if (
|
||||||
|
(this.#isRow && e.key === "ArrowRight") ||
|
||||||
|
(!this.#isRow && e.key === "ArrowDown")
|
||||||
|
) {
|
||||||
|
delta = e.shiftKey ? 50 : 10;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
const dimension = this.#dimension();
|
||||||
|
const inlineCurrent = parseFloat(this.#first.style.flexGrow);
|
||||||
|
const currentFirst = isNaN(inlineCurrent)
|
||||||
|
? this.#first.getBoundingClientRect()[dimension]
|
||||||
|
: inlineCurrent;
|
||||||
|
this.#resize(currentFirst + delta);
|
||||||
|
this.#onResize?.();
|
||||||
|
};
|
||||||
|
this.#resizer.addEventListener("keydown", this.#onKeyDown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SplitView };
|
||||||
185
web/internal/tree_view.css
Normal file
185
web/internal/tree_view.css
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#tree.loading {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tree {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--surface-bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
min-height: 60px;
|
||||||
|
|
||||||
|
.node {
|
||||||
|
display: block;
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
color: var(--muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
[role="button"] {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
font-size: 0.7em;
|
||||||
|
color: var(--muted-color);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
[role="group"] {
|
||||||
|
padding-left: 20px;
|
||||||
|
border-left: 1px dashed var(--border-subtle-color);
|
||||||
|
margin-left: 2px;
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref {
|
||||||
|
color: var(--ref-color);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--ref-hover-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.str-value {
|
||||||
|
color: var(--string-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.num-value {
|
||||||
|
color: var(--number-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bool-value {
|
||||||
|
color: var(--bool-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.null-value {
|
||||||
|
color: var(--null-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-value {
|
||||||
|
color: var(--name-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket {
|
||||||
|
color: var(--muted-color);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: light-dark(#444, #bbb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-label {
|
||||||
|
color: var(--stream-color);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
[role="status"] {
|
||||||
|
color: var(--muted-color);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
[role="alert"] {
|
||||||
|
color: var(--changed-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bytes-content {
|
||||||
|
padding-left: 20px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 1em;
|
||||||
|
opacity: 0.85;
|
||||||
|
color: var(--string-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bytes-hex {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--bool-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
max-width: 40%;
|
||||||
|
height: auto;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
border: 1px solid var(--border-subtle-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-cmd {
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-num {
|
||||||
|
color: var(--number-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-str {
|
||||||
|
color: var(--string-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-name {
|
||||||
|
color: var(--name-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-bool {
|
||||||
|
color: var(--bool-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-null {
|
||||||
|
color: var(--null-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-ref {
|
||||||
|
color: var(--ref-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-array,
|
||||||
|
.token-dict {
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cap height when a MultilineView is embedded in the tree. */
|
||||||
|
.mlc-scroll {
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content-stream line styles. */
|
||||||
|
.content-stm-instruction {
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-inline-start: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-bytes-stream {
|
||||||
|
color: var(--string-color);
|
||||||
|
}
|
||||||
846
web/internal/tree_view.js
Normal file
846
web/internal/tree_view.js
Normal file
@ -0,0 +1,846 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MultilineView } from "./multiline_view.js";
|
||||||
|
|
||||||
|
const ARROW_COLLAPSED = "▶";
|
||||||
|
const ARROW_EXPANDED = "▼";
|
||||||
|
|
||||||
|
// Matches indirect object references such as "10 0 R".
|
||||||
|
const REF_RE = /^\d+ \d+ R$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders and manages the PDF internal structure tree.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} treeEl
|
||||||
|
* @param {object} options
|
||||||
|
* @param {Function} options.onMarkLoading Called with +1/-1 to track
|
||||||
|
* in-flight requests.
|
||||||
|
*/
|
||||||
|
class TreeView {
|
||||||
|
#treeEl;
|
||||||
|
|
||||||
|
#onMarkLoading;
|
||||||
|
|
||||||
|
// Cache for getRawData results, keyed by "num:gen". Cleared on each new
|
||||||
|
// document.
|
||||||
|
#refCache = new Map();
|
||||||
|
|
||||||
|
constructor(treeEl, { onMarkLoading }) {
|
||||||
|
this.#treeEl = treeEl;
|
||||||
|
this.#onMarkLoading = onMarkLoading;
|
||||||
|
this.#setupKeyboardNav();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Public API ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and render a tree for the given ref/page from doc.
|
||||||
|
* @param {{ ref?: object, page?: number }} data
|
||||||
|
* @param {string|null} rootLabel
|
||||||
|
* @param {PDFDocumentProxy} doc
|
||||||
|
*/
|
||||||
|
async load(data, rootLabel, doc) {
|
||||||
|
this.#treeEl.classList.add("loading");
|
||||||
|
this.#onMarkLoading(1);
|
||||||
|
try {
|
||||||
|
const rootNode = this.#renderNode(
|
||||||
|
rootLabel,
|
||||||
|
await doc.getRawData(data),
|
||||||
|
doc
|
||||||
|
);
|
||||||
|
this.#treeEl.replaceChildren(rootNode);
|
||||||
|
rootNode.querySelector("[role='button']")?.click();
|
||||||
|
const firstTreeItem = this.#treeEl.querySelector("[role='treeitem']");
|
||||||
|
if (firstTreeItem) {
|
||||||
|
firstTreeItem.tabIndex = 0;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.#treeEl.classList.remove("loading");
|
||||||
|
this.#onMarkLoading(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append a role=alert error node to the tree element. */
|
||||||
|
showError(message) {
|
||||||
|
this.#treeEl.append(this.#makeErrorNode(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the ref cache (call when a new document is opened). */
|
||||||
|
clearCache() {
|
||||||
|
this.#refCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private helpers ---
|
||||||
|
|
||||||
|
#moveFocus(from, to) {
|
||||||
|
if (!to) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (from) {
|
||||||
|
from.tabIndex = -1;
|
||||||
|
}
|
||||||
|
to.tabIndex = 0;
|
||||||
|
to.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
#getVisibleItems() {
|
||||||
|
return Array.from(
|
||||||
|
this.#treeEl.querySelectorAll("[role='treeitem']")
|
||||||
|
).filter(item => {
|
||||||
|
let el = item.parentElement;
|
||||||
|
while (el && el !== this.#treeEl) {
|
||||||
|
if (el.role === "group" && el.classList.contains("hidden")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#makeErrorNode(message) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.role = "alert";
|
||||||
|
el.textContent = `Error: ${message}`;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
#setupKeyboardNav() {
|
||||||
|
this.#treeEl.addEventListener("keydown", e => {
|
||||||
|
const { key } = e;
|
||||||
|
if (
|
||||||
|
key !== "ArrowDown" &&
|
||||||
|
key !== "ArrowUp" &&
|
||||||
|
key !== "ArrowRight" &&
|
||||||
|
key !== "ArrowLeft" &&
|
||||||
|
key !== "Home" &&
|
||||||
|
key !== "End"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
const focused =
|
||||||
|
document.activeElement instanceof HTMLElement &&
|
||||||
|
this.#treeEl.contains(document.activeElement)
|
||||||
|
? document.activeElement
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// ArrowRight/Left operate on the focused treeitem directly without
|
||||||
|
// needing a full list of visible items.
|
||||||
|
if (key === "ArrowRight" || key === "ArrowLeft") {
|
||||||
|
if (!focused || focused.role !== "treeitem") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === "ArrowRight") {
|
||||||
|
// Find the toggle button inside this treeitem (not inside a child
|
||||||
|
// group).
|
||||||
|
const toggle = focused.querySelector(":scope > [role='button']");
|
||||||
|
if (!toggle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (toggle.ariaExpanded === "false") {
|
||||||
|
toggle.click();
|
||||||
|
} else {
|
||||||
|
// Already expanded — move to first child treeitem.
|
||||||
|
const group = focused.querySelector(
|
||||||
|
":scope > [role='group']:not(.hidden)"
|
||||||
|
);
|
||||||
|
const firstChild = group?.querySelector("[role='treeitem']");
|
||||||
|
this.#moveFocus(focused, firstChild);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Collapsed or no children — move to parent treeitem.
|
||||||
|
const toggle = focused.querySelector(":scope > [role='button']");
|
||||||
|
if (toggle?.ariaExpanded === "true") {
|
||||||
|
toggle.click();
|
||||||
|
} else {
|
||||||
|
const parentGroup = focused.closest("[role='group']");
|
||||||
|
const parentItem = parentGroup?.closest("[role='treeitem']");
|
||||||
|
this.#moveFocus(focused, parentItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArrowDown/Up/Home/End need the full ordered list of visible treeitems.
|
||||||
|
const visibleItems = this.#getVisibleItems();
|
||||||
|
if (visibleItems.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idx = visibleItems.indexOf(focused);
|
||||||
|
|
||||||
|
if (key === "ArrowDown") {
|
||||||
|
const next = visibleItems[idx >= 0 ? idx + 1 : 0];
|
||||||
|
this.#moveFocus(focused, next);
|
||||||
|
} else if (key === "ArrowUp") {
|
||||||
|
const prev = idx >= 0 ? visibleItems[idx - 1] : visibleItems.at(-1);
|
||||||
|
this.#moveFocus(focused, prev);
|
||||||
|
} else if (key === "Home") {
|
||||||
|
const first = visibleItems[0];
|
||||||
|
if (first !== focused) {
|
||||||
|
this.#moveFocus(focused, first);
|
||||||
|
}
|
||||||
|
} else if (key === "End") {
|
||||||
|
const last = visibleItems.at(-1);
|
||||||
|
if (last !== focused) {
|
||||||
|
this.#moveFocus(focused, last);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a bare div.node treeitem with an optional "key: " prefix. */
|
||||||
|
#makeNodeEl(key) {
|
||||||
|
const node = document.createElement("div");
|
||||||
|
node.className = "node";
|
||||||
|
node.role = "treeitem";
|
||||||
|
node.tabIndex = -1;
|
||||||
|
if (key !== null) {
|
||||||
|
node.append(
|
||||||
|
this.#makeSpan("key", key),
|
||||||
|
this.#makeSpan("separator", ": ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render one key/value pair as a <div class="node">.
|
||||||
|
* @param {string|null} key Dict key, array index, or null for root.
|
||||||
|
* @param {*} value
|
||||||
|
* @param {PDFDocumentProxy} doc
|
||||||
|
*/
|
||||||
|
#renderNode(key, value, doc) {
|
||||||
|
const node = this.#makeNodeEl(key);
|
||||||
|
node.append(this.#renderValue(value, doc));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate a container element with the direct children of a value.
|
||||||
|
* Used both by renderValue (inside expandables) and renderRef (directly
|
||||||
|
* into the ref's children container, avoiding an extra toggle level).
|
||||||
|
*/
|
||||||
|
#buildChildren(value, doc, container) {
|
||||||
|
if (this.#isStream(value)) {
|
||||||
|
for (const [k, v] of Object.entries(value.dict)) {
|
||||||
|
container.append(this.#renderNode(k, v, doc));
|
||||||
|
}
|
||||||
|
if (this.#isImageStream(value)) {
|
||||||
|
container.append(this.#renderImageData(value.imageData));
|
||||||
|
} else if (this.#isFormXObjectStream(value)) {
|
||||||
|
const contentNode = this.#makeNodeEl("content");
|
||||||
|
const csLabel = `[Content Stream, ${value.instructions.length} instructions]`;
|
||||||
|
const csLabelEl = this.#makeSpan("stream-label", csLabel);
|
||||||
|
contentNode.append(
|
||||||
|
this.#makeExpandable(csLabelEl, csLabel, c =>
|
||||||
|
this.#buildContentStreamPanel(value, c, csLabelEl)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
container.append(contentNode);
|
||||||
|
} else {
|
||||||
|
const byteNode = this.#makeNodeEl("bytes");
|
||||||
|
byteNode.append(
|
||||||
|
this.#makeSpan("stream-label", `<${value.bytes.length} raw bytes>`)
|
||||||
|
);
|
||||||
|
container.append(byteNode);
|
||||||
|
|
||||||
|
const bytesContentEl = document.createElement("div");
|
||||||
|
bytesContentEl.className = "bytes-content";
|
||||||
|
bytesContentEl.append(this.#formatBytes(value.bytes));
|
||||||
|
container.append(bytesContentEl);
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
value.forEach((v, i) =>
|
||||||
|
container.append(this.#renderNode(String(i), v, doc))
|
||||||
|
);
|
||||||
|
} else if (value !== null && typeof value === "object") {
|
||||||
|
for (const [k, v] of Object.entries(value)) {
|
||||||
|
container.append(this.#renderNode(k, v, doc));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
container.append(this.#renderNode(null, value, doc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a single content-stream token as a styled span.
|
||||||
|
*/
|
||||||
|
#renderToken(token) {
|
||||||
|
if (!token) {
|
||||||
|
return this.#makeSpan("token-null", "null");
|
||||||
|
}
|
||||||
|
switch (token.type) {
|
||||||
|
case "cmd":
|
||||||
|
return this.#makeSpan("token-cmd", token.value);
|
||||||
|
case "name":
|
||||||
|
return this.#makeSpan("token-name", "/" + token.value);
|
||||||
|
case "ref":
|
||||||
|
return this.#makeSpan("token-ref", `${token.num} ${token.gen} R`);
|
||||||
|
case "number":
|
||||||
|
return this.#makeSpan("token-num", String(token.value));
|
||||||
|
case "string":
|
||||||
|
return this.#makeSpan("token-str", JSON.stringify(token.value));
|
||||||
|
case "boolean":
|
||||||
|
return this.#makeSpan("token-bool", String(token.value));
|
||||||
|
case "null":
|
||||||
|
return this.#makeSpan("token-null", "null");
|
||||||
|
case "array": {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "token-array";
|
||||||
|
span.append(this.#makeSpan("bracket", "["));
|
||||||
|
for (const item of token.value) {
|
||||||
|
span.append(document.createTextNode(" "));
|
||||||
|
span.append(this.#renderToken(item));
|
||||||
|
}
|
||||||
|
span.append(document.createTextNode(" "));
|
||||||
|
span.append(this.#makeSpan("bracket", "]"));
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
case "dict": {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "token-dict";
|
||||||
|
span.append(this.#makeSpan("bracket", "<<"));
|
||||||
|
for (const [k, v] of Object.entries(token.value)) {
|
||||||
|
span.append(document.createTextNode(" "));
|
||||||
|
span.append(this.#makeSpan("token-name", `/${k}`));
|
||||||
|
span.append(document.createTextNode(" "));
|
||||||
|
span.append(this.#renderToken(v));
|
||||||
|
}
|
||||||
|
span.append(document.createTextNode(" "));
|
||||||
|
span.append(this.#makeSpan("bracket", ">>"));
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return this.#makeSpan(
|
||||||
|
"token-unknown",
|
||||||
|
String(token.value ?? token.type)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the plain-text representation of a token (mirrors #renderToken).
|
||||||
|
* Used to build searchable strings for every instruction.
|
||||||
|
*/
|
||||||
|
#tokenToText(token) {
|
||||||
|
if (!token) {
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
switch (token.type) {
|
||||||
|
case "cmd":
|
||||||
|
return token.value;
|
||||||
|
case "name":
|
||||||
|
return "/" + token.value;
|
||||||
|
case "ref":
|
||||||
|
return `${token.num} ${token.gen} R`;
|
||||||
|
case "number":
|
||||||
|
return String(token.value);
|
||||||
|
case "string":
|
||||||
|
return JSON.stringify(token.value);
|
||||||
|
case "boolean":
|
||||||
|
return String(token.value);
|
||||||
|
case "null":
|
||||||
|
return "null";
|
||||||
|
case "array":
|
||||||
|
return `[ ${token.value.map(t => this.#tokenToText(t)).join(" ")} ]`;
|
||||||
|
case "dict": {
|
||||||
|
const inner = Object.entries(token.value)
|
||||||
|
.map(([k, v]) => `/${k} ${this.#tokenToText(v)}`)
|
||||||
|
.join(" ");
|
||||||
|
return `<< ${inner} >>`;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return String(token.value ?? token.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#buildInstructionLines(val, container, actions = null) {
|
||||||
|
const { instructions, cmdNames } = val;
|
||||||
|
const total = instructions.length;
|
||||||
|
|
||||||
|
// Pre-compute indentation depth for every instruction so that any
|
||||||
|
// slice [from, to) can be rendered without replaying from the start.
|
||||||
|
const depths = new Int32Array(total);
|
||||||
|
let d = 0;
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
const cmd = instructions[i].cmd;
|
||||||
|
if (cmd === "ET" || cmd === "Q" || cmd === "EMC") {
|
||||||
|
d = Math.max(0, d - 1);
|
||||||
|
}
|
||||||
|
depths[i] = d;
|
||||||
|
if (cmd === "BT" || cmd === "q" || cmd === "BDC") {
|
||||||
|
d++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-compute a plain-text string per instruction for searching.
|
||||||
|
const instrTexts = instructions.map(instr => {
|
||||||
|
const parts = instr.args.map(t => this.#tokenToText(t));
|
||||||
|
if (instr.cmd !== null) {
|
||||||
|
parts.push(instr.cmd);
|
||||||
|
}
|
||||||
|
return parts.join(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
const mc = new MultilineView({
|
||||||
|
total,
|
||||||
|
lineClass: "content-stream",
|
||||||
|
getText: i => instrTexts[i],
|
||||||
|
actions,
|
||||||
|
makeLineEl: (i, isHighlighted) => {
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.className = "content-stm-instruction";
|
||||||
|
if (isHighlighted) {
|
||||||
|
line.classList.add("mlc-match");
|
||||||
|
}
|
||||||
|
// Wrap the instruction content so that indentation shifts the tokens.
|
||||||
|
const content = document.createElement("span");
|
||||||
|
if (depths[i] > 0) {
|
||||||
|
content.style.paddingInlineStart = `${depths[i] * 1.5}em`;
|
||||||
|
}
|
||||||
|
const instr = instructions[i];
|
||||||
|
for (const arg of instr.args) {
|
||||||
|
content.append(this.#renderToken(arg));
|
||||||
|
content.append(document.createTextNode(" "));
|
||||||
|
}
|
||||||
|
if (instr.cmd !== null) {
|
||||||
|
const cmdEl = this.#makeSpan("token-cmd", instr.cmd);
|
||||||
|
const opsName = cmdNames[instr.cmd];
|
||||||
|
if (opsName) {
|
||||||
|
cmdEl.title = opsName;
|
||||||
|
}
|
||||||
|
content.append(cmdEl);
|
||||||
|
}
|
||||||
|
line.append(content);
|
||||||
|
return line;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
container.append(mc.element);
|
||||||
|
return mc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fills container with a raw-bytes virtual-scroll panel.
|
||||||
|
#buildRawBytesPanel(rawBytes, container, actions = null) {
|
||||||
|
const lines = rawBytes.split(/\r?\n|\r/);
|
||||||
|
if (lines.at(-1) === "") {
|
||||||
|
lines.pop();
|
||||||
|
}
|
||||||
|
const mc = new MultilineView({
|
||||||
|
total: lines.length,
|
||||||
|
lineClass: "content-stream raw-bytes-stream",
|
||||||
|
getText: i => lines[i],
|
||||||
|
actions,
|
||||||
|
makeLineEl: (i, isHighlighted) => {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "content-stm-instruction";
|
||||||
|
if (isHighlighted) {
|
||||||
|
el.classList.add("mlc-match");
|
||||||
|
}
|
||||||
|
el.append(this.#formatBytes(lines[i]));
|
||||||
|
return el;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
container.append(mc.element);
|
||||||
|
return mc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a "Parsed" toggle button. aria-pressed=true means the parsed view
|
||||||
|
// is currently active; clicking switches to the other view.
|
||||||
|
#makeParseToggleBtn(isParsed, onToggle) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "mlc-nav-button";
|
||||||
|
btn.textContent = "Parsed";
|
||||||
|
btn.ariaPressed = String(isParsed);
|
||||||
|
btn.title = isParsed ? "Show raw bytes" : "Show parsed instructions";
|
||||||
|
btn.addEventListener("click", onToggle);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fills container with the content stream panel (parsed or raw), with a
|
||||||
|
// toggle button in the toolbar that swaps the view in-place.
|
||||||
|
#buildContentStreamPanel(val, container, labelEl = null) {
|
||||||
|
let isParsed = true;
|
||||||
|
let currentPanel = null;
|
||||||
|
const rawBytes = val.rawBytes ?? val.bytes;
|
||||||
|
const rawLines = rawBytes ? rawBytes.split(/\r?\n|\r/) : [];
|
||||||
|
if (rawLines.at(-1) === "") {
|
||||||
|
rawLines.pop();
|
||||||
|
}
|
||||||
|
const parsedLabel = `[Content Stream, ${val.instructions.length} instructions]`;
|
||||||
|
const rawLabel = `[Content Stream, ${rawLines.length} lines]`;
|
||||||
|
|
||||||
|
const rebuild = () => {
|
||||||
|
currentPanel?.destroy();
|
||||||
|
currentPanel = null;
|
||||||
|
container.replaceChildren();
|
||||||
|
if (labelEl) {
|
||||||
|
labelEl.textContent = isParsed ? parsedLabel : rawLabel;
|
||||||
|
}
|
||||||
|
const btn = this.#makeParseToggleBtn(isParsed, () => {
|
||||||
|
isParsed = !isParsed;
|
||||||
|
rebuild();
|
||||||
|
});
|
||||||
|
currentPanel = isParsed
|
||||||
|
? this.#buildInstructionLines(val, container, btn)
|
||||||
|
: this.#buildRawBytesPanel(rawBytes, container, btn);
|
||||||
|
};
|
||||||
|
|
||||||
|
rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render Page content stream as an expandable panel with a Parsed/Raw toggle.
|
||||||
|
*/
|
||||||
|
#renderContentStream(val) {
|
||||||
|
const label = `[Content Stream, ${val.instructions.length} instructions]`;
|
||||||
|
const labelEl = this.#makeSpan("stream-label", label);
|
||||||
|
return this.#makeExpandable(labelEl, label, container =>
|
||||||
|
this.#buildContentStreamPanel(val, container, labelEl)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a value inline (primitive) or as an expandable widget.
|
||||||
|
* Returns a Node or DocumentFragment suitable for appendChild().
|
||||||
|
*/
|
||||||
|
#renderValue(value, doc) {
|
||||||
|
// Ref string ("10 0 R") – lazy expandable via getRawData()
|
||||||
|
if (typeof value === "string" && REF_RE.test(value)) {
|
||||||
|
return this.#renderRef(value, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ref object { num, gen } – lazy expandable via getRawData()
|
||||||
|
if (this.#isRefObject(value)) {
|
||||||
|
return this.#renderRef(value, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF Name → /Name
|
||||||
|
if (this.#isPDFName(value)) {
|
||||||
|
return this.#makeSpan("name-value", `/${value.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content stream (Page Contents) → expandable with Parsed/Raw toggle
|
||||||
|
if (this.#isContentStream(value)) {
|
||||||
|
return this.#renderContentStream(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream → expandable showing dict entries + byte count or image preview
|
||||||
|
if (this.#isStream(value)) {
|
||||||
|
return this.#renderExpandable("[Stream]", "stream-label", container =>
|
||||||
|
this.#buildChildren(value, doc, container)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain object (dict)
|
||||||
|
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return this.#makeSpan("bracket", "{}");
|
||||||
|
}
|
||||||
|
return this.#renderExpandable(`{${keys.length}}`, "bracket", container =>
|
||||||
|
this.#buildChildren(value, doc, container)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) {
|
||||||
|
return this.#makeSpan("bracket", "[]");
|
||||||
|
}
|
||||||
|
return this.#renderExpandable(`[${value.length}]`, "bracket", container =>
|
||||||
|
this.#buildChildren(value, doc, container)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primitives
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return this.#makeSpan("str-value", JSON.stringify(value));
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return this.#makeSpan("num-value", String(value));
|
||||||
|
}
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return this.#makeSpan("bool-value", String(value));
|
||||||
|
}
|
||||||
|
return this.#makeSpan("null-value", "null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a lazy-loading expand/collapse widget for a ref (string or object).
|
||||||
|
* Results are cached in #refCache keyed by "num:gen".
|
||||||
|
*/
|
||||||
|
#renderRef(ref, doc) {
|
||||||
|
// Derive the cache key and display label from whichever form we received.
|
||||||
|
// String refs look like "10 0 R"; object refs are { num, gen }.
|
||||||
|
let cacheKey, label;
|
||||||
|
if (typeof ref === "string") {
|
||||||
|
const parts = ref.split(" ");
|
||||||
|
cacheKey = `${parts[0]}:${parts[1]}`;
|
||||||
|
label = ref;
|
||||||
|
} else {
|
||||||
|
cacheKey = `${ref.num}:${ref.gen}`;
|
||||||
|
label = this.#refLabel(ref);
|
||||||
|
}
|
||||||
|
return this.#makeExpandable(
|
||||||
|
this.#makeSpan("ref", label),
|
||||||
|
`reference ${label}`,
|
||||||
|
childrenEl => {
|
||||||
|
const spinner = document.createElement("div");
|
||||||
|
spinner.role = "status";
|
||||||
|
spinner.textContent = "Loading…";
|
||||||
|
childrenEl.append(spinner);
|
||||||
|
this.#onMarkLoading(1);
|
||||||
|
if (!this.#refCache.has(cacheKey)) {
|
||||||
|
this.#refCache.set(cacheKey, doc.getRawData({ ref }));
|
||||||
|
}
|
||||||
|
this.#refCache
|
||||||
|
.get(cacheKey)
|
||||||
|
.then(result => {
|
||||||
|
childrenEl.replaceChildren();
|
||||||
|
this.#buildChildren(result, doc, childrenEl);
|
||||||
|
})
|
||||||
|
.catch(err =>
|
||||||
|
childrenEl.replaceChildren(this.#makeErrorNode(err.message))
|
||||||
|
)
|
||||||
|
.finally(() => this.#onMarkLoading(-1));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a shared expand/collapse widget.
|
||||||
|
* labelEl is the element shown between the toggle arrow and the children.
|
||||||
|
* ariaLabel is used for the toggle and group aria-labels.
|
||||||
|
* onFirstOpen(childrenEl) is called once when first expanded (may be async).
|
||||||
|
*/
|
||||||
|
#makeExpandable(labelEl, ariaLabel, onFirstOpen) {
|
||||||
|
const toggleEl = document.createElement("span");
|
||||||
|
toggleEl.textContent = ARROW_COLLAPSED;
|
||||||
|
toggleEl.role = "button";
|
||||||
|
toggleEl.tabIndex = 0;
|
||||||
|
toggleEl.ariaExpanded = "false";
|
||||||
|
toggleEl.ariaLabel = `Expand ${ariaLabel}`;
|
||||||
|
labelEl.ariaHidden = "true";
|
||||||
|
|
||||||
|
const childrenEl = document.createElement("div");
|
||||||
|
childrenEl.className = "hidden";
|
||||||
|
childrenEl.role = "group";
|
||||||
|
childrenEl.ariaLabel = `Contents of ${ariaLabel}`;
|
||||||
|
|
||||||
|
let open = false,
|
||||||
|
done = false;
|
||||||
|
const toggle = () => {
|
||||||
|
open = !open;
|
||||||
|
toggleEl.textContent = open ? ARROW_EXPANDED : ARROW_COLLAPSED;
|
||||||
|
toggleEl.ariaExpanded = String(open);
|
||||||
|
childrenEl.classList.toggle("hidden", !open);
|
||||||
|
if (open && !done) {
|
||||||
|
done = true;
|
||||||
|
onFirstOpen(childrenEl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
toggleEl.addEventListener("click", toggle);
|
||||||
|
toggleEl.addEventListener("keydown", e => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
labelEl.addEventListener("click", toggle);
|
||||||
|
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
frag.append(toggleEl, labelEl, childrenEl);
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a synchronous expand/collapse widget.
|
||||||
|
* @param {string} label Text shown on the collapsed line.
|
||||||
|
* @param {string} labelClass CSS class for the label.
|
||||||
|
* @param {Function} buildFn Called with (containerEl) on first open.
|
||||||
|
*/
|
||||||
|
#renderExpandable(label, labelClass, buildFn) {
|
||||||
|
return this.#makeExpandable(
|
||||||
|
this.#makeSpan(labelClass, label),
|
||||||
|
label,
|
||||||
|
buildFn
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render image data (RGBA Uint8ClampedArray) into a <canvas> node.
|
||||||
|
*/
|
||||||
|
#renderImageData({ width, height, data }) {
|
||||||
|
const node = document.createElement("div");
|
||||||
|
node.className = "node";
|
||||||
|
const keyEl = document.createElement("span");
|
||||||
|
keyEl.className = "key";
|
||||||
|
keyEl.textContent = "imageData";
|
||||||
|
const sep = document.createElement("span");
|
||||||
|
sep.className = "separator";
|
||||||
|
sep.textContent = ": ";
|
||||||
|
const info = document.createElement("span");
|
||||||
|
info.className = "stream-label";
|
||||||
|
info.textContent = `<${width}×${height}>`;
|
||||||
|
node.append(keyEl, sep, info);
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.className = "image-preview";
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.style.width = `${width / dpr}px`;
|
||||||
|
canvas.style.aspectRatio = `${width} / ${height}`;
|
||||||
|
canvas.ariaLabel = `Image preview ${width}×${height}`;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const imgData = new ImageData(new Uint8ClampedArray(data), width, height);
|
||||||
|
ctx.putImageData(imgData, 0, 0);
|
||||||
|
node.append(canvas);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
#isMostlyText(str) {
|
||||||
|
let printable = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const c = str.charCodeAt(i);
|
||||||
|
if (c >= 0x20 && c <= 0x7e) {
|
||||||
|
printable++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return str.length > 0 && printable / str.length >= 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#formatBytes(str) {
|
||||||
|
const mostlyText = this.#isMostlyText(str);
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
|
||||||
|
if (!mostlyText) {
|
||||||
|
// Binary content: render every byte as hex in a single span.
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "bytes-hex";
|
||||||
|
const hexParts = [];
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hexParts.push(
|
||||||
|
str.charCodeAt(i).toString(16).toUpperCase().padStart(2, "0")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
span.textContent = hexParts.join("\u00B7\u200B");
|
||||||
|
frag.append(span);
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text content: printable ASCII + 0x0A as-is, other bytes as hex spans.
|
||||||
|
const isPrintable = c => (c >= 0x20 && c <= 0x7e) || c === 0x0a;
|
||||||
|
let i = 0;
|
||||||
|
while (i < str.length) {
|
||||||
|
const code = str.charCodeAt(i);
|
||||||
|
if (isPrintable(code)) {
|
||||||
|
let run = "";
|
||||||
|
while (i < str.length && isPrintable(str.charCodeAt(i))) {
|
||||||
|
run += str[i++];
|
||||||
|
}
|
||||||
|
frag.append(document.createTextNode(run));
|
||||||
|
} else {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "bytes-hex";
|
||||||
|
const hexParts = [];
|
||||||
|
while (i < str.length && !isPrintable(str.charCodeAt(i))) {
|
||||||
|
hexParts.push(
|
||||||
|
str.charCodeAt(i).toString(16).toUpperCase().padStart(2, "0")
|
||||||
|
);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
span.textContent = hexParts.join("\u00B7\u200B");
|
||||||
|
frag.append(span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a <span> with the given class and text content.
|
||||||
|
#makeSpan(className, text) {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = className;
|
||||||
|
span.textContent = text;
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
#isPDFName(val) {
|
||||||
|
return (
|
||||||
|
val !== null &&
|
||||||
|
typeof val === "object" &&
|
||||||
|
!Array.isArray(val) &&
|
||||||
|
typeof val.name === "string" &&
|
||||||
|
Object.keys(val).length === 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ref objects arrive as { num: N, gen: G } after structured clone.
|
||||||
|
#isRefObject(val) {
|
||||||
|
return (
|
||||||
|
val !== null &&
|
||||||
|
typeof val === "object" &&
|
||||||
|
!Array.isArray(val) &&
|
||||||
|
typeof val.num === "number" &&
|
||||||
|
typeof val.gen === "number" &&
|
||||||
|
Object.keys(val).length === 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#refLabel(ref) {
|
||||||
|
return ref.gen !== 0 ? `${ref.num}R${ref.gen}` : `${ref.num}R`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page content streams:
|
||||||
|
// { contentStream: true, instructions, cmdNames, rawContents }.
|
||||||
|
#isContentStream(val) {
|
||||||
|
return (
|
||||||
|
val !== null &&
|
||||||
|
typeof val === "object" &&
|
||||||
|
val.contentStream === true &&
|
||||||
|
Array.isArray(val.instructions) &&
|
||||||
|
Array.isArray(val.rawContents)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streams: { dict, bytes }, { dict, imageData },
|
||||||
|
// or { dict, contentStream: true, instructions, cmdNames } (Form XObject).
|
||||||
|
#isStream(val) {
|
||||||
|
return (
|
||||||
|
val !== null &&
|
||||||
|
typeof val === "object" &&
|
||||||
|
!Array.isArray(val) &&
|
||||||
|
Object.prototype.hasOwnProperty.call(val, "dict") &&
|
||||||
|
(Object.prototype.hasOwnProperty.call(val, "bytes") ||
|
||||||
|
Object.prototype.hasOwnProperty.call(val, "imageData") ||
|
||||||
|
val.contentStream === true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#isImageStream(val) {
|
||||||
|
return (
|
||||||
|
this.#isStream(val) &&
|
||||||
|
Object.prototype.hasOwnProperty.call(val, "imageData")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#isFormXObjectStream(val) {
|
||||||
|
return this.#isStream(val) && val.contentStream === true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TreeView };
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user