mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-04-10 07:14:04 +02:00
478 lines
14 KiB
JavaScript
478 lines
14 KiB
JavaScript
/* Copyright 2026 Mozilla Foundation
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
// Properties of CanvasRenderingContext2D that we track while stepping.
|
|
const TRACKED_CTX_PROPS = new Set([
|
|
"direction",
|
|
"fillStyle",
|
|
"filter",
|
|
"font",
|
|
"globalAlpha",
|
|
"globalCompositeOperation",
|
|
"imageSmoothingEnabled",
|
|
"lineCap",
|
|
"lineDashOffset",
|
|
"lineJoin",
|
|
"lineWidth",
|
|
"miterLimit",
|
|
"strokeStyle",
|
|
"textAlign",
|
|
"textBaseline",
|
|
]);
|
|
|
|
// Methods that modify the current transform matrix.
|
|
const TRANSFORM_METHODS = new Set([
|
|
"resetTransform",
|
|
"rotate",
|
|
"scale",
|
|
"setTransform",
|
|
"transform",
|
|
"translate",
|
|
]);
|
|
|
|
// Maps every tracked context property to a function that reads its current
|
|
// value from a CanvasRenderingContext2D. Covers directly-readable properties
|
|
// (TRACKED_CTX_PROPS) and method-read ones (lineDash, transform).
|
|
const CTX_PROP_READERS = new Map([
|
|
...Array.from(TRACKED_CTX_PROPS, p => [p, ctx => ctx[p]]),
|
|
["lineDash", ctx => ctx.getLineDash()],
|
|
[
|
|
"transform",
|
|
ctx => {
|
|
const { a, b, c, d, e, f } = ctx.getTransform();
|
|
return { a, b, c, d, e, f };
|
|
},
|
|
],
|
|
]);
|
|
|
|
// Color properties whose value is rendered as a swatch.
|
|
const COLOR_CTX_PROPS = new Set(["fillStyle", "shadowColor", "strokeStyle"]);
|
|
|
|
const MATHML_NS = "http://www.w3.org/1998/Math/MathML";
|
|
|
|
/**
|
|
* Tracks and displays the CanvasRenderingContext2D graphics state for all
|
|
* contexts created during a stepped render.
|
|
*
|
|
* @param {HTMLElement} panelEl The #gfx-state-panel DOM element.
|
|
*/
|
|
class CanvasContextDetailsView {
|
|
#panel;
|
|
|
|
// Map<label, Map<prop, value>> — live graphics state per tracked context.
|
|
#ctxStates = new Map();
|
|
|
|
// Map<label, Array<Map<prop, value>>> — save() stack snapshots per context.
|
|
#ctxStateStacks = new Map();
|
|
|
|
// Map<label, number|null> — which stack frame is shown; null = live/current.
|
|
#ctxStackViewIdx = new Map();
|
|
|
|
// Map<label, Map<prop, {valEl, swatchEl?}>> — DOM elements for live updates.
|
|
#gfxStateValueElements = new Map();
|
|
|
|
// Map<label, {container, prevBtn, pos, nextBtn}> — stack-nav DOM elements.
|
|
#gfxStateNavElements = new Map();
|
|
|
|
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) {
|
|
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.title = "View older saved state";
|
|
prevBtn.textContent = "←";
|
|
|
|
const pos = document.createElement("span");
|
|
pos.className = "gfx-state-stack-pos";
|
|
|
|
const nextBtn = document.createElement("button");
|
|
nextBtn.className = "gfx-state-stack-button";
|
|
nextBtn.title = "View newer saved state";
|
|
nextBtn.textContent = "→";
|
|
|
|
navContainer.append(prevBtn, pos, nextBtn);
|
|
title.append(titleLabel, navContainer);
|
|
section.append(title);
|
|
|
|
this.#gfxStateNavElements.set(ctxLabel, {
|
|
container: navContainer,
|
|
prevBtn,
|
|
pos,
|
|
nextBtn,
|
|
});
|
|
|
|
prevBtn.addEventListener("click", () => this.#navigate(ctxLabel, -1));
|
|
nextBtn.addEventListener("click", () => this.#navigate(ctxLabel, +1));
|
|
|
|
for (const [prop, value] of state) {
|
|
const row = document.createElement("div");
|
|
row.className = "gfx-state-row";
|
|
|
|
const key = document.createElement("span");
|
|
key.className = "gfx-state-key";
|
|
key.textContent = prop;
|
|
|
|
row.append(key);
|
|
|
|
if (prop === "transform") {
|
|
const { math, mnEls } = this.#buildTransformMathML(value);
|
|
row.append(math);
|
|
propEls.set(prop, { valEl: math, swatchEl: null, mnEls });
|
|
} else {
|
|
const val = document.createElement("span");
|
|
val.className = "gfx-state-val";
|
|
const text = this.#formatCtxValue(value);
|
|
val.textContent = text;
|
|
val.title = text;
|
|
let swatchEl = null;
|
|
if (COLOR_CTX_PROPS.has(prop)) {
|
|
swatchEl = document.createElement("span");
|
|
swatchEl.className = "color-swatch";
|
|
swatchEl.style.background = String(value);
|
|
row.append(swatchEl);
|
|
}
|
|
row.append(val);
|
|
propEls.set(prop, { valEl: val, swatchEl });
|
|
}
|
|
section.append(row);
|
|
}
|
|
this.#panel.append(section);
|
|
|
|
// Apply the correct state for the current view index (may be a saved
|
|
// frame).
|
|
this.#showState(ctxLabel);
|
|
this.#updateStackNav(ctxLabel);
|
|
}
|
|
}
|
|
|
|
/** 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 };
|