mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-04-09 23:04:02 +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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -2372,13 +2372,13 @@ gulp.task("check_l10n", function (done) {
|
||||
|
||||
function createInternalViewerBundle(defines) {
|
||||
const viewerFileConfig = createWebpackConfig(defines, {
|
||||
filename: "pdf_internal_viewer.mjs",
|
||||
filename: "debugger.mjs",
|
||||
library: {
|
||||
type: "module",
|
||||
},
|
||||
});
|
||||
return gulp
|
||||
.src("./web/pdf_internal_viewer.js", { encoding: false })
|
||||
.src("./web/internal/debugger.js", { encoding: false })
|
||||
.pipe(webpack2Stream(viewerFileConfig));
|
||||
}
|
||||
|
||||
@ -2389,10 +2389,10 @@ function buildInternalViewer(defines, dir) {
|
||||
createMainBundle(defines).pipe(gulp.dest(dir + "build")),
|
||||
createWorkerBundle(defines).pipe(gulp.dest(dir + "build")),
|
||||
createInternalViewerBundle(defines).pipe(gulp.dest(dir + "web")),
|
||||
preprocessHTML("web/pdf_internal_viewer.html", defines).pipe(
|
||||
preprocessHTML("web/internal/debugger.html", defines).pipe(
|
||||
gulp.dest(dir + "web")
|
||||
),
|
||||
preprocessCSS("web/pdf_internal_viewer.css", defines)
|
||||
preprocessCSS("web/internal/debugger.css", defines)
|
||||
.pipe(
|
||||
postcss([
|
||||
postcssDirPseudoClass(),
|
||||
|
||||
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 name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PDF.js — Debugging tools</title>
|
||||
<link rel="stylesheet" href="pdf_internal_viewer.css" />
|
||||
<link rel="stylesheet" href="debugger.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="header">
|
||||
@ -34,8 +34,8 @@ limitations under the License.
|
||||
<span id="goto-input-hint" class="sr-only">
|
||||
Enter a page number (e.g. 5), a reference as numR (e.g. 10R) or numRgen (e.g. 10R2). Press Enter to navigate.
|
||||
</span>
|
||||
<button id="debug-btn" hidden>Debug page</button>
|
||||
<button id="debug-back-btn" hidden>← Back to tree</button>
|
||||
<button id="debug-button" hidden>Debug page</button>
|
||||
<button id="debug-back-button" hidden>← Back to tree</button>
|
||||
<span id="status" role="status" aria-live="polite"> Select a PDF file to explore its internal structure. </span>
|
||||
<a id="github-link" href="https://github.com/mozilla/pdf.js" target="_blank" rel="noopener noreferrer" aria-label="PDF.js on GitHub">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
@ -49,25 +49,20 @@ limitations under the License.
|
||||
<div id="debug-view" hidden>
|
||||
<div id="render-panels">
|
||||
<div id="op-left-col">
|
||||
<div id="op-top-row">
|
||||
<div id="op-list-panel">
|
||||
<div id="op-list" role="listbox" aria-label="Operator list"></div>
|
||||
</div>
|
||||
<div id="op-gfx-state-resizer" role="separator" aria-orientation="vertical" tabindex="0" hidden></div>
|
||||
<div id="gfx-state-panel" aria-label="Graphics state" hidden></div>
|
||||
<div id="op-list-panel">
|
||||
<div id="op-list" role="listbox" aria-label="Operator list"></div>
|
||||
</div>
|
||||
<div id="op-resizer" role="separator" aria-orientation="horizontal" tabindex="0"></div>
|
||||
<div id="op-detail-panel"></div>
|
||||
<div id="gfx-state-panel" aria-label="Graphics state" hidden></div>
|
||||
</div>
|
||||
<div id="render-resizer" role="separator" aria-orientation="vertical" tabindex="0"></div>
|
||||
<div id="canvas-panel">
|
||||
<div id="canvas-toolbar" role="toolbar" aria-label="Zoom controls">
|
||||
<button id="zoom-out-btn" aria-label="Zoom out">−</button>
|
||||
<button id="zoom-out-button" aria-label="Zoom out">−</button>
|
||||
<span id="zoom-level" aria-live="polite"></span>
|
||||
<button id="zoom-in-btn" aria-label="Zoom in">+</button>
|
||||
<button id="redraw-btn" aria-label="Redraw page">Redraw</button>
|
||||
<button id="step-btn" aria-label="Step one instruction" disabled><u>S</u>tep</button>
|
||||
<button id="continue-btn" aria-label="Continue to next breakpoint" disabled><u>C</u>ontinue</button>
|
||||
<button id="zoom-in-button" aria-label="Zoom in">+</button>
|
||||
<button id="redraw-button" aria-label="Redraw page">Redraw</button>
|
||||
<button id="step-button" aria-label="Step one instruction" disabled><u>S</u>tep</button>
|
||||
<button id="continue-button" aria-label="Continue to next breakpoint" disabled><u>C</u>ontinue</button>
|
||||
</div>
|
||||
<div id="canvas-scroll">
|
||||
<div id="canvas-wrapper">
|
||||
@ -93,25 +88,25 @@ limitations under the License.
|
||||
|
||||
<!--#if GENERIC-->
|
||||
<!--<script src="../build/pdf.mjs" type="module"></script>-->
|
||||
<!--<script src="pdf_internal_viewer.mjs" type="module"></script>-->
|
||||
<!--<script src="debugger.mjs" type="module"></script>-->
|
||||
<!--#else-->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"pdfjs/": "../src/",
|
||||
"pdfjs-lib": "../src/pdf.js",
|
||||
"pdfjs/": "../../src/",
|
||||
"pdfjs-lib": "../../src/pdf.js",
|
||||
|
||||
"display-cmap_reader_factory": "../src/display/cmap_reader_factory.js",
|
||||
"display-standard_fontdata_factory": "../src/display/standard_fontdata_factory.js",
|
||||
"display-wasm_factory": "../src/display/wasm_factory.js",
|
||||
"display-fetch_stream": "../src/display/fetch_stream.js",
|
||||
"display-network": "../src/display/network.js",
|
||||
"display-node_stream": "../src/display/stubs.js",
|
||||
"display-node_utils": "../src/display/stubs.js"
|
||||
"display-cmap_reader_factory": "../../src/display/cmap_reader_factory.js",
|
||||
"display-standard_fontdata_factory": "../../src/display/standard_fontdata_factory.js",
|
||||
"display-wasm_factory": "../../src/display/wasm_factory.js",
|
||||
"display-fetch_stream": "../../src/display/fetch_stream.js",
|
||||
"display-network": "../../src/display/network.js",
|
||||
"display-node_stream": "../../src/display/stubs.js",
|
||||
"display-node_utils": "../../src/display/stubs.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="pdf_internal_viewer.js" type="module"></script>
|
||||
<script src="debugger.js" type="module"></script>
|
||||
<!--#endif-->
|
||||
</body>
|
||||
</html>
|
||||
255
web/internal/debugger.js
Normal file
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