fork from https://github.com/mozilla/pdf.js.git
This commit is contained in:
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;
|
||||
}
|
||||
509
web/internal/canvas_context_details_view.js
Normal file
509
web/internal/canvas_context_details_view.js
Normal file
@@ -0,0 +1,509 @@
|
||||
/* Copyright 2026 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Properties of CanvasRenderingContext2D that we track while stepping.
|
||||
const TRACKED_CTX_PROPS = new Set([
|
||||
"direction",
|
||||
"fillStyle",
|
||||
"filter",
|
||||
"font",
|
||||
"globalAlpha",
|
||||
"globalCompositeOperation",
|
||||
"imageSmoothingEnabled",
|
||||
"lineCap",
|
||||
"lineDashOffset",
|
||||
"lineJoin",
|
||||
"lineWidth",
|
||||
"miterLimit",
|
||||
"strokeStyle",
|
||||
"textAlign",
|
||||
"textBaseline",
|
||||
]);
|
||||
|
||||
// Methods that modify the current transform matrix.
|
||||
const TRANSFORM_METHODS = new Set([
|
||||
"resetTransform",
|
||||
"rotate",
|
||||
"scale",
|
||||
"setTransform",
|
||||
"transform",
|
||||
"translate",
|
||||
]);
|
||||
|
||||
// Maps every tracked context property to a function that reads its current
|
||||
// value from a CanvasRenderingContext2D. Covers directly-readable properties
|
||||
// (TRACKED_CTX_PROPS) and method-read ones (lineDash, transform).
|
||||
const CTX_PROP_READERS = new Map([
|
||||
...Array.from(TRACKED_CTX_PROPS, p => [p, ctx => ctx[p]]),
|
||||
["lineDash", ctx => ctx.getLineDash()],
|
||||
[
|
||||
"transform",
|
||||
ctx => {
|
||||
const { a, b, c, d, e, f } = ctx.getTransform();
|
||||
return { a, b, c, d, e, f };
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
// Color properties whose value is rendered as a swatch.
|
||||
const COLOR_CTX_PROPS = new Set(["fillStyle", "shadowColor", "strokeStyle"]);
|
||||
|
||||
const MATHML_NS = "http://www.w3.org/1998/Math/MathML";
|
||||
|
||||
/**
|
||||
* Tracks and displays the CanvasRenderingContext2D graphics state for all
|
||||
* contexts created during a stepped render.
|
||||
*
|
||||
* @param {HTMLElement} panelEl The #gfx-state-panel DOM element.
|
||||
*/
|
||||
class CanvasContextDetailsView {
|
||||
#panel;
|
||||
|
||||
// Map<label, Map<prop, value>> — live graphics state per tracked context.
|
||||
#ctxStates = new Map();
|
||||
|
||||
// Map<label, Array<Map<prop, value>>> — save() stack snapshots per context.
|
||||
#ctxStateStacks = new Map();
|
||||
|
||||
// Map<label, number|null> — which stack frame is shown; null = live/current.
|
||||
#ctxStackViewIdx = new Map();
|
||||
|
||||
// Map<label, Map<prop, {valEl, swatchEl?}>> — DOM elements for live updates.
|
||||
#gfxStateValueElements = new Map();
|
||||
|
||||
// Map<label, {container, prevBtn, pos, nextBtn}> — stack-nav DOM elements.
|
||||
#gfxStateNavElements = new Map();
|
||||
|
||||
// When true, suppress live DOM updates; build() re-reads state and resets it.
|
||||
#frozen = false;
|
||||
|
||||
constructor(panelEl) {
|
||||
this.#panel = panelEl;
|
||||
}
|
||||
|
||||
/** Suppress live DOM updates until the next build() call. */
|
||||
freeze() {
|
||||
this.#frozen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a CanvasRenderingContext2D to track its graphics state.
|
||||
* Returns a Proxy that keeps internal state in sync and updates the DOM.
|
||||
*/
|
||||
wrapContext(ctx, label) {
|
||||
const state = new Map();
|
||||
for (const [prop, read] of CTX_PROP_READERS) {
|
||||
state.set(prop, read(ctx));
|
||||
}
|
||||
this.#ctxStates.set(label, state);
|
||||
this.#ctxStateStacks.set(label, []);
|
||||
this.#ctxStackViewIdx.set(label, null);
|
||||
// If the panel is already visible and we're at a pause point, rebuild it
|
||||
// so the new context section is added and its live-update entries are
|
||||
// registered. Skip the rebuild while frozen (execution is in progress
|
||||
// between pauses) — the next build() call from #onStepped() will pick it
|
||||
// up.
|
||||
if (!this.#frozen && this.#gfxStateValueElements.size > 0) {
|
||||
this.build();
|
||||
}
|
||||
|
||||
return new Proxy(ctx, {
|
||||
set: (target, prop, value) => {
|
||||
target[prop] = value;
|
||||
if (TRACKED_CTX_PROPS.has(prop)) {
|
||||
state.set(prop, value);
|
||||
this.#updatePropEl(label, prop, value);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
get: (target, prop) => {
|
||||
const val = target[prop];
|
||||
if (typeof val !== "function") {
|
||||
return val;
|
||||
}
|
||||
if (prop === "save") {
|
||||
return (...args) => {
|
||||
const result = val.apply(target, args);
|
||||
this.#ctxStateStacks.get(label).push(this.#copyState(state));
|
||||
this.#updateStackNav(label);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
if (prop === "restore") {
|
||||
return (...args) => {
|
||||
const result = val.apply(target, args);
|
||||
for (const [p, read] of CTX_PROP_READERS) {
|
||||
const v = read(target);
|
||||
state.set(p, v);
|
||||
this.#updatePropEl(label, p, v);
|
||||
}
|
||||
const stack = this.#ctxStateStacks.get(label);
|
||||
if (stack.length > 0) {
|
||||
stack.pop();
|
||||
// If the viewed frame was just removed, fall back to current.
|
||||
const viewIndex = this.#ctxStackViewIdx.get(label);
|
||||
if (viewIndex !== null && viewIndex >= stack.length) {
|
||||
this.#ctxStackViewIdx.set(label, null);
|
||||
this.#showState(label);
|
||||
}
|
||||
this.#updateStackNav(label);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
if (prop === "setLineDash") {
|
||||
return segments => {
|
||||
val.call(target, segments);
|
||||
const dash = target.getLineDash();
|
||||
state.set("lineDash", dash);
|
||||
this.#updatePropEl(label, "lineDash", dash);
|
||||
};
|
||||
}
|
||||
if (TRANSFORM_METHODS.has(prop)) {
|
||||
return (...args) => {
|
||||
const result = val.apply(target, args);
|
||||
const { a, b, c, d, e, f } = target.getTransform();
|
||||
const tf = { a, b, c, d, e, f };
|
||||
state.set("transform", tf);
|
||||
this.#updatePropEl(label, "transform", tf);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
return val.bind(target);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Override canvas.getContext to return a tracked proxy for "2d" contexts.
|
||||
* Caches the proxy so repeated getContext("2d") calls return the same
|
||||
* wrapper.
|
||||
*/
|
||||
wrapCanvasGetContext(canvas, label) {
|
||||
let wrappedCtx = null;
|
||||
const origGetContext = canvas.getContext.bind(canvas);
|
||||
canvas.getContext = (type, ...args) => {
|
||||
const ctx = origGetContext(type, ...args);
|
||||
if (type !== "2d") {
|
||||
return ctx;
|
||||
}
|
||||
wrappedCtx ??= this.wrapContext(ctx, label);
|
||||
return wrappedCtx;
|
||||
};
|
||||
return canvas.getContext("2d");
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the graphics-state panel DOM for all currently tracked contexts.
|
||||
* Shows the panel if it was hidden.
|
||||
*/
|
||||
build() {
|
||||
this.#frozen = false;
|
||||
this.#panel.hidden = false;
|
||||
this.#panel.replaceChildren();
|
||||
this.#gfxStateValueElements.clear();
|
||||
this.#gfxStateNavElements.clear();
|
||||
|
||||
for (const [ctxLabel, state] of this.#ctxStates) {
|
||||
const propEls = new Map();
|
||||
this.#gfxStateValueElements.set(ctxLabel, propEls);
|
||||
|
||||
const section = document.createElement("div");
|
||||
section.className = "gfx-state-section";
|
||||
section.dataset.ctxLabel = ctxLabel;
|
||||
|
||||
// Title row with label and stack-navigation arrows.
|
||||
const title = document.createElement("div");
|
||||
title.className = "gfx-state-title";
|
||||
|
||||
const titleLabel = document.createElement("span");
|
||||
titleLabel.textContent = ctxLabel;
|
||||
|
||||
const navContainer = document.createElement("span");
|
||||
navContainer.className = "gfx-state-stack-nav";
|
||||
navContainer.hidden = true;
|
||||
|
||||
const prevBtn = document.createElement("button");
|
||||
prevBtn.className = "gfx-state-stack-button";
|
||||
prevBtn.title = "View older saved state";
|
||||
prevBtn.textContent = "←";
|
||||
|
||||
const pos = document.createElement("span");
|
||||
pos.className = "gfx-state-stack-pos";
|
||||
|
||||
const nextBtn = document.createElement("button");
|
||||
nextBtn.className = "gfx-state-stack-button";
|
||||
nextBtn.title = "View newer saved state";
|
||||
nextBtn.textContent = "→";
|
||||
|
||||
navContainer.append(prevBtn, pos, nextBtn);
|
||||
title.append(titleLabel, navContainer);
|
||||
section.append(title);
|
||||
|
||||
this.#gfxStateNavElements.set(ctxLabel, {
|
||||
container: navContainer,
|
||||
prevBtn,
|
||||
pos,
|
||||
nextBtn,
|
||||
});
|
||||
|
||||
prevBtn.addEventListener("click", () => this.#navigate(ctxLabel, -1));
|
||||
nextBtn.addEventListener("click", () => this.#navigate(ctxLabel, +1));
|
||||
|
||||
for (const [prop, value] of state) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "gfx-state-row";
|
||||
|
||||
const key = document.createElement("span");
|
||||
key.className = "gfx-state-key";
|
||||
key.textContent = prop;
|
||||
|
||||
row.append(key);
|
||||
|
||||
if (prop === "transform") {
|
||||
const { math, mnEls } = this.#buildTransformMathML(value);
|
||||
row.append(math);
|
||||
propEls.set(prop, { valEl: math, swatchEl: null, mnEls });
|
||||
} else {
|
||||
const val = document.createElement("span");
|
||||
val.className = "gfx-state-val";
|
||||
const text = this.#formatCtxValue(value);
|
||||
val.textContent = text;
|
||||
val.title = text;
|
||||
let swatchEl = null;
|
||||
if (COLOR_CTX_PROPS.has(prop)) {
|
||||
swatchEl = document.createElement("span");
|
||||
swatchEl.className = "color-swatch";
|
||||
swatchEl.style.background = String(value);
|
||||
row.append(swatchEl);
|
||||
}
|
||||
row.append(val);
|
||||
propEls.set(prop, { valEl: val, swatchEl });
|
||||
}
|
||||
section.append(row);
|
||||
}
|
||||
this.#panel.append(section);
|
||||
|
||||
// Apply the correct state for the current view index (may be a saved
|
||||
// frame).
|
||||
this.#showState(ctxLabel);
|
||||
this.#updateStackNav(ctxLabel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the section for a single context from the panel and all internal
|
||||
* state maps. Called when a temporary canvas is destroyed.
|
||||
*/
|
||||
removeContext(label) {
|
||||
this.#ctxStates.delete(label);
|
||||
this.#ctxStateStacks.delete(label);
|
||||
this.#ctxStackViewIdx.delete(label);
|
||||
this.#gfxStateValueElements.delete(label);
|
||||
this.#gfxStateNavElements.delete(label);
|
||||
this.#panel
|
||||
.querySelector(
|
||||
`.gfx-state-section[data-ctx-label="${CSS.escape(label)}"]`
|
||||
)
|
||||
?.remove();
|
||||
}
|
||||
|
||||
/** Hide the panel and discard all DOM state so no live updates occur. */
|
||||
hide() {
|
||||
this.#panel.hidden = true;
|
||||
this.#gfxStateValueElements.clear();
|
||||
this.#gfxStateNavElements.clear();
|
||||
this.#panel.replaceChildren();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the panel to bring the section for the given context label into
|
||||
* view.
|
||||
*/
|
||||
scrollToSection(label) {
|
||||
this.#panel
|
||||
.querySelector(`[data-ctx-label="${CSS.escape(label)}"]`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all tracked state and reset the panel DOM.
|
||||
* Called when the debug view is reset between pages.
|
||||
*/
|
||||
clear() {
|
||||
this.#ctxStates.clear();
|
||||
this.#ctxStateStacks.clear();
|
||||
this.#ctxStackViewIdx.clear();
|
||||
this.#gfxStateValueElements.clear();
|
||||
this.#gfxStateNavElements.clear();
|
||||
this.#panel.replaceChildren();
|
||||
}
|
||||
|
||||
#formatCtxValue(value) {
|
||||
return Array.isArray(value) ? `[${value.join(", ")}]` : String(value);
|
||||
}
|
||||
|
||||
// Shallow-copy a state Map (arrays and plain objects are cloned one level
|
||||
// deep).
|
||||
#copyState(state) {
|
||||
const clone = v => {
|
||||
if (Array.isArray(v)) {
|
||||
return [...v];
|
||||
}
|
||||
if (typeof v === "object" && v !== null) {
|
||||
return { ...v };
|
||||
}
|
||||
return v;
|
||||
};
|
||||
return new Map([...state].map(([k, v]) => [k, clone(v)]));
|
||||
}
|
||||
|
||||
// Apply a single (label, prop, value) update to the DOM unconditionally.
|
||||
#applyPropEl(label, prop, value) {
|
||||
const entry = this.#gfxStateValueElements.get(label)?.get(prop);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
if (entry.mnEls) {
|
||||
for (const k of ["a", "b", "c", "d", "e", "f"]) {
|
||||
entry.mnEls[k].textContent = this.#formatMatrixValue(value[k]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const text = this.#formatCtxValue(value);
|
||||
entry.valEl.textContent = text;
|
||||
entry.valEl.title = text;
|
||||
if (entry.swatchEl) {
|
||||
entry.swatchEl.style.background = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Update DOM for a live setter — skipped when frozen (continuous execution)
|
||||
// or when the user is browsing a saved state.
|
||||
#updatePropEl(label, prop, value) {
|
||||
if (this.#frozen || this.#ctxStackViewIdx.get(label) !== null) {
|
||||
return;
|
||||
}
|
||||
this.#applyPropEl(label, prop, value);
|
||||
}
|
||||
|
||||
// Re-render all value DOM elements for label using the currently-viewed
|
||||
// state.
|
||||
#showState(label) {
|
||||
const viewIdx = this.#ctxStackViewIdx.get(label);
|
||||
const stateToShow =
|
||||
viewIdx === null
|
||||
? this.#ctxStates.get(label)
|
||||
: this.#ctxStateStacks.get(label)?.[viewIdx];
|
||||
if (!stateToShow) {
|
||||
return;
|
||||
}
|
||||
for (const [prop, value] of stateToShow) {
|
||||
this.#applyPropEl(label, prop, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the stack-nav button states and position counter for a context.
|
||||
#updateStackNav(label) {
|
||||
if (this.#frozen) {
|
||||
return;
|
||||
}
|
||||
const nav = this.#gfxStateNavElements.get(label);
|
||||
if (!nav) {
|
||||
return;
|
||||
}
|
||||
const stack = this.#ctxStateStacks.get(label) ?? [];
|
||||
const viewIdx = this.#ctxStackViewIdx.get(label);
|
||||
nav.container.hidden = stack.length === 0;
|
||||
if (stack.length === 0) {
|
||||
return;
|
||||
}
|
||||
nav.prevBtn.disabled = viewIdx === 0;
|
||||
nav.nextBtn.disabled = viewIdx === null;
|
||||
nav.pos.textContent =
|
||||
viewIdx === null ? "cur" : `${viewIdx + 1}/${stack.length}`;
|
||||
}
|
||||
|
||||
// Navigate the save/restore stack view for a context.
|
||||
// delta = -1 → older (prev) frame; +1 → newer (next) frame.
|
||||
#navigate(label, delta) {
|
||||
const stack = this.#ctxStateStacks.get(label) ?? [];
|
||||
const viewIndex = this.#ctxStackViewIdx.get(label);
|
||||
let newViewIndex;
|
||||
if (delta < 0) {
|
||||
newViewIndex = viewIndex === null ? stack.length - 1 : viewIndex - 1;
|
||||
if (newViewIndex < 0) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (viewIndex === null) {
|
||||
return;
|
||||
}
|
||||
newViewIndex = viewIndex >= stack.length - 1 ? null : viewIndex + 1;
|
||||
}
|
||||
this.#ctxStackViewIdx.set(label, newViewIndex);
|
||||
this.#showState(label);
|
||||
this.#updateStackNav(label);
|
||||
}
|
||||
|
||||
#mEl(tag, ...children) {
|
||||
const el = document.createElementNS(MATHML_NS, tag);
|
||||
el.append(...children);
|
||||
return el;
|
||||
}
|
||||
|
||||
#formatMatrixValue(v) {
|
||||
return Number.isInteger(v) ? String(v) : String(parseFloat(v.toFixed(4)));
|
||||
}
|
||||
|
||||
#buildTransformMathML({ a, b, c, d, e, f }) {
|
||||
const mnEls = {};
|
||||
for (const [k, v] of Object.entries({ a, b, c, d, e, f })) {
|
||||
mnEls[k] = this.#mEl("mn", this.#formatMatrixValue(v));
|
||||
}
|
||||
const math = this.#mEl(
|
||||
"math",
|
||||
this.#mEl(
|
||||
"mrow",
|
||||
this.#mEl("mo", "["),
|
||||
this.#mEl(
|
||||
"mtable",
|
||||
this.#mEl(
|
||||
"mtr",
|
||||
this.#mEl("mtd", mnEls.a),
|
||||
this.#mEl("mtd", mnEls.c),
|
||||
this.#mEl("mtd", mnEls.e)
|
||||
),
|
||||
this.#mEl(
|
||||
"mtr",
|
||||
this.#mEl("mtd", mnEls.b),
|
||||
this.#mEl("mtd", mnEls.d),
|
||||
this.#mEl("mtd", mnEls.f)
|
||||
),
|
||||
this.#mEl(
|
||||
"mtr",
|
||||
this.#mEl("mtd", this.#mEl("mn", "0")),
|
||||
this.#mEl("mtd", this.#mEl("mn", "0")),
|
||||
this.#mEl("mtd", this.#mEl("mn", "1"))
|
||||
)
|
||||
),
|
||||
this.#mEl("mo", "]")
|
||||
)
|
||||
);
|
||||
return { math, mnEls };
|
||||
}
|
||||
}
|
||||
|
||||
export { CanvasContextDetailsView };
|
||||
310
web/internal/debugger.css
Normal file
310
web/internal/debugger.css
Normal file
@@ -0,0 +1,310 @@
|
||||
/* Copyright 2026 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@import url(../text_layer_builder.css);
|
||||
@import url(canvas_context_details_view.css);
|
||||
@import url(draw_ops_view.css);
|
||||
@import url(font_view.css);
|
||||
@import url(multiline_view.css);
|
||||
@import url(page_view.css);
|
||||
@import url(split_view.css);
|
||||
@import url(tree_view.css);
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
/* Backgrounds */
|
||||
--bg-color: light-dark(#fff, #1e1e1e);
|
||||
--surface-bg: light-dark(#f3f3f3, #252526);
|
||||
--input-bg: light-dark(#fff, #3c3c3c);
|
||||
--button-bg: light-dark(#f3f3f3, #3c3c3c);
|
||||
--button-hover-bg: light-dark(#e0e0e0, #4a4a4a);
|
||||
--clr-canvas-bg: var(--surface-bg);
|
||||
|
||||
/* Text */
|
||||
--text-color: light-dark(#1e1e1e, #d4d4d4);
|
||||
--muted-color: light-dark(#6e6e6e, #888);
|
||||
--accent-color: light-dark(#0070c1, #9cdcfe);
|
||||
--accent-fg: light-dark(white, #1e1e1e);
|
||||
|
||||
/* Borders */
|
||||
--border-color: light-dark(#e0e0e0, #3c3c3c);
|
||||
--border-subtle-color: light-dark(#d0d0d0, #444);
|
||||
--input-border-color: light-dark(#c8c8c8, #555);
|
||||
|
||||
/* Interactive states */
|
||||
--hover-bg: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.05));
|
||||
--hover-color: currentColor;
|
||||
--paused-bg: light-dark(rgb(255 165 0 / 0.15), rgb(255 165 0 / 0.2));
|
||||
--paused-outline-color: rgb(255 140 0 / 0.6);
|
||||
--paused-color: currentColor;
|
||||
|
||||
/* Semantic */
|
||||
--ref-color: light-dark(#007b6e, #4ec9b0);
|
||||
--ref-hover-color: light-dark(#065, #89d9c8);
|
||||
--changed-bg: transparent;
|
||||
--changed-color: light-dark(#c00, #f66);
|
||||
--match-bg: light-dark(rgb(255 200 0 / 0.35), rgb(255 200 0 / 0.25));
|
||||
--match-outline-color: light-dark(rgb(200 140 0 / 0.8), rgb(255 200 0 / 0.6));
|
||||
|
||||
/* Syntax highlighting */
|
||||
--string-color: light-dark(#a31515, #ce9178);
|
||||
--number-color: light-dark(#098658, #b5cea8);
|
||||
--bool-color: light-dark(#00f, #569cd6);
|
||||
--null-color: light-dark(#767676, #808080);
|
||||
--name-color: light-dark(#795e26, #dcdcaa);
|
||||
--stream-color: light-dark(#af00db, #c586c0);
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
:root {
|
||||
/* Backgrounds */
|
||||
--bg-color: Canvas;
|
||||
--surface-bg: Canvas;
|
||||
--input-bg: Field;
|
||||
--button-bg: ButtonFace;
|
||||
--button-hover-bg: Highlight;
|
||||
--clr-canvas-bg: var(--surface-bg);
|
||||
|
||||
/* Text */
|
||||
--text-color: CanvasText;
|
||||
--muted-color: GrayText;
|
||||
--accent-color: CanvasText;
|
||||
|
||||
/* Borders */
|
||||
--border-color: ButtonBorder;
|
||||
--border-subtle-color: ButtonBorder;
|
||||
--input-border-color: ButtonBorder;
|
||||
|
||||
/* Interactive states */
|
||||
--hover-bg: Highlight;
|
||||
--hover-color: HighlightText;
|
||||
--paused-bg: Mark;
|
||||
--paused-outline-color: ButtonBorder;
|
||||
--paused-color: MarkText;
|
||||
|
||||
/* Semantic */
|
||||
--ref-color: LinkText;
|
||||
--ref-hover-color: ActiveText;
|
||||
--changed-bg: Mark;
|
||||
--changed-color: MarkText;
|
||||
--match-bg: Mark;
|
||||
--match-outline-color: ButtonBorder;
|
||||
|
||||
/* Syntax highlighting — replaced by plain text in HCM */
|
||||
--string-color: CanvasText;
|
||||
--number-color: CanvasText;
|
||||
--bool-color: CanvasText;
|
||||
--null-color: GrayText;
|
||||
--name-color: CanvasText;
|
||||
--stream-color: CanvasText;
|
||||
}
|
||||
|
||||
/* Opacity-only disabled style → explicit GrayText. */
|
||||
button:disabled,
|
||||
input:disabled {
|
||||
color: GrayText;
|
||||
border-color: GrayText;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
body.loading {
|
||||
cursor: wait;
|
||||
}
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* In debug mode the body must be viewport-height so #debug-view can fill it.
|
||||
In tree mode body is auto-height so the tree can grow and the page scrolls. */
|
||||
body:has(#debug-view:not([hidden])) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
#header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h1 {
|
||||
color: var(--accent-color);
|
||||
font-size: 1.2em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#pdf-info {
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 1.15em;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
#password-dialog {
|
||||
background: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--input-border-color);
|
||||
border-radius: 6px;
|
||||
padding: 20px;
|
||||
min-width: 320px;
|
||||
|
||||
&::backdrop {
|
||||
background: rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--input-border-color);
|
||||
border-radius: 3px;
|
||||
padding: 4px 8px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.password-dialog-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
|
||||
button {
|
||||
padding: 4px 14px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--input-border-color);
|
||||
background: var(--button-bg);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
|
||||
&:hover {
|
||||
background: var(--button-hover-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#controls {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 14px;
|
||||
background: var(--surface-bg);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--muted-color);
|
||||
}
|
||||
|
||||
#github-link {
|
||||
margin-inline-start: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--muted-color);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
#goto-input {
|
||||
background: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--input-border-color);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
&[aria-invalid="true"] {
|
||||
border-color: var(--changed-color);
|
||||
}
|
||||
}
|
||||
#status {
|
||||
color: var(--muted-color);
|
||||
font-style: italic;
|
||||
}
|
||||
#tree-button,
|
||||
#debug-button {
|
||||
padding: 4px 12px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--input-border-color);
|
||||
background: var(--button-bg);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
|
||||
&:hover {
|
||||
background: var(--button-hover-bg);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
137
web/internal/debugger.html
Normal file
137
web/internal/debugger.html
Normal file
@@ -0,0 +1,137 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Copyright 2026 Mozilla Foundation
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PDF.js — Debugging tools</title>
|
||||
<link rel="stylesheet" href="debugger.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="header">
|
||||
<h1>PDF.js — Debugging tools</h1>
|
||||
<span id="pdf-info" aria-live="polite"></span>
|
||||
</div>
|
||||
<div id="controls" role="toolbar" aria-label="PDF viewer controls">
|
||||
<label for="file-input">PDF file:</label>
|
||||
<input type="file" id="file-input" accept=".pdf" />
|
||||
<label for="goto-input">Go to:</label>
|
||||
<input type="text" id="goto-input" placeholder="num, numR, numRgen" size="18" disabled aria-describedby="goto-input-hint" aria-invalid="false" />
|
||||
<span id="goto-input-hint" class="sr-only">
|
||||
Enter a page number (e.g. 5), a reference as numR (e.g. 10R) or numRgen (e.g. 10R2). Press Enter to navigate.
|
||||
</span>
|
||||
<button id="tree-button">Tree</button>
|
||||
<button id="debug-button" disabled>Debug</button>
|
||||
<span id="status" role="status" aria-live="polite"> Select a PDF file to explore its internal structure. </span>
|
||||
<a id="github-link" href="https://github.com/mozilla/pdf.js" target="_blank" rel="noopener noreferrer" aria-label="PDF.js on GitHub">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<path
|
||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div id="tree" role="tree" aria-label="PDF internal structure"></div>
|
||||
<div id="debug-view" hidden>
|
||||
<div id="render-panels">
|
||||
<div id="op-left-col">
|
||||
<div id="op-list-panel">
|
||||
<div id="op-list" role="listbox" aria-label="Operator list"></div>
|
||||
</div>
|
||||
<div id="op-detail-panel"></div>
|
||||
<div id="gfx-state-panel" aria-label="Graphics state" hidden></div>
|
||||
</div>
|
||||
<div id="font-panel" hidden></div>
|
||||
<div id="canvas-panel">
|
||||
<div id="canvas-toolbar" role="toolbar" aria-label="Zoom controls">
|
||||
<div class="toolbar-left">
|
||||
<button id="text-filter-button" title="Show only text drawing operations" aria-pressed="false">T</button>
|
||||
<button id="text-layer-color-button" title="Text layer color">
|
||||
<span id="text-layer-color-swatch"></span>
|
||||
</button>
|
||||
<input type="color" id="text-layer-color-input" hidden />
|
||||
<button id="text-span-border-button" title="Show span borders" aria-pressed="false">
|
||||
<svg
|
||||
width="18"
|
||||
height="14"
|
||||
viewBox="0 0 18 14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-dasharray="2 1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="0.75" y="0.75" width="16.5" height="12.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button id="font-view-button" title="Show fonts used on page" aria-pressed="false">F</button>
|
||||
</div>
|
||||
<div class="toolbar-center">
|
||||
<button id="zoom-out-button" title="Zoom out">−</button>
|
||||
<span id="zoom-level" aria-live="polite"></span>
|
||||
<button id="zoom-in-button" title="Zoom in">+</button>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<button id="redraw-button" title="Redraw page">Redraw</button>
|
||||
<button id="step-button" title="Step one instruction" disabled><u>S</u>tep</button>
|
||||
<button id="continue-button" title="Continue to next breakpoint" disabled><u>C</u>ontinue</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="canvas-scroll">
|
||||
<div id="canvas-wrapper">
|
||||
<div class="canvas-checker">
|
||||
<canvas id="render-canvas"></canvas>
|
||||
</div>
|
||||
<canvas id="highlight-canvas" aria-hidden="true"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="password-dialog" aria-labelledby="password-dialog-title">
|
||||
<form method="dialog">
|
||||
<p id="password-dialog-title"></p>
|
||||
<label for="password-input">Password:</label>
|
||||
<input type="password" id="password-input" autocomplete="current-password" />
|
||||
<div class="password-dialog-buttons">
|
||||
<button type="submit" id="password-submit">OK</button>
|
||||
<button type="button" id="password-cancel">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!--#if GENERIC-->
|
||||
<!--<script src="../build/pdf.mjs" type="module"></script>-->
|
||||
<!--<script src="debugger.mjs" type="module"></script>-->
|
||||
<!--#else-->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"pdfjs/": "../../src/",
|
||||
"pdfjs-lib": "../../src/pdf.js",
|
||||
|
||||
"display-binary_data_factory": "../../src/display/binary_data_factory.js",
|
||||
"display-network_stream": "../../src/display/network_stream.js",
|
||||
"display-node_utils": "../../src/display/stubs.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="debugger.js" type="module"></script>
|
||||
<!--#endif-->
|
||||
</body>
|
||||
</html>
|
||||
272
web/internal/debugger.js
Normal file
272
web/internal/debugger.js
Normal file
@@ -0,0 +1,272 @@
|
||||
/* Copyright 2026 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getDocument, GlobalWorkerOptions, PasswordResponses } from "pdfjs-lib";
|
||||
import { PageView } from "./page_view.js";
|
||||
import { TreeView } from "./tree_view.js";
|
||||
|
||||
GlobalWorkerOptions.workerSrc =
|
||||
typeof PDFJSDev === "undefined"
|
||||
? "../../src/pdf.worker.js"
|
||||
: "../build/pdf.worker.mjs";
|
||||
|
||||
// Parses "num" into { page: num }, or "numR"/"numRgen" into { ref: {num,gen} }.
|
||||
// Returns null for invalid input.
|
||||
function parseGoToInput(str) {
|
||||
const match = str.trim().match(/^(\d+)(R(\d+)?)?$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
if (!match[2]) {
|
||||
return { page: parseInt(match[1], 10) };
|
||||
}
|
||||
return {
|
||||
ref: {
|
||||
num: parseInt(match[1], 10),
|
||||
gen: match[3] !== undefined ? parseInt(match[3], 10) : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Parses "num", "numR" or "numRgen" into { num, gen }, or returns null.
|
||||
// Used for URL hash param parsing where a bare number means a ref, not a page.
|
||||
function parseRefInput(str) {
|
||||
const match = str.trim().match(/^(\d+)(?:R(\d+)?)?$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
num: parseInt(match[1], 10),
|
||||
gen: match[2] !== undefined ? parseInt(match[2], 10) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
let pdfDoc = null;
|
||||
|
||||
// Page number currently displayed in the tree (null when showing a
|
||||
// ref/trailer).
|
||||
let currentPage = null;
|
||||
|
||||
// Count of in-flight getRawData calls; drives the body "loading" cursor.
|
||||
let loadingCount = 0;
|
||||
function markLoading(delta) {
|
||||
loadingCount += delta;
|
||||
document.body.classList.toggle("loading", loadingCount > 0);
|
||||
}
|
||||
|
||||
// Cache frequently accessed elements.
|
||||
const treeButton = document.getElementById("tree-button");
|
||||
const debugButton = document.getElementById("debug-button");
|
||||
const debugViewEl = document.getElementById("debug-view");
|
||||
const treeEl = document.getElementById("tree");
|
||||
const statusEl = document.getElementById("status");
|
||||
const gotoInput = document.getElementById("goto-input");
|
||||
const pdfInfoEl = document.getElementById("pdf-info");
|
||||
|
||||
const pageView = new PageView({ onMarkLoading: markLoading });
|
||||
|
||||
const treeView = new TreeView(treeEl, { onMarkLoading: markLoading });
|
||||
|
||||
async function loadTree(data, rootLabel = null) {
|
||||
currentPage = typeof data.page === "number" ? data.page : null;
|
||||
debugButton.disabled = currentPage === null;
|
||||
pageView.reset();
|
||||
debugViewEl.hidden = true;
|
||||
treeEl.hidden = false;
|
||||
await treeView.load(data, rootLabel, pdfDoc);
|
||||
}
|
||||
|
||||
async function openDocument(source, name) {
|
||||
statusEl.textContent = `Loading ${name}…`;
|
||||
pdfInfoEl.textContent = "";
|
||||
treeView.clearCache();
|
||||
|
||||
if (pdfDoc) {
|
||||
pageView.reset();
|
||||
await pdfDoc.loadingTask.destroy();
|
||||
pdfDoc = null;
|
||||
}
|
||||
|
||||
const loadingTask = getDocument({
|
||||
...source,
|
||||
cMapUrl:
|
||||
typeof PDFJSDev === "undefined" ? "../external/bcmaps/" : "../web/cmaps/",
|
||||
iccUrl:
|
||||
typeof PDFJSDev === "undefined" ? "../external/iccs/" : "../web/iccs/",
|
||||
standardFontDataUrl:
|
||||
typeof PDFJSDev === "undefined"
|
||||
? "../external/standard_fonts/"
|
||||
: "../web/standard_fonts/",
|
||||
wasmUrl: "../web/wasm/",
|
||||
useWorkerFetch: true,
|
||||
pdfBug: true,
|
||||
fontExtraProperties: true,
|
||||
CanvasFactory: pageView.DebugCanvasFactory,
|
||||
});
|
||||
loadingTask.onPassword = (updateCallback, reason) => {
|
||||
const dialog = document.getElementById("password-dialog");
|
||||
const title = document.getElementById("password-dialog-title");
|
||||
const input = document.getElementById("password-input");
|
||||
const cancelButton = document.getElementById("password-cancel");
|
||||
|
||||
title.textContent =
|
||||
reason === PasswordResponses.INCORRECT_PASSWORD
|
||||
? "Incorrect password. Please try again:"
|
||||
: "This PDF is password-protected. Please enter the password:";
|
||||
input.value = "";
|
||||
dialog.showModal();
|
||||
|
||||
const cleanup = () => {
|
||||
dialog.removeEventListener("close", onSubmit);
|
||||
cancelButton.removeEventListener("click", onCancel);
|
||||
};
|
||||
const onSubmit = () => {
|
||||
cleanup();
|
||||
updateCallback(input.value);
|
||||
};
|
||||
const onCancel = () => {
|
||||
cleanup();
|
||||
dialog.close();
|
||||
updateCallback(new Error("Password prompt cancelled."));
|
||||
};
|
||||
|
||||
dialog.addEventListener("close", onSubmit, { once: true });
|
||||
cancelButton.addEventListener("click", onCancel, { once: true });
|
||||
};
|
||||
pdfDoc = await loadingTask.promise;
|
||||
const plural = pdfDoc.numPages !== 1 ? "s" : "";
|
||||
pdfInfoEl.textContent = `${name} — ${pdfDoc.numPages} page${plural}`;
|
||||
statusEl.textContent = "";
|
||||
gotoInput.disabled = false;
|
||||
gotoInput.value = "";
|
||||
}
|
||||
|
||||
function showError(err) {
|
||||
statusEl.textContent = `Error: ${err.message}`;
|
||||
treeView.showError(err.message);
|
||||
}
|
||||
|
||||
document.getElementById("file-input").value = "";
|
||||
|
||||
document
|
||||
.getElementById("file-input")
|
||||
.addEventListener("change", async ({ target }) => {
|
||||
const file = target.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openDocument({ data: await file.arrayBuffer() }, file.name);
|
||||
await loadTree({ ref: null }, "Trailer");
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
});
|
||||
|
||||
(async () => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const hashParams = new URLSearchParams(location.hash.slice(1));
|
||||
const fileUrl = searchParams.get("file");
|
||||
if (!fileUrl) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openDocument({ url: fileUrl }, fileUrl.split("/").pop());
|
||||
const refStr = hashParams.get("ref");
|
||||
const pageStr = hashParams.get("page");
|
||||
if (refStr) {
|
||||
const ref = parseRefInput(refStr);
|
||||
if (ref) {
|
||||
gotoInput.value = refStr;
|
||||
await loadTree({ ref });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (pageStr) {
|
||||
const page = parseInt(pageStr, 10);
|
||||
if (Number.isInteger(page) && page >= 1 && page <= pdfDoc.numPages) {
|
||||
gotoInput.value = pageStr;
|
||||
await loadTree({ page });
|
||||
return;
|
||||
}
|
||||
}
|
||||
await loadTree({ ref: null }, "Trailer");
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
})();
|
||||
|
||||
gotoInput.addEventListener("keydown", async ({ key, target }) => {
|
||||
if (key !== "Enter" || !pdfDoc) {
|
||||
return;
|
||||
}
|
||||
if (target.value.trim() === "") {
|
||||
target.removeAttribute("aria-invalid");
|
||||
await loadTree({ ref: null }, "Trailer");
|
||||
return;
|
||||
}
|
||||
const result = parseGoToInput(target.value);
|
||||
if (!result) {
|
||||
target.setAttribute("aria-invalid", "true");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
result.page !== undefined &&
|
||||
(result.page < 1 || result.page > pdfDoc.numPages)
|
||||
) {
|
||||
target.setAttribute("aria-invalid", "true");
|
||||
return;
|
||||
}
|
||||
target.removeAttribute("aria-invalid");
|
||||
|
||||
// Allow debugging via references, as well as page numbers.
|
||||
if (result.page === undefined) {
|
||||
try {
|
||||
result.page =
|
||||
pdfDoc.cachedPageNumber(result.ref) ??
|
||||
(await pdfDoc.getPageIndex(result.ref)) + 1;
|
||||
} catch {}
|
||||
}
|
||||
// If we're in debug view and navigating to a page, stay in debug view
|
||||
// without switching to the tree at all.
|
||||
if (!debugViewEl.hidden && result.page !== undefined) {
|
||||
currentPage = result.page;
|
||||
pageView.reset();
|
||||
await pageView.show(pdfDoc, currentPage);
|
||||
} else {
|
||||
await (result.page !== undefined
|
||||
? loadTree({ page: result.page })
|
||||
: loadTree({ ref: result.ref }));
|
||||
}
|
||||
});
|
||||
|
||||
gotoInput.addEventListener("input", ({ target }) => {
|
||||
if (target.value.trim() === "") {
|
||||
target.removeAttribute("aria-invalid");
|
||||
}
|
||||
});
|
||||
|
||||
debugButton.addEventListener("click", async () => {
|
||||
treeEl.hidden = true;
|
||||
debugViewEl.hidden = false;
|
||||
// Only render if not already loaded for this page; re-entering from the
|
||||
// tree button keeps the existing debug state (op-list, canvas, breakpoints).
|
||||
await pageView.show(pdfDoc, currentPage);
|
||||
});
|
||||
|
||||
treeButton.addEventListener("click", () => {
|
||||
debugViewEl.hidden = true;
|
||||
treeEl.hidden = false;
|
||||
});
|
||||
222
web/internal/draw_ops_view.css
Normal file
222
web/internal/draw_ops_view.css
Normal file
@@ -0,0 +1,222 @@
|
||||
/* Copyright 2026 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* Hidden color-picker input reused by all color swatches. */
|
||||
.color-picker-input {
|
||||
position: fixed;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* MultilineView instance used as the op-list panel in the debug view. */
|
||||
.op-list-panel-wrapper {
|
||||
flex: 7 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
|
||||
& > .mlc-goto-bar {
|
||||
position: static;
|
||||
}
|
||||
|
||||
& > .mlc-body > .mlc-inner {
|
||||
padding: 8px 12px;
|
||||
background: var(--surface-bg);
|
||||
}
|
||||
|
||||
.mlc-line-nums-col {
|
||||
padding-block: 8px;
|
||||
}
|
||||
}
|
||||
#op-list-panel {
|
||||
flex: 7 1 0;
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface-bg);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
#op-list {
|
||||
min-width: max-content;
|
||||
}
|
||||
#op-detail-panel {
|
||||
flex: 3 1 0;
|
||||
container-type: size;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface-bg);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
.detail-name {
|
||||
color: var(--accent-color);
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.detail-empty {
|
||||
color: var(--muted-color);
|
||||
font-style: italic;
|
||||
}
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 1px 0;
|
||||
}
|
||||
.detail-idx {
|
||||
color: var(--muted-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.detail-val {
|
||||
color: var(--number-color);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.detail-body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.detail-args-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.detail-img-col {
|
||||
flex-shrink: 0;
|
||||
max-width: 45%;
|
||||
overflow: hidden;
|
||||
|
||||
.image-preview {
|
||||
height: 90cqh;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
.path-preview {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border-subtle-color);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
}
|
||||
.op-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5ch;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
&.selected {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--hover-color);
|
||||
}
|
||||
}
|
||||
.op-name {
|
||||
color: var(--accent-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
.op-arg {
|
||||
color: var(--number-color);
|
||||
direction: ltr;
|
||||
unicode-bidi: bidi-override;
|
||||
}
|
||||
.changed-value {
|
||||
font-weight: bold;
|
||||
background: var(--changed-bg);
|
||||
color: var(--changed-color);
|
||||
}
|
||||
.bp-gutter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&::before {
|
||||
content: "●";
|
||||
color: var(--changed-color);
|
||||
font-size: 0.9em;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&[data-bp="pause"]::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&[data-bp="skip"]::before {
|
||||
content: "✕";
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.op-line.op-skipped > :not(.bp-gutter) {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.op-line.paused {
|
||||
background: var(--paused-bg);
|
||||
color: var(--paused-color);
|
||||
outline: 1px solid var(--paused-outline-color);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.color-swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--muted-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
/* Opacity trick for breakpoint glyph visibility → use Canvas color to hide. */
|
||||
.bp-gutter::before {
|
||||
opacity: 1;
|
||||
color: Canvas;
|
||||
}
|
||||
.bp-gutter:hover::before {
|
||||
color: ButtonBorder;
|
||||
}
|
||||
.bp-gutter[data-bp="pause"]::before {
|
||||
color: ButtonText;
|
||||
}
|
||||
.bp-gutter[data-bp="skip"]::before {
|
||||
color: ButtonText;
|
||||
}
|
||||
.op-line.op-skipped > :not(.bp-gutter) {
|
||||
opacity: 1;
|
||||
color: GrayText;
|
||||
}
|
||||
|
||||
/* Color swatch preserves the actual PDF color value. */
|
||||
.color-swatch {
|
||||
forced-color-adjust: none;
|
||||
}
|
||||
}
|
||||
764
web/internal/draw_ops_view.js
Normal file
764
web/internal/draw_ops_view.js
Normal file
@@ -0,0 +1,764 @@
|
||||
/* Copyright 2026 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ImageKind, OPS } from "pdfjs-lib";
|
||||
import { makePathFromDrawOPS } from "pdfjs/display/display_utils.js";
|
||||
import { MultilineView } from "./multiline_view.js";
|
||||
|
||||
// Reverse map: OPS numeric id → string name, built once from the OPS object.
|
||||
const OPS_TO_NAME = Object.create(null);
|
||||
for (const [name, id] of Object.entries(OPS)) {
|
||||
OPS_TO_NAME[id] = name;
|
||||
}
|
||||
|
||||
// Ops from the getTextContent() switch in evaluator.js — shown in the draw ops
|
||||
// view when text-only filter is active, and used to determine step targets.
|
||||
const TEXT_OP_IDS = new Set([
|
||||
OPS.setFont,
|
||||
OPS.setTextRise,
|
||||
OPS.setHScale,
|
||||
OPS.setLeading,
|
||||
OPS.moveText,
|
||||
OPS.setLeadingMoveText,
|
||||
OPS.nextLine,
|
||||
OPS.setTextMatrix,
|
||||
OPS.setCharSpacing,
|
||||
OPS.setWordSpacing,
|
||||
OPS.beginText,
|
||||
OPS.endText,
|
||||
OPS.showSpacedText,
|
||||
OPS.showText,
|
||||
OPS.nextLineShowText,
|
||||
OPS.nextLineSetSpacingShowText,
|
||||
OPS.beginMarkedContent,
|
||||
OPS.beginMarkedContentProps,
|
||||
OPS.endMarkedContent,
|
||||
]);
|
||||
|
||||
// Superset of TEXT_OP_IDS — all ops that must be executed (not skipped) during
|
||||
// text-only rendering. The extra ops here are infrastructure (save/restore,
|
||||
// transforms, XObject wrappers) that affect the graphics state but are not
|
||||
// shown in the filtered op list.
|
||||
const TEXT_EXEC_OP_IDS = new Set([
|
||||
...TEXT_OP_IDS,
|
||||
OPS.restore,
|
||||
OPS.save,
|
||||
OPS.dependency,
|
||||
OPS.transform,
|
||||
OPS.paintFormXObjectBegin,
|
||||
OPS.paintFormXObjectEnd,
|
||||
OPS.beginGroup,
|
||||
OPS.endGroup,
|
||||
OPS.setGState,
|
||||
]);
|
||||
|
||||
const BreakpointType = {
|
||||
PAUSE: 0,
|
||||
SKIP: 1,
|
||||
};
|
||||
|
||||
// Single hidden color input reused for all swatch pickers.
|
||||
const colorPickerInput = document.createElement("input");
|
||||
colorPickerInput.type = "color";
|
||||
colorPickerInput.className = "color-picker-input";
|
||||
|
||||
// AbortController for the currently open color-picker session (if any).
|
||||
let _colorPickerAc = null;
|
||||
|
||||
function ensureColorPickerInput() {
|
||||
if (!colorPickerInput.isConnected) {
|
||||
document.body.append(colorPickerInput);
|
||||
}
|
||||
}
|
||||
|
||||
function openColorPicker(hex, onPick) {
|
||||
// Cancel any previous session that was dismissed without a change event.
|
||||
_colorPickerAc?.abort();
|
||||
ensureColorPickerInput();
|
||||
colorPickerInput.value = hex;
|
||||
|
||||
const ac = new AbortController();
|
||||
_colorPickerAc = ac;
|
||||
colorPickerInput.addEventListener(
|
||||
"input",
|
||||
() => {
|
||||
onPick(colorPickerInput.value);
|
||||
},
|
||||
{ signal: ac.signal }
|
||||
);
|
||||
colorPickerInput.addEventListener(
|
||||
"change",
|
||||
() => {
|
||||
ac.abort();
|
||||
},
|
||||
{ once: true, signal: ac.signal }
|
||||
);
|
||||
colorPickerInput.click();
|
||||
}
|
||||
|
||||
// Creates a color swatch. If `onPick` is provided the swatch is clickable and
|
||||
// opens the browser color picker; onPick(newHex) is called on each change.
|
||||
function makeColorSwatch(hex, onPick) {
|
||||
const swatch = document.createElement("span");
|
||||
swatch.className = "color-swatch";
|
||||
swatch.style.background = hex;
|
||||
if (onPick) {
|
||||
swatch.role = "button";
|
||||
swatch.tabIndex = 0;
|
||||
swatch.ariaLabel = "Change color";
|
||||
swatch.title = "Click to change color";
|
||||
const activate = e => {
|
||||
e.stopPropagation();
|
||||
openColorPicker(hex, newHex => {
|
||||
hex = newHex;
|
||||
swatch.style.background = newHex;
|
||||
onPick(newHex);
|
||||
});
|
||||
};
|
||||
swatch.addEventListener("click", activate);
|
||||
swatch.addEventListener("keydown", e => {
|
||||
if (e.key !== "Enter" && e.key !== " ") {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
activate(e);
|
||||
});
|
||||
}
|
||||
return swatch;
|
||||
}
|
||||
|
||||
// Formats a glyph items array as: "text" kerning "more text" …
|
||||
function formatGlyphItems(items) {
|
||||
const parts = [];
|
||||
let str = "";
|
||||
for (const item of items) {
|
||||
if (typeof item === "number") {
|
||||
if (str) {
|
||||
parts.push(JSON.stringify(str));
|
||||
str = "";
|
||||
}
|
||||
parts.push(String(Math.round(item * 100) / 100));
|
||||
} else if (item?.unicode) {
|
||||
str += item.unicode;
|
||||
}
|
||||
}
|
||||
if (str) {
|
||||
parts.push(JSON.stringify(str));
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an operator argument for display.
|
||||
* @param {*} arg The argument value.
|
||||
* @param {boolean} full true → expand fully (detail panel);
|
||||
* false → truncate for compact list display.
|
||||
*/
|
||||
function formatArg(arg, full) {
|
||||
if (arg === null || arg === undefined) {
|
||||
return full ? "null" : "";
|
||||
}
|
||||
if (typeof arg === "number") {
|
||||
return Number.isInteger(arg)
|
||||
? String(arg)
|
||||
: String(Math.round(arg * 10000) / 10000);
|
||||
}
|
||||
if (typeof arg === "string") {
|
||||
return JSON.stringify(arg);
|
||||
}
|
||||
if (typeof arg === "boolean") {
|
||||
return String(arg);
|
||||
}
|
||||
if (ArrayBuffer.isView(arg)) {
|
||||
if (!full && arg.length > 8) {
|
||||
return `<${arg.length} values>`;
|
||||
}
|
||||
const fmt = n => (Number.isInteger(n) ? n : Math.round(n * 1000) / 1000);
|
||||
return `[${Array.from(arg).map(fmt).join(" ")}]`;
|
||||
}
|
||||
if (Array.isArray(arg)) {
|
||||
if (arg.length === 0) {
|
||||
return "[]";
|
||||
}
|
||||
if (!full && arg.length > 4) {
|
||||
return `[…${arg.length}]`;
|
||||
}
|
||||
return `[${arg.map(a => formatArg(a, full)).join(", ")}]`;
|
||||
}
|
||||
if (typeof arg === "object") {
|
||||
if (!full) {
|
||||
return "{…}";
|
||||
}
|
||||
return `{${Object.entries(arg)
|
||||
.map(([k, v]) => `${k}: ${formatArg(v, true)}`)
|
||||
.join(", ")}}`;
|
||||
}
|
||||
return String(arg);
|
||||
}
|
||||
|
||||
class DrawOpDetailView {
|
||||
#el;
|
||||
|
||||
#prefersDark;
|
||||
|
||||
constructor(detailPanelEl, { prefersDark }) {
|
||||
this.#el = detailPanelEl;
|
||||
this.#prefersDark = prefersDark;
|
||||
}
|
||||
|
||||
show(
|
||||
name,
|
||||
args,
|
||||
opIdx,
|
||||
{ originalColors, renderedPage, selectedLine = null }
|
||||
) {
|
||||
const detailEl = this.#el;
|
||||
detailEl.replaceChildren();
|
||||
|
||||
// Always build args into a .detail-args-col so it can be placed in a
|
||||
// .detail-body alongside a path preview or image preview on the right.
|
||||
const argsContainer = document.createElement("div");
|
||||
argsContainer.className = "detail-args-col";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "detail-name";
|
||||
header.textContent = name;
|
||||
argsContainer.append(header);
|
||||
|
||||
if (!args || args.length === 0) {
|
||||
const none = document.createElement("div");
|
||||
none.className = "detail-empty";
|
||||
none.textContent = "(no arguments)";
|
||||
argsContainer.append(none);
|
||||
detailEl.append(argsContainer);
|
||||
return;
|
||||
}
|
||||
|
||||
const imagePreviews = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "detail-row";
|
||||
const idx = document.createElement("span");
|
||||
idx.className = "detail-idx";
|
||||
idx.textContent = `[${i}]`;
|
||||
const val = document.createElement("span");
|
||||
val.className = "detail-val";
|
||||
if (name === "showText" && i === 0 && Array.isArray(args[0])) {
|
||||
val.textContent = formatGlyphItems(args[0]);
|
||||
} else if (
|
||||
name === "constructPath" &&
|
||||
i === 0 &&
|
||||
typeof args[0] === "number"
|
||||
) {
|
||||
val.textContent = OPS_TO_NAME[args[0]] ?? String(args[0]);
|
||||
} else {
|
||||
val.textContent = formatArg(args[i], true);
|
||||
}
|
||||
row.append(idx);
|
||||
if (typeof args[i] === "string" && /^#[0-9a-f]{6}$/i.test(args[i])) {
|
||||
const argIdx = i;
|
||||
const originalHex = originalColors.get(opIdx);
|
||||
if (originalHex && args[i] !== originalHex) {
|
||||
val.classList.add("changed-value");
|
||||
val.title = `Original: ${originalHex}`;
|
||||
}
|
||||
row.append(
|
||||
makeColorSwatch(args[i], newHex => {
|
||||
args[argIdx] = newHex;
|
||||
val.textContent = JSON.stringify(newHex);
|
||||
const changed = originalHex && newHex !== originalHex;
|
||||
val.classList.toggle("changed-value", !!changed);
|
||||
val.title = changed ? `Original: ${originalHex}` : "";
|
||||
// Also update the swatch and arg span in the selected op list line.
|
||||
const listSwatch = selectedLine?.querySelector(".color-swatch");
|
||||
if (listSwatch) {
|
||||
listSwatch.style.background = newHex;
|
||||
}
|
||||
const listArgSpan = selectedLine?.querySelector(".op-arg");
|
||||
if (listArgSpan) {
|
||||
listArgSpan.textContent = JSON.stringify(newHex);
|
||||
listArgSpan.classList.toggle("changed-value", !!changed);
|
||||
listArgSpan.title = changed ? `Original: ${originalHex}` : "";
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
row.append(val);
|
||||
argsContainer.append(row);
|
||||
if (typeof args[i] === "string" && args[i].startsWith("img_")) {
|
||||
const preview = this.#makeImageArgPreview(args[i], renderedPage);
|
||||
if (preview) {
|
||||
imagePreviews.push(preview);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assemble the final layout: constructPath gets a path preview on the
|
||||
// right; image ops get an image column on the right; others just use
|
||||
// argsContainer.
|
||||
if (name === "constructPath") {
|
||||
// args[1] is [Float32Array|null], args[2] is [minX,minY,maxX,maxY]|null
|
||||
const data = Array.isArray(args?.[1]) ? args[1][0] : null;
|
||||
const body = document.createElement("div");
|
||||
body.className = "detail-body";
|
||||
body.append(
|
||||
argsContainer,
|
||||
this.#renderPathPreview(data, args?.[2] ?? null)
|
||||
);
|
||||
detailEl.append(body);
|
||||
} else if (imagePreviews.length > 0) {
|
||||
const imgCol = document.createElement("div");
|
||||
imgCol.className = "detail-img-col";
|
||||
imgCol.append(...imagePreviews);
|
||||
const body = document.createElement("div");
|
||||
body.className = "detail-body";
|
||||
body.append(argsContainer, imgCol);
|
||||
detailEl.append(body);
|
||||
} else {
|
||||
detailEl.append(argsContainer);
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#el.replaceChildren();
|
||||
}
|
||||
|
||||
#renderPathPreview(data, minMax) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "path-preview";
|
||||
|
||||
const [minX, minY, maxX, maxY] = minMax ?? [];
|
||||
const pathW = maxX - minX || 1;
|
||||
const pathH = maxY - minY || 1;
|
||||
if (!data || !minMax || !(pathW > 0) || !(pathH > 0)) {
|
||||
canvas.width = canvas.height = 1;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
const PADDING = 10; // px
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const drawW = Math.min(200, 200 * (pathW / pathH));
|
||||
const drawH = Math.min(200, 200 * (pathH / pathW));
|
||||
const scale = Math.min(drawW / pathW, drawH / pathH);
|
||||
|
||||
canvas.width = Math.round((drawW + PADDING * 2) * dpr);
|
||||
canvas.height = Math.round((drawH + PADDING * 2) * dpr);
|
||||
canvas.style.width = `${drawW + PADDING * 2}px`;
|
||||
canvas.style.height = `${drawH + PADDING * 2}px`;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.scale(dpr, dpr);
|
||||
// PDF user space has Y pointing up; canvas has Y pointing down — flip Y.
|
||||
ctx.translate(PADDING, PADDING + drawH);
|
||||
ctx.scale(scale, -scale);
|
||||
ctx.translate(-minX, -minY);
|
||||
|
||||
ctx.lineWidth = 1 / scale;
|
||||
ctx.strokeStyle = this.#prefersDark.matches ? "#9cdcfe" : "#0070c1";
|
||||
ctx.stroke(data instanceof Path2D ? data : makePathFromDrawOPS(data));
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// Render an img_ argument value into a canvas preview using the decoded image
|
||||
// stored in renderedPage.objs (or commonObjs for global images starting with
|
||||
// g_). Handles ImageBitmap and raw pixel data with ImageKind values
|
||||
// GRAYSCALE_1BPP, RGB_24BPP, and RGBA_32BPP.
|
||||
#makeImageArgPreview(name, renderedPage) {
|
||||
const objStore = name.startsWith("g_")
|
||||
? renderedPage?.commonObjs
|
||||
: renderedPage?.objs;
|
||||
if (!objStore?.has(name)) {
|
||||
return null;
|
||||
}
|
||||
const imgObj = objStore.get(name);
|
||||
if (!imgObj) {
|
||||
return null;
|
||||
}
|
||||
const { width, height } = imgObj;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "image-preview";
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.style.aspectRatio = `${width} / ${height}`;
|
||||
canvas.ariaLabel = `${name} ${width}×${height}`;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
// Fast path: if the browser already decoded it as an ImageBitmap, draw it.
|
||||
if (imgObj.bitmap instanceof ImageBitmap) {
|
||||
ctx.drawImage(imgObj.bitmap, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// Slow path: convert raw pixel data to RGBA for putImageData.
|
||||
const { data, kind } = imgObj;
|
||||
let rgba;
|
||||
if (kind === ImageKind.RGBA_32BPP) {
|
||||
rgba = new Uint8ClampedArray(
|
||||
data.buffer,
|
||||
data.byteOffset,
|
||||
data.byteLength
|
||||
);
|
||||
} else if (kind === ImageKind.RGB_24BPP) {
|
||||
const pixels = width * height;
|
||||
rgba = new Uint8ClampedArray(pixels * 4);
|
||||
for (let i = 0, j = 0; i < pixels; i++, j += 3) {
|
||||
rgba[i * 4] = data[j];
|
||||
rgba[i * 4 + 1] = data[j + 1];
|
||||
rgba[i * 4 + 2] = data[j + 2];
|
||||
rgba[i * 4 + 3] = 255;
|
||||
}
|
||||
} else if (kind === ImageKind.GRAYSCALE_1BPP) {
|
||||
const rowBytes = (width + 7) >> 3;
|
||||
rgba = new Uint8ClampedArray(width * height * 4);
|
||||
for (let row = 0; row < height; row++) {
|
||||
const srcRow = row * rowBytes;
|
||||
const dstRow = row * width * 4;
|
||||
for (let col = 0; col < width; col++) {
|
||||
const bit = (data[srcRow + (col >> 3)] >> (7 - (col & 7))) & 1;
|
||||
const v = bit ? 255 : 0;
|
||||
rgba[dstRow + col * 4] = v;
|
||||
rgba[dstRow + col * 4 + 1] = v;
|
||||
rgba[dstRow + col * 4 + 2] = v;
|
||||
rgba[dstRow + col * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
ctx.putImageData(new ImageData(rgba, width, height), 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
}
|
||||
|
||||
class DrawOpsView {
|
||||
#listPanelEl;
|
||||
|
||||
#detailView;
|
||||
|
||||
#multilineView = null;
|
||||
|
||||
// All op lines indexed by original op index.
|
||||
#opLines = [];
|
||||
|
||||
// Plain-text representations for search, parallel to #opLines.
|
||||
#opTexts = [];
|
||||
|
||||
// Currently visible lines (all lines, or text-only subset when filtering).
|
||||
#visibleLines = [];
|
||||
|
||||
#textFilter = false;
|
||||
|
||||
#selectedLine = null;
|
||||
|
||||
// Map<opIndex, BreakpointType>
|
||||
#breakpoints = new Map();
|
||||
|
||||
#originalColors = new Map();
|
||||
|
||||
#renderedPage = null;
|
||||
|
||||
#pausedAtIdx = null;
|
||||
|
||||
#onHighlight;
|
||||
|
||||
#onClearHighlight;
|
||||
|
||||
constructor(
|
||||
opListPanelEl,
|
||||
detailPanelEl,
|
||||
{ onHighlight, onClearHighlight, prefersDark }
|
||||
) {
|
||||
this.#listPanelEl = opListPanelEl;
|
||||
this.#detailView = new DrawOpDetailView(detailPanelEl, { prefersDark });
|
||||
this.#onHighlight = onHighlight;
|
||||
this.#onClearHighlight = onClearHighlight;
|
||||
}
|
||||
|
||||
get breakpoints() {
|
||||
return this.#breakpoints;
|
||||
}
|
||||
|
||||
load(opList, renderedPage) {
|
||||
this.#renderedPage = renderedPage;
|
||||
this.#opLines = [];
|
||||
this.#opTexts = [];
|
||||
|
||||
for (let i = 0; i < opList.fnArray.length; i++) {
|
||||
const name = OPS_TO_NAME[opList.fnArray[i]] ?? `op${opList.fnArray[i]}`;
|
||||
const args = opList.argsArray[i] ?? [];
|
||||
const { line, text } = this.#buildLine(i, name, args);
|
||||
this.#opLines.push(line);
|
||||
this.#opTexts.push(text);
|
||||
}
|
||||
|
||||
this.#rebuildMultilineView();
|
||||
}
|
||||
|
||||
// Enable or disable the text-ops-only filter. Can be called at any time;
|
||||
// rebuilds the list view in place when ops are already loaded.
|
||||
setTextFilter(enabled) {
|
||||
if (this.#textFilter === enabled) {
|
||||
return;
|
||||
}
|
||||
this.#textFilter = enabled;
|
||||
if (this.#opLines.length > 0) {
|
||||
this.#rebuildMultilineView();
|
||||
}
|
||||
}
|
||||
|
||||
#rebuildMultilineView() {
|
||||
// Compute the visible (possibly filtered) subset.
|
||||
this.#visibleLines = this.#textFilter
|
||||
? this.#opLines.filter(line => TEXT_OP_IDS.has(OPS[line.dataset.opName]))
|
||||
: this.#opLines;
|
||||
|
||||
// Tear down the existing MultilineView (if any), keeping the placeholder.
|
||||
const anchor = this.#multilineView?.element ?? this.#listPanelEl;
|
||||
if (this.#multilineView) {
|
||||
this.#multilineView.destroy();
|
||||
this.#multilineView = null;
|
||||
}
|
||||
|
||||
const multilineView = new MultilineView({
|
||||
total: this.#visibleLines.length,
|
||||
getText: i => this.#opTexts[+this.#visibleLines[i].dataset.opIdx],
|
||||
makeLineEl: (i, isHighlighted) => {
|
||||
this.#visibleLines[i].classList.toggle("mlc-match", isHighlighted);
|
||||
return this.#visibleLines[i];
|
||||
},
|
||||
});
|
||||
multilineView.element.classList.add("op-list-panel-wrapper");
|
||||
multilineView.inner.id = "op-list";
|
||||
multilineView.inner.role = "listbox";
|
||||
multilineView.inner.ariaLabel = "Operator list";
|
||||
|
||||
multilineView.inner.addEventListener("keydown", e => {
|
||||
const { key } = e;
|
||||
const lines = this.#visibleLines;
|
||||
if (!lines.length) {
|
||||
return;
|
||||
}
|
||||
const focused = document.activeElement;
|
||||
const currentIdx = lines.indexOf(focused);
|
||||
let targetIdx = -1;
|
||||
if (key === "ArrowDown") {
|
||||
targetIdx = currentIdx < lines.length - 1 ? currentIdx + 1 : currentIdx;
|
||||
} else if (key === "ArrowUp") {
|
||||
targetIdx = currentIdx > 0 ? currentIdx - 1 : 0;
|
||||
} else if (key === "Home") {
|
||||
targetIdx = 0;
|
||||
} else if (key === "End") {
|
||||
targetIdx = lines.length - 1;
|
||||
} else if (key === "Enter" || key === " ") {
|
||||
if (currentIdx >= 0) {
|
||||
lines[currentIdx].click();
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (targetIdx >= 0) {
|
||||
lines[targetIdx].tabIndex = 0;
|
||||
if (currentIdx >= 0 && currentIdx !== targetIdx) {
|
||||
lines[currentIdx].tabIndex = -1;
|
||||
}
|
||||
multilineView.scrollToLine(targetIdx);
|
||||
lines[targetIdx].focus();
|
||||
}
|
||||
});
|
||||
|
||||
anchor.replaceWith(multilineView.element);
|
||||
this.#multilineView = multilineView;
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (this.#multilineView) {
|
||||
this.#multilineView.destroy();
|
||||
this.#multilineView.element.replaceWith(this.#listPanelEl);
|
||||
this.#multilineView = null;
|
||||
}
|
||||
document.getElementById("op-list").replaceChildren();
|
||||
this.#detailView.clear();
|
||||
this.#opLines = [];
|
||||
this.#opTexts = [];
|
||||
this.#visibleLines = [];
|
||||
this.#selectedLine = null;
|
||||
this.#originalColors.clear();
|
||||
this.#breakpoints.clear();
|
||||
this.#pausedAtIdx = this.#renderedPage = null;
|
||||
}
|
||||
|
||||
markPaused(i) {
|
||||
if (this.#pausedAtIdx !== null) {
|
||||
this.#opLines[this.#pausedAtIdx]?.classList.remove("paused");
|
||||
}
|
||||
this.#pausedAtIdx = i;
|
||||
this.#opLines[i]?.classList.add("paused");
|
||||
// Scroll to the position of this op within the currently visible list.
|
||||
const visibleIdx = this.#visibleLines.indexOf(this.#opLines[i]);
|
||||
if (visibleIdx >= 0) {
|
||||
this.#multilineView?.scrollToLine(visibleIdx);
|
||||
}
|
||||
}
|
||||
|
||||
clearPaused() {
|
||||
if (this.#pausedAtIdx !== null) {
|
||||
this.#opLines[this.#pausedAtIdx]?.classList.remove("paused");
|
||||
this.#pausedAtIdx = null;
|
||||
}
|
||||
}
|
||||
|
||||
// The evaluator normalizes all color ops to setFillRGBColor /
|
||||
// setStrokeRGBColor with args = ["#rrggbb"]. Return that hex string, or null.
|
||||
#getOpColor(name, args) {
|
||||
if (
|
||||
(name === "setFillRGBColor" || name === "setStrokeRGBColor") &&
|
||||
typeof args?.[0] === "string" &&
|
||||
/^#[0-9a-f]{6}$/i.test(args[0])
|
||||
) {
|
||||
return args[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
#buildLine(i, name, args) {
|
||||
const line = document.createElement("div");
|
||||
line.className = "op-line";
|
||||
line.role = "option";
|
||||
line.ariaSelected = "false";
|
||||
line.tabIndex = i === 0 ? 0 : -1;
|
||||
line.dataset.opName = name;
|
||||
line.dataset.opIdx = i;
|
||||
|
||||
// Breakpoint gutter — click cycles: none → pause (●) → skip (✕) → none.
|
||||
const gutter = document.createElement("span");
|
||||
gutter.className = "bp-gutter";
|
||||
gutter.role = "checkbox";
|
||||
gutter.tabIndex = 0;
|
||||
gutter.ariaLabel = "Breakpoint";
|
||||
const initBpType = this.#breakpoints.get(i);
|
||||
if (initBpType === BreakpointType.PAUSE) {
|
||||
gutter.dataset.bp = "pause";
|
||||
gutter.ariaChecked = "true";
|
||||
} else if (initBpType === BreakpointType.SKIP) {
|
||||
gutter.dataset.bp = "skip";
|
||||
gutter.ariaChecked = "mixed";
|
||||
line.classList.add("op-skipped");
|
||||
} else {
|
||||
gutter.ariaChecked = "false";
|
||||
}
|
||||
gutter.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
const current = this.#breakpoints.get(i);
|
||||
if (current === undefined) {
|
||||
this.#breakpoints.set(i, BreakpointType.PAUSE);
|
||||
gutter.dataset.bp = "pause";
|
||||
gutter.ariaChecked = "true";
|
||||
} else if (current === BreakpointType.PAUSE) {
|
||||
this.#breakpoints.set(i, BreakpointType.SKIP);
|
||||
gutter.dataset.bp = "skip";
|
||||
gutter.ariaChecked = "mixed";
|
||||
line.classList.add("op-skipped");
|
||||
} else {
|
||||
this.#breakpoints.delete(i);
|
||||
delete gutter.dataset.bp;
|
||||
gutter.ariaChecked = "false";
|
||||
line.classList.remove("op-skipped");
|
||||
}
|
||||
});
|
||||
gutter.addEventListener("keydown", e => {
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
gutter.click();
|
||||
}
|
||||
});
|
||||
line.append(gutter);
|
||||
|
||||
const nameEl = document.createElement("span");
|
||||
nameEl.className = "op-name";
|
||||
nameEl.textContent = name;
|
||||
line.append(nameEl);
|
||||
const rgb = this.#getOpColor(name, args);
|
||||
let colorArgSpan = null;
|
||||
if (rgb) {
|
||||
this.#originalColors.set(i, rgb);
|
||||
line.append(
|
||||
makeColorSwatch(rgb, newHex => {
|
||||
args[0] = newHex;
|
||||
if (colorArgSpan) {
|
||||
const changed = newHex !== rgb;
|
||||
colorArgSpan.textContent = JSON.stringify(newHex);
|
||||
colorArgSpan.classList.toggle("changed-value", changed);
|
||||
colorArgSpan.title = changed ? `Original: ${rgb}` : "";
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Build arg spans and plain-text representation for search in one pass.
|
||||
let text = name;
|
||||
if (name === "showText" && Array.isArray(args[0])) {
|
||||
const formatted = formatGlyphItems(args[0]);
|
||||
const argEl = document.createElement("span");
|
||||
argEl.className = "op-arg";
|
||||
argEl.textContent = formatted;
|
||||
line.append(argEl);
|
||||
text += " " + formatted;
|
||||
} else {
|
||||
for (let j = 0; j < args.length; j++) {
|
||||
const s =
|
||||
name === "constructPath" && j === 0 && typeof args[0] === "number"
|
||||
? (OPS_TO_NAME[args[0]] ?? String(args[0]))
|
||||
: formatArg(args[j], false);
|
||||
if (s) {
|
||||
const argEl = document.createElement("span");
|
||||
argEl.className = "op-arg";
|
||||
argEl.textContent = s;
|
||||
line.append(argEl);
|
||||
if (rgb && j === 0) {
|
||||
colorArgSpan = argEl;
|
||||
}
|
||||
text += " " + s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
line.addEventListener("pointerenter", () => this.#onHighlight(i));
|
||||
line.addEventListener("pointerleave", () => this.#onClearHighlight());
|
||||
line.addEventListener("click", () => {
|
||||
if (this.#selectedLine) {
|
||||
this.#selectedLine.classList.remove("selected");
|
||||
this.#selectedLine.ariaSelected = "false";
|
||||
this.#selectedLine.tabIndex = -1;
|
||||
}
|
||||
this.#selectedLine = line;
|
||||
line.classList.add("selected");
|
||||
line.ariaSelected = "true";
|
||||
line.tabIndex = 0;
|
||||
this.#detailView.show(name, args, i, {
|
||||
originalColors: this.#originalColors,
|
||||
renderedPage: this.#renderedPage,
|
||||
selectedLine: line,
|
||||
});
|
||||
});
|
||||
|
||||
return { line, text };
|
||||
}
|
||||
}
|
||||
|
||||
export { BreakpointType, DrawOpsView, TEXT_EXEC_OP_IDS, TEXT_OP_IDS };
|
||||
164
web/internal/font_view.css
Normal file
164
web/internal/font_view.css
Normal file
@@ -0,0 +1,164 @@
|
||||
/* Copyright 2026 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
#font-panel {
|
||||
flex: 1 1 0;
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--surface-bg);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.font-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.font-item {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border-subtle-color);
|
||||
cursor: pointer;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--font-highlight-color, #0070c1) 12%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.font-download-button {
|
||||
box-sizing: border-box;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--input-border-color);
|
||||
background: var(--button-bg);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: auto;
|
||||
background: currentColor;
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z'/%3E%3Cpolyline points='17 21 17 13 7 13 7 21'/%3E%3Cpolyline points='7 3 7 8 15 8'/%3E%3C/svg%3E");
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--button-hover-bg);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.font-name {
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.font-loaded-name {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.font-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.font-tag {
|
||||
font-size: 0.75em;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--hover-bg);
|
||||
color: var(--muted-color);
|
||||
border: 1px solid var(--border-subtle-color);
|
||||
}
|
||||
|
||||
.font-empty {
|
||||
padding: 8px 10px;
|
||||
color: var(--muted-color);
|
||||
font-style: italic;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#font-view-button {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.font-toolbar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.font-color-button {
|
||||
box-sizing: border-box;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--input-border-color);
|
||||
background: var(--button-bg);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--button-hover-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.font-color-swatch {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--font-highlight-color, #0070c1);
|
||||
}
|
||||
248
web/internal/font_view.js
Normal file
248
web/internal/font_view.js
Normal file
@@ -0,0 +1,248 @@
|
||||
/* Copyright 2026 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const FONT_HIGHLIGHT_COLOR_KEY = "debugger.fontHighlightColor";
|
||||
const DEFAULT_FONT_HIGHLIGHT_COLOR = "#0070c1";
|
||||
|
||||
// Maps MIME types to file extensions used when downloading fonts.
|
||||
const MIMETYPE_TO_EXTENSION = new Map([
|
||||
["font/opentype", "otf"],
|
||||
["font/otf", "otf"],
|
||||
["font/woff", "woff"],
|
||||
["font/woff2", "woff2"],
|
||||
["application/x-font-ttf", "ttf"],
|
||||
["font/truetype", "ttf"],
|
||||
["font/ttf", "ttf"],
|
||||
["application/x-font-type1", "pfb"],
|
||||
]);
|
||||
|
||||
// Maps MIME types reported by FontFaceObject to human-readable font format
|
||||
// names.
|
||||
const MIMETYPE_TO_FORMAT = new Map([
|
||||
["font/opentype", "OpenType"],
|
||||
["font/otf", "OpenType"],
|
||||
["font/woff", "WOFF"],
|
||||
["font/woff2", "WOFF2"],
|
||||
["application/x-font-ttf", "TrueType"],
|
||||
["font/truetype", "TrueType"],
|
||||
["font/ttf", "TrueType"],
|
||||
["application/x-font-type1", "Type1"],
|
||||
]);
|
||||
|
||||
class FontView {
|
||||
// Persistent map of all fonts seen since the document was opened,
|
||||
// keyed by loadedName (= the PDF resource name / CSS font-family).
|
||||
// Never cleared on page navigation so fonts cached in commonObjs
|
||||
// (which only trigger fontAdded once per document) are always available.
|
||||
#fontMap = new Map();
|
||||
|
||||
#container;
|
||||
|
||||
#list = (() => {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "font-list";
|
||||
return ul;
|
||||
})();
|
||||
|
||||
#onSelect;
|
||||
|
||||
#selectedName = null;
|
||||
|
||||
#downloadBtn;
|
||||
|
||||
constructor(containerEl, { onSelect } = {}) {
|
||||
this.#container = containerEl;
|
||||
this.#onSelect = onSelect;
|
||||
|
||||
this.#container.append(this.#buildToolbar(), this.#list);
|
||||
}
|
||||
|
||||
#buildToolbar() {
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "font-toolbar";
|
||||
|
||||
// Color picker button + hidden input.
|
||||
const colorInput = document.createElement("input");
|
||||
colorInput.type = "color";
|
||||
colorInput.hidden = true;
|
||||
|
||||
const colorSwatch = document.createElement("span");
|
||||
colorSwatch.className = "font-color-swatch";
|
||||
|
||||
const colorBtn = document.createElement("button");
|
||||
colorBtn.className = "font-color-button";
|
||||
colorBtn.title = "Highlight color";
|
||||
colorBtn.append(colorSwatch);
|
||||
|
||||
const applyColor = color => {
|
||||
colorInput.value = color;
|
||||
document.documentElement.style.setProperty(
|
||||
"--font-highlight-color",
|
||||
color
|
||||
);
|
||||
};
|
||||
applyColor(
|
||||
localStorage.getItem(FONT_HIGHLIGHT_COLOR_KEY) ??
|
||||
DEFAULT_FONT_HIGHLIGHT_COLOR
|
||||
);
|
||||
|
||||
colorBtn.addEventListener("click", () => colorInput.click());
|
||||
colorInput.addEventListener("input", () => {
|
||||
applyColor(colorInput.value);
|
||||
localStorage.setItem(FONT_HIGHLIGHT_COLOR_KEY, colorInput.value);
|
||||
});
|
||||
|
||||
// Download button — enabled only when a font with data is selected.
|
||||
const downloadBtn = (this.#downloadBtn = document.createElement("button"));
|
||||
downloadBtn.className = "font-download-button";
|
||||
downloadBtn.title = "Download selected font";
|
||||
downloadBtn.disabled = true;
|
||||
downloadBtn.addEventListener("click", () => {
|
||||
const font = this.#fontMap.get(this.#selectedName);
|
||||
if (!font?.data) {
|
||||
return;
|
||||
}
|
||||
const ext = MIMETYPE_TO_EXTENSION.get(font.mimetype) ?? "font";
|
||||
const name = (font.name || font.loadedName).replaceAll(/[^\w-]/g, "_");
|
||||
const blob = new Blob([font.data], { type: font.mimetype });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${name}.${ext}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
toolbar.append(colorBtn, colorInput, downloadBtn);
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
get element() {
|
||||
return this.#container;
|
||||
}
|
||||
|
||||
// Called by FontInspector.fontAdded whenever a font face is bound.
|
||||
fontAdded(font) {
|
||||
this.#fontMap.set(font.loadedName, font);
|
||||
}
|
||||
|
||||
// Show the subset of known fonts that appear in the given op list.
|
||||
// Uses setFont ops to determine which fonts are actually used on the page.
|
||||
showForOpList({ fnArray, argsArray }, OPS) {
|
||||
const usedNames = new Set();
|
||||
for (let i = 0, len = fnArray.length; i < len; i++) {
|
||||
if (fnArray[i] === OPS.setFont) {
|
||||
usedNames.add(argsArray[i][0]);
|
||||
}
|
||||
}
|
||||
|
||||
const fonts = [];
|
||||
for (const name of usedNames) {
|
||||
const font = this.#fontMap.get(name);
|
||||
if (font) {
|
||||
fonts.push(font);
|
||||
}
|
||||
}
|
||||
this.#render(fonts);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#selectedName = null;
|
||||
this.#downloadBtn.disabled = true;
|
||||
this.#list.replaceChildren();
|
||||
}
|
||||
|
||||
#render(fonts) {
|
||||
if (fonts.length === 0) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "font-empty";
|
||||
li.textContent = "No fonts on this page.";
|
||||
this.#list.replaceChildren(li);
|
||||
return;
|
||||
}
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const font of fonts) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "font-item";
|
||||
li.dataset.loadedName = font.loadedName;
|
||||
if (font.loadedName === this.#selectedName) {
|
||||
li.classList.add("selected");
|
||||
}
|
||||
li.addEventListener("click", () => {
|
||||
const next =
|
||||
font.loadedName === this.#selectedName ? null : font.loadedName;
|
||||
this.#selectedName = next;
|
||||
for (const item of this.#list.querySelectorAll(".font-item")) {
|
||||
item.classList.toggle("selected", item.dataset.loadedName === next);
|
||||
}
|
||||
const selectedFont = next ? this.#fontMap.get(next) : null;
|
||||
this.#downloadBtn.disabled = !selectedFont?.data;
|
||||
this.#onSelect?.(next);
|
||||
});
|
||||
|
||||
const nameEl = document.createElement("div");
|
||||
nameEl.className = "font-name";
|
||||
nameEl.textContent = font.name || font.loadedName;
|
||||
li.append(nameEl);
|
||||
|
||||
const tags = [];
|
||||
const fmt = MIMETYPE_TO_FORMAT.get(font.mimetype);
|
||||
if (fmt) {
|
||||
tags.push(fmt);
|
||||
}
|
||||
if (font.isType3Font) {
|
||||
tags.push("Type3");
|
||||
}
|
||||
if (font.bold) {
|
||||
tags.push("Bold");
|
||||
}
|
||||
if (font.italic) {
|
||||
tags.push("Italic");
|
||||
}
|
||||
if (font.vertical) {
|
||||
tags.push("Vertical");
|
||||
}
|
||||
if (font.disableFontFace) {
|
||||
tags.push("System");
|
||||
}
|
||||
if (font.missingFile) {
|
||||
tags.push("Missing");
|
||||
}
|
||||
|
||||
if (tags.length) {
|
||||
const tagsEl = document.createElement("div");
|
||||
tagsEl.className = "font-tags";
|
||||
for (const tag of tags) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "font-tag";
|
||||
span.textContent = tag;
|
||||
tagsEl.append(span);
|
||||
}
|
||||
li.append(tagsEl);
|
||||
}
|
||||
|
||||
const loadedEl = document.createElement("div");
|
||||
loadedEl.className = "font-loaded-name";
|
||||
loadedEl.textContent = font.loadedName;
|
||||
li.append(loadedEl);
|
||||
|
||||
frag.append(li);
|
||||
}
|
||||
this.#list.replaceChildren(frag);
|
||||
}
|
||||
}
|
||||
|
||||
export { FontView };
|
||||
192
web/internal/multiline_view.css
Normal file
192
web/internal/multiline_view.css
Normal file
@@ -0,0 +1,192 @@
|
||||
/* Copyright 2026 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
.mlc-scroll {
|
||||
color-scheme: light dark;
|
||||
|
||||
--surface-bg: light-dark(#f3f3f3, #252526);
|
||||
--input-bg: light-dark(#fff, #3c3c3c);
|
||||
--button-bg: light-dark(#f3f3f3, #3c3c3c);
|
||||
--button-hover-bg: light-dark(#e0e0e0, #4a4a4a);
|
||||
--text-color: light-dark(#1e1e1e, #d4d4d4);
|
||||
--muted-color: light-dark(#6e6e6e, #888);
|
||||
--accent-color: light-dark(#0070c1, #9cdcfe);
|
||||
--border-subtle-color: light-dark(#d0d0d0, #444);
|
||||
--input-border-color: light-dark(#c8c8c8, #555);
|
||||
--match-bg: light-dark(rgb(255 200 0 / 0.35), rgb(255 200 0 / 0.25));
|
||||
--match-outline-color: light-dark(rgb(200 140 0 / 0.8), rgb(255 200 0 / 0.6));
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-subtle-color);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
|
||||
.mlc-load-sentinel {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Row wrapper that sits between the toolbar and the scrollable content.
|
||||
Hosts the frozen line-number column and the actual scroll container. */
|
||||
.mlc-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-height: 0;
|
||||
line-height: 1.8em;
|
||||
}
|
||||
|
||||
/* The line-number column lives *outside* the scroll container so it is
|
||||
never affected by horizontal or vertical scroll. Its scrollTop is kept
|
||||
in sync with the adjacent scroll container via a JS scroll listener. */
|
||||
.mlc-line-nums-col {
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--surface-bg);
|
||||
border-inline-end: 1px solid var(--border-subtle-color);
|
||||
}
|
||||
|
||||
.mlc-inner {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
/* Disable scroll anchoring so manual scrollTop corrections aren't doubled. */
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.mlc-goto-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 4px;
|
||||
background: var(--surface-bg);
|
||||
border-bottom: 1px solid var(--border-subtle-color);
|
||||
z-index: 1;
|
||||
|
||||
.mlc-search-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mlc-search-input,
|
||||
.mlc-goto {
|
||||
font: inherit;
|
||||
font-size: 0.85em;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--input-border-color);
|
||||
border-radius: 3px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
&[aria-invalid="true"] {
|
||||
border-color: red;
|
||||
}
|
||||
}
|
||||
|
||||
.mlc-search-input {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.mlc-goto {
|
||||
width: 110px;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.mlc-nav-button {
|
||||
font: inherit;
|
||||
font-size: 0.85em;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--input-border-color);
|
||||
border-radius: 3px;
|
||||
background: var(--button-bg);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--button-hover-bg);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&[aria-pressed="true"] {
|
||||
background: var(--accent-color);
|
||||
color: light-dark(white, black);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
.mlc-match-info {
|
||||
font-size: 0.8em;
|
||||
color: var(--muted-color);
|
||||
white-space: nowrap;
|
||||
min-width: 4ch;
|
||||
}
|
||||
}
|
||||
|
||||
.mlc-num-item {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
min-width: var(--line-num-width, 3ch);
|
||||
padding-inline: 0.4em;
|
||||
text-align: right;
|
||||
font-family: monospace;
|
||||
font-size: 0.8em;
|
||||
color: var(--muted-color);
|
||||
user-select: none;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.mlc-num-item.mlc-match {
|
||||
background: var(--match-bg);
|
||||
}
|
||||
|
||||
.mlc-match {
|
||||
background: var(--match-bg);
|
||||
outline: 1px solid var(--match-outline-color);
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.mlc-scroll {
|
||||
--surface-bg: Canvas;
|
||||
--input-bg: Field;
|
||||
--button-bg: ButtonFace;
|
||||
--button-hover-bg: Highlight;
|
||||
--text-color: CanvasText;
|
||||
--muted-color: GrayText;
|
||||
--accent-color: CanvasText;
|
||||
--border-subtle-color: ButtonBorder;
|
||||
--input-border-color: ButtonBorder;
|
||||
--match-bg: Mark;
|
||||
--match-outline-color: ButtonBorder;
|
||||
|
||||
.mlc-search-input[aria-invalid="true"],
|
||||
.mlc-goto[aria-invalid="true"] {
|
||||
border-color: ButtonBorder;
|
||||
}
|
||||
}
|
||||
}
|
||||
575
web/internal/multiline_view.js
Normal file
575
web/internal/multiline_view.js
Normal file
@@ -0,0 +1,575 @@
|
||||
/* Copyright 2026 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Number of rows rendered per batch (IntersectionObserver batching).
|
||||
const BATCH_SIZE = 500;
|
||||
// Maximum rows kept in the DOM at once (two batches).
|
||||
const MAX_RENDERED = BATCH_SIZE * 2;
|
||||
|
||||
let _idCounter = 0;
|
||||
|
||||
/**
|
||||
* A scrollable multi-line panel combining:
|
||||
* – a frozen line-number column on the left,
|
||||
* – a scrollable content column on the right,
|
||||
* – a search / go-to-line toolbar at the top,
|
||||
* – IntersectionObserver-based virtual scroll.
|
||||
*
|
||||
* Usage:
|
||||
* const mc = new MultilineView({ total, getText, makeLineEl });
|
||||
* container.append(mc.element);
|
||||
*/
|
||||
class MultilineView {
|
||||
// -- DOM elements --
|
||||
#element;
|
||||
|
||||
#numCol;
|
||||
|
||||
#innerEl;
|
||||
|
||||
#pre;
|
||||
|
||||
#topSentinel;
|
||||
|
||||
#bottomSentinel;
|
||||
|
||||
#observer = null;
|
||||
|
||||
#onScroll = null;
|
||||
|
||||
#total;
|
||||
|
||||
#getText;
|
||||
|
||||
#makeLineEl;
|
||||
|
||||
#startIndex = 0;
|
||||
|
||||
#endIndex = 0;
|
||||
|
||||
#highlightedIndex = -1;
|
||||
|
||||
#searchMatches = [];
|
||||
|
||||
#currentMatchIdx = -1;
|
||||
|
||||
#searchInput;
|
||||
|
||||
#searchError;
|
||||
|
||||
#prevButton;
|
||||
|
||||
#nextButton;
|
||||
|
||||
#matchInfo;
|
||||
|
||||
#ignoreCaseBtn;
|
||||
|
||||
#regexBtn;
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {number} opts.total Total number of lines.
|
||||
* @param {Function} opts.getText (i) => string used for search.
|
||||
* @param {Function} opts.makeLineEl (i, isHighlighted) => HTMLElement.
|
||||
* @param {string} [opts.lineClass] CSS class for the lines container.
|
||||
* @param {HTMLElement} [opts.actions] Element prepended in the toolbar.
|
||||
*/
|
||||
constructor({ total, getText, makeLineEl, lineClass = "", actions = null }) {
|
||||
this.#total = total;
|
||||
this.#getText = getText;
|
||||
this.#makeLineEl = makeLineEl;
|
||||
|
||||
// Root element.
|
||||
this.#element = document.createElement("div");
|
||||
this.#element.className = "mlc-scroll";
|
||||
|
||||
// Line-number column (frozen; scrollTop synced with the scroll pane).
|
||||
this.#numCol = document.createElement("div");
|
||||
this.#numCol.className = "mlc-line-nums-col";
|
||||
this.#numCol.style.setProperty(
|
||||
"--line-num-width",
|
||||
`${String(total).length}ch`
|
||||
);
|
||||
|
||||
// Scrollable content column.
|
||||
this.#innerEl = document.createElement("div");
|
||||
this.#innerEl.className = "mlc-inner";
|
||||
this.#onScroll = () => {
|
||||
this.#numCol.scrollTop = this.#innerEl.scrollTop;
|
||||
};
|
||||
this.#innerEl.addEventListener("scroll", this.#onScroll);
|
||||
|
||||
// Item container inside the scroll column.
|
||||
this.#pre = document.createElement("div");
|
||||
if (lineClass) {
|
||||
this.#pre.className = lineClass;
|
||||
}
|
||||
this.#innerEl.append(this.#pre);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "mlc-body";
|
||||
body.append(this.#numCol, this.#innerEl);
|
||||
|
||||
this.#element.append(this.#buildToolbar(actions), body);
|
||||
|
||||
// Sentinels bracket the rendered window inside #pre:
|
||||
// topSentinel [startIndex .. endIndex) bottomSentinel
|
||||
this.#topSentinel = document.createElement("div");
|
||||
this.#topSentinel.className = "mlc-load-sentinel";
|
||||
this.#bottomSentinel = document.createElement("div");
|
||||
this.#bottomSentinel.className = "mlc-load-sentinel";
|
||||
|
||||
this.#endIndex = Math.min(BATCH_SIZE, total);
|
||||
this.#pre.append(
|
||||
this.#topSentinel,
|
||||
this.#renderRange(0, this.#endIndex),
|
||||
this.#bottomSentinel
|
||||
);
|
||||
this.#numCol.append(this.#renderNumRange(0, this.#endIndex));
|
||||
|
||||
if (total > BATCH_SIZE) {
|
||||
this.#setupObserver();
|
||||
}
|
||||
}
|
||||
|
||||
/** The root element — append to the DOM to display the component. */
|
||||
get element() {
|
||||
return this.#element;
|
||||
}
|
||||
|
||||
/** The inner content container (between the sentinels). Useful for setting
|
||||
* ARIA attributes and attaching keyboard listeners. */
|
||||
get inner() {
|
||||
return this.#pre;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to ensure line i (0-based) is visible without changing the current
|
||||
* search highlight. Useful for programmatic navigation (e.g. a debugger).
|
||||
*/
|
||||
scrollToLine(i) {
|
||||
if (i < 0 || i >= this.#total) {
|
||||
return;
|
||||
}
|
||||
if (i >= this.#startIndex && i < this.#endIndex) {
|
||||
this.#scrollRenderedTargetIntoView(i);
|
||||
} else {
|
||||
this.#jumpToTarget(i);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.#observer?.disconnect();
|
||||
this.#observer = null;
|
||||
|
||||
if (this.#onScroll) {
|
||||
this.#innerEl.removeEventListener("scroll", this.#onScroll);
|
||||
this.#onScroll = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to line i (0-based) and mark it as the current search highlight.
|
||||
* Pass i = -1 to clear the highlight.
|
||||
*/
|
||||
jumpToLine(i) {
|
||||
this.#pre.querySelector(".mlc-match")?.classList.remove("mlc-match");
|
||||
this.#numCol.querySelector(".mlc-match")?.classList.remove("mlc-match");
|
||||
if (i < 0) {
|
||||
this.#highlightedIndex = -1;
|
||||
return;
|
||||
}
|
||||
if (i >= this.#total) {
|
||||
return;
|
||||
}
|
||||
this.#highlightedIndex = i;
|
||||
if (i >= this.#startIndex && i < this.#endIndex) {
|
||||
this.#scrollRenderedTargetIntoView(i);
|
||||
} else {
|
||||
this.#jumpToTarget(i);
|
||||
}
|
||||
this.#pre.children[i - this.#startIndex + 1]?.classList.add("mlc-match");
|
||||
this.#numCol.children[i - this.#startIndex]?.classList.add("mlc-match");
|
||||
}
|
||||
|
||||
#renderRange(from, to) {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let i = from; i < to; i++) {
|
||||
frag.append(this.#makeLineEl(i, i === this.#highlightedIndex));
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
|
||||
#renderNumRange(from, to) {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let i = from; i < to; i++) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "mlc-num-item";
|
||||
if (i === this.#highlightedIndex) {
|
||||
item.classList.add("mlc-match");
|
||||
}
|
||||
item.textContent = String(i + 1);
|
||||
frag.append(item);
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
|
||||
// Re-render a window centred on targetIndex and scroll to it.
|
||||
#jumpToTarget(targetIndex) {
|
||||
// Remove all rendered rows between the sentinels.
|
||||
const firstRow = this.#topSentinel.nextSibling;
|
||||
const lastRow = this.#bottomSentinel.previousSibling;
|
||||
if (firstRow && lastRow && firstRow !== this.#bottomSentinel) {
|
||||
const range = document.createRange();
|
||||
range.setStartBefore(firstRow);
|
||||
range.setEndAfter(lastRow);
|
||||
range.deleteContents();
|
||||
}
|
||||
|
||||
const half = Math.floor(MAX_RENDERED / 2);
|
||||
this.#startIndex = Math.max(0, targetIndex - half);
|
||||
this.#endIndex = Math.min(this.#total, this.#startIndex + MAX_RENDERED);
|
||||
this.#startIndex = Math.max(0, this.#endIndex - MAX_RENDERED);
|
||||
|
||||
this.#topSentinel.after(
|
||||
this.#renderRange(this.#startIndex, this.#endIndex)
|
||||
);
|
||||
this.#numCol.replaceChildren(
|
||||
this.#renderNumRange(this.#startIndex, this.#endIndex)
|
||||
);
|
||||
|
||||
this.#scrollRenderedTargetIntoView(targetIndex);
|
||||
}
|
||||
|
||||
#scrollRenderedTargetIntoView(targetIndex) {
|
||||
// #pre.children: [0]=topSentinel, [1..n]=rows, [n+1]=bottomSentinel
|
||||
const targetEl = this.#pre.children[targetIndex - this.#startIndex + 1];
|
||||
if (!targetEl) {
|
||||
return;
|
||||
}
|
||||
const targetRect = targetEl.getBoundingClientRect();
|
||||
const innerRect = this.#innerEl.getBoundingClientRect();
|
||||
this.#innerEl.scrollTop +=
|
||||
targetRect.top -
|
||||
innerRect.top -
|
||||
this.#innerEl.clientHeight / 2 +
|
||||
targetEl.clientHeight / 2;
|
||||
}
|
||||
|
||||
#setupObserver() {
|
||||
const observer = (this.#observer = new IntersectionObserver(
|
||||
entries => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) {
|
||||
continue;
|
||||
}
|
||||
if (entry.target === this.#bottomSentinel) {
|
||||
this.#loadBottom();
|
||||
} else {
|
||||
this.#loadTop();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: this.#innerEl, rootMargin: "200px" }
|
||||
));
|
||||
observer.observe(this.#topSentinel);
|
||||
observer.observe(this.#bottomSentinel);
|
||||
}
|
||||
|
||||
// Remove `count` children from `parent` starting at `firstChild`, in one
|
||||
// Range operation instead of N individual remove() calls.
|
||||
#removeChildren(parent, firstChild, count, fromEnd = false) {
|
||||
if (count <= 0 || !firstChild) {
|
||||
return;
|
||||
}
|
||||
const range = document.createRange();
|
||||
if (fromEnd) {
|
||||
// Remove the last `count` children ending at firstChild
|
||||
// (=lastChild here).
|
||||
let startChild = firstChild;
|
||||
for (let i = 1; i < count; i++) {
|
||||
startChild = startChild.previousElementSibling;
|
||||
if (!startChild) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
range.setStartBefore(startChild);
|
||||
range.setEndAfter(firstChild);
|
||||
} else {
|
||||
let endChild = firstChild;
|
||||
for (let i = 1; i < count; i++) {
|
||||
endChild = endChild.nextElementSibling;
|
||||
if (!endChild) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
range.setStartBefore(firstChild);
|
||||
range.setEndAfter(endChild);
|
||||
}
|
||||
range.deleteContents();
|
||||
}
|
||||
|
||||
#loadBottom() {
|
||||
const newEnd = Math.min(this.#endIndex + BATCH_SIZE, this.#total);
|
||||
if (newEnd === this.#endIndex) {
|
||||
return;
|
||||
}
|
||||
this.#bottomSentinel.before(this.#renderRange(this.#endIndex, newEnd));
|
||||
this.#numCol.append(this.#renderNumRange(this.#endIndex, newEnd));
|
||||
this.#endIndex = newEnd;
|
||||
|
||||
// Trim from top if the window exceeds MAX_RENDERED.
|
||||
if (this.#endIndex - this.#startIndex > MAX_RENDERED) {
|
||||
const removeCount = this.#endIndex - this.#startIndex - MAX_RENDERED;
|
||||
const heightBefore = this.#pre.scrollHeight;
|
||||
this.#removeChildren(
|
||||
this.#pre,
|
||||
this.#topSentinel.nextElementSibling,
|
||||
removeCount
|
||||
);
|
||||
this.#removeChildren(
|
||||
this.#numCol,
|
||||
this.#numCol.firstElementChild,
|
||||
removeCount
|
||||
);
|
||||
this.#startIndex += removeCount;
|
||||
// Compensate so visible content doesn't jump upward.
|
||||
this.#innerEl.scrollTop -= heightBefore - this.#pre.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
#loadTop() {
|
||||
if (this.#startIndex === 0) {
|
||||
return;
|
||||
}
|
||||
const newStart = Math.max(0, this.#startIndex - BATCH_SIZE);
|
||||
const scrollBefore = this.#innerEl.scrollTop;
|
||||
const heightBefore = this.#pre.scrollHeight;
|
||||
this.#topSentinel.after(this.#renderRange(newStart, this.#startIndex));
|
||||
this.#numCol.prepend(this.#renderNumRange(newStart, this.#startIndex));
|
||||
// Compensate so visible content doesn't jump downward.
|
||||
this.#innerEl.scrollTop =
|
||||
scrollBefore + (this.#pre.scrollHeight - heightBefore);
|
||||
this.#startIndex = newStart;
|
||||
|
||||
// Trim from bottom if the window exceeds MAX_RENDERED.
|
||||
if (this.#endIndex - this.#startIndex > MAX_RENDERED) {
|
||||
const removeCount = this.#endIndex - this.#startIndex - MAX_RENDERED;
|
||||
this.#removeChildren(
|
||||
this.#pre,
|
||||
this.#bottomSentinel.previousElementSibling,
|
||||
removeCount,
|
||||
true
|
||||
);
|
||||
this.#removeChildren(
|
||||
this.#numCol,
|
||||
this.#numCol.lastElementChild,
|
||||
removeCount,
|
||||
true
|
||||
);
|
||||
this.#endIndex -= removeCount;
|
||||
}
|
||||
}
|
||||
|
||||
#buildToolbar(actions) {
|
||||
const id = ++_idCounter;
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "mlc-goto-bar";
|
||||
|
||||
const searchGroup = document.createElement("div");
|
||||
searchGroup.className = "mlc-search-group";
|
||||
|
||||
const searchErrorId = `mlc-err-${id}`;
|
||||
|
||||
const searchInput = (this.#searchInput = document.createElement("input"));
|
||||
searchInput.type = "search";
|
||||
searchInput.className = "mlc-search-input";
|
||||
searchInput.placeholder = "Search for\u2026";
|
||||
searchInput.ariaLabel = "Search";
|
||||
searchInput.setAttribute("aria-describedby", searchErrorId);
|
||||
|
||||
const searchError = (this.#searchError = document.createElement("span"));
|
||||
searchError.id = searchErrorId;
|
||||
searchError.className = "sr-only";
|
||||
searchError.role = "alert";
|
||||
|
||||
const prevButton = (this.#prevButton = document.createElement("button"));
|
||||
prevButton.className = "mlc-nav-button";
|
||||
prevButton.textContent = "↑";
|
||||
prevButton.title = "Previous match";
|
||||
prevButton.disabled = true;
|
||||
|
||||
const nextButton = (this.#nextButton = document.createElement("button"));
|
||||
nextButton.className = "mlc-nav-button";
|
||||
nextButton.textContent = "↓";
|
||||
nextButton.title = "Next match";
|
||||
nextButton.disabled = true;
|
||||
|
||||
const matchInfo = (this.#matchInfo = document.createElement("span"));
|
||||
matchInfo.className = "mlc-match-info";
|
||||
|
||||
const ignoreCaseBtn = (this.#ignoreCaseBtn = this.#makeToggleButton(
|
||||
"Aa",
|
||||
"Ignore case"
|
||||
));
|
||||
const regexBtn = (this.#regexBtn = this.#makeToggleButton(".*", "Regex"));
|
||||
|
||||
searchGroup.append(
|
||||
searchInput,
|
||||
searchError,
|
||||
prevButton,
|
||||
nextButton,
|
||||
ignoreCaseBtn,
|
||||
regexBtn,
|
||||
matchInfo
|
||||
);
|
||||
|
||||
const gotoInput = document.createElement("input");
|
||||
gotoInput.type = "number";
|
||||
gotoInput.className = "mlc-goto";
|
||||
gotoInput.placeholder = "Go to line\u2026";
|
||||
gotoInput.min = "1";
|
||||
gotoInput.max = String(this.#total);
|
||||
gotoInput.step = "1";
|
||||
gotoInput.ariaLabel = "Go to line";
|
||||
|
||||
if (actions) {
|
||||
bar.append(actions);
|
||||
}
|
||||
bar.append(searchGroup, gotoInput);
|
||||
|
||||
searchInput.addEventListener("input", () => this.#runSearch());
|
||||
searchInput.addEventListener("keydown", ({ key, shiftKey }) => {
|
||||
if (key === "Enter") {
|
||||
this.#navigateMatch(shiftKey ? -1 : 1);
|
||||
}
|
||||
});
|
||||
prevButton.addEventListener("click", () => this.#navigateMatch(-1));
|
||||
nextButton.addEventListener("click", () => this.#navigateMatch(1));
|
||||
this.#ignoreCaseBtn.addEventListener("click", () => {
|
||||
this.#ignoreCaseBtn.ariaPressed =
|
||||
this.#ignoreCaseBtn.ariaPressed === "true" ? "false" : "true";
|
||||
this.#runSearch();
|
||||
});
|
||||
this.#regexBtn.addEventListener("click", () => {
|
||||
this.#regexBtn.ariaPressed =
|
||||
this.#regexBtn.ariaPressed === "true" ? "false" : "true";
|
||||
this.#runSearch();
|
||||
});
|
||||
|
||||
gotoInput.addEventListener("keydown", ({ key }) => {
|
||||
if (key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
const value = gotoInput.value.trim();
|
||||
const n = Number(value);
|
||||
if (!value || !Number.isInteger(n) || n < 1 || n > this.#total) {
|
||||
gotoInput.setAttribute("aria-invalid", "true");
|
||||
return;
|
||||
}
|
||||
gotoInput.removeAttribute("aria-invalid");
|
||||
this.jumpToLine(n - 1);
|
||||
});
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
#makeToggleButton(text, title) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "mlc-nav-button";
|
||||
btn.textContent = text;
|
||||
btn.title = title;
|
||||
btn.ariaPressed = "false";
|
||||
return btn;
|
||||
}
|
||||
|
||||
#updateMatchInfo() {
|
||||
if (!this.#searchInput.value) {
|
||||
this.#matchInfo.textContent = "";
|
||||
this.#prevButton.disabled = this.#nextButton.disabled = true;
|
||||
} else if (this.#searchMatches.length === 0) {
|
||||
this.#matchInfo.textContent = "No results";
|
||||
this.#prevButton.disabled = this.#nextButton.disabled = true;
|
||||
} else {
|
||||
this.#matchInfo.textContent = `${this.#currentMatchIdx + 1} / ${this.#searchMatches.length}`;
|
||||
this.#prevButton.disabled = this.#nextButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
#computeMatches() {
|
||||
this.jumpToLine(-1);
|
||||
this.#searchMatches = [];
|
||||
this.#currentMatchIdx = -1;
|
||||
|
||||
const query = this.#searchInput.value;
|
||||
if (!query) {
|
||||
this.#updateMatchInfo();
|
||||
return false;
|
||||
}
|
||||
|
||||
let test;
|
||||
if (this.#regexBtn.ariaPressed === "true") {
|
||||
try {
|
||||
const re = new RegExp(
|
||||
query,
|
||||
this.#ignoreCaseBtn.ariaPressed === "true" ? "i" : ""
|
||||
);
|
||||
test = str => re.test(str);
|
||||
this.#searchInput.removeAttribute("aria-invalid");
|
||||
this.#searchError.textContent = "";
|
||||
} catch {
|
||||
this.#searchInput.setAttribute("aria-invalid", "true");
|
||||
this.#searchError.textContent = "Invalid regular expression";
|
||||
this.#updateMatchInfo();
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const ignoreCase = this.#ignoreCaseBtn.ariaPressed === "true";
|
||||
const needle = ignoreCase ? query.toLowerCase() : query;
|
||||
test = str => (ignoreCase ? str.toLowerCase() : str).includes(needle);
|
||||
}
|
||||
this.#searchInput.removeAttribute("aria-invalid");
|
||||
this.#searchError.textContent = "";
|
||||
|
||||
for (let i = 0, ii = this.#total; i < ii; i++) {
|
||||
if (test(this.#getText(i))) {
|
||||
this.#searchMatches.push(i);
|
||||
}
|
||||
}
|
||||
return this.#searchMatches.length > 0;
|
||||
}
|
||||
|
||||
#navigateMatch(delta) {
|
||||
if (!this.#searchMatches.length) {
|
||||
return;
|
||||
}
|
||||
this.#currentMatchIdx =
|
||||
(this.#currentMatchIdx + delta + this.#searchMatches.length) %
|
||||
this.#searchMatches.length;
|
||||
this.jumpToLine(this.#searchMatches[this.#currentMatchIdx]);
|
||||
this.#updateMatchInfo();
|
||||
}
|
||||
|
||||
#runSearch() {
|
||||
if (this.#computeMatches() && this.#searchMatches.length) {
|
||||
this.#currentMatchIdx = 0;
|
||||
this.jumpToLine(this.#searchMatches[0]);
|
||||
}
|
||||
this.#updateMatchInfo();
|
||||
}
|
||||
}
|
||||
|
||||
export { MultilineView };
|
||||
243
web/internal/page_view.css
Normal file
243
web/internal/page_view.css
Normal file
@@ -0,0 +1,243 @@
|
||||
/* Copyright 2026 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
#debug-view {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
&[hidden] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
#render-panels {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
align-items: stretch;
|
||||
--spc-resizer-color: var(--border-color);
|
||||
--spc-resizer-hover-color: var(--accent-color);
|
||||
}
|
||||
#render-panels {
|
||||
/* instructionsSplit (spc-column) takes half the width next to canvas. */
|
||||
> .spc-column {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
/* canvasFontSplit (spc-row) takes the other half. */
|
||||
> .spc-row {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
/* opTopSplit (spc-row) takes 70% of the instructions column height. */
|
||||
> .spc-column > .spc-row {
|
||||
flex: 7 1 0;
|
||||
}
|
||||
}
|
||||
#gfx-state-panel {
|
||||
flex: 3 1 0;
|
||||
min-width: 20ch;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding-block: 8px;
|
||||
background: var(--surface-bg);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
#canvas-panel {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: var(--surface-bg);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
#canvas-toolbar {
|
||||
--btn-h: calc(
|
||||
1.1em * 1.4 + 4px
|
||||
); /* font-size * line-height + 2×(padding+border) */
|
||||
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
button {
|
||||
box-sizing: border-box;
|
||||
height: var(--btn-h);
|
||||
padding: 0 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--input-border-color);
|
||||
background: var(--button-bg);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 1.1em;
|
||||
line-height: 1.4;
|
||||
|
||||
&:hover {
|
||||
background: var(--button-hover-bg);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
&[aria-pressed="true"] {
|
||||
background: var(--accent-color);
|
||||
color: var(--accent-fg, white);
|
||||
border-color: var(--accent-color);
|
||||
|
||||
&:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--accent-color),
|
||||
var(--accent-fg, white) 15%
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-left:not(:has(#text-filter-button[aria-pressed="true"])) {
|
||||
#text-layer-color-button,
|
||||
#text-span-border-button,
|
||||
#font-view-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
#text-span-border-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: var(--btn-h);
|
||||
}
|
||||
|
||||
#text-layer-color-button {
|
||||
padding: 3px;
|
||||
width: var(--btn-h);
|
||||
|
||||
#text-layer-color-swatch {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--text-layer-color, #c03030);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.toolbar-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#zoom-level {
|
||||
min-width: 4ch;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
#canvas-scroll {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px 12px;
|
||||
min-height: 0;
|
||||
background: var(--clr-canvas-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: safe center;
|
||||
gap: 12px;
|
||||
}
|
||||
#canvas-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
|
||||
/* Make text-layer spans visible in the debugger (normally transparent). */
|
||||
.textLayer :is(span, br) {
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--text-layer-color, #c03030) 70%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.textLayer .font-highlighted {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--font-highlight-color, #0070c1) 25%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&.show-span-borders .textLayer :is(span, br) {
|
||||
outline: 1px solid
|
||||
color-mix(in srgb, var(--text-layer-color, #c03030) 60%, black);
|
||||
}
|
||||
}
|
||||
.temp-canvas-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.temp-canvas-label {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted-color);
|
||||
font-style: italic;
|
||||
}
|
||||
.canvas-checker {
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
background-image: conic-gradient(
|
||||
light-dark(#cfcfd8, #42414d) 25%,
|
||||
light-dark(white, #8f8f9d) 25% 50%,
|
||||
light-dark(#cfcfd8, #42414d) 50% 75%,
|
||||
light-dark(white, #8f8f9d) 75%
|
||||
);
|
||||
background-size: 30px 30px;
|
||||
}
|
||||
.temp-canvas-wrapper .canvas-checker {
|
||||
border: 1px solid var(--border-subtle-color);
|
||||
zoom: calc(1 / var(--dpr, 1));
|
||||
}
|
||||
#render-canvas {
|
||||
cursor: pointer;
|
||||
}
|
||||
#highlight-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
888
web/internal/page_view.js
Normal file
888
web/internal/page_view.js
Normal file
@@ -0,0 +1,888 @@
|
||||
/* Copyright 2026 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
BreakpointType,
|
||||
DrawOpsView,
|
||||
TEXT_EXEC_OP_IDS,
|
||||
TEXT_OP_IDS,
|
||||
} from "./draw_ops_view.js";
|
||||
import { OPS, TextLayer } from "pdfjs-lib";
|
||||
import { CanvasContextDetailsView } from "./canvas_context_details_view.js";
|
||||
import { DOMCanvasFactory } from "pdfjs/display/canvas_factory.js";
|
||||
import { FontView } from "./font_view.js";
|
||||
import { SplitView } from "./split_view.js";
|
||||
|
||||
// Enable font inspection so TextLayer sets data-font-name on each span.
|
||||
// fontAdded is called by FontFaceObject when loading fonts (via the pdfBug
|
||||
// inspectFont callback in api.js) — we don't need it here, but it must exist
|
||||
// to avoid a TypeError that would disrupt font loading and break canvas
|
||||
// rendering.
|
||||
globalThis.FontInspector = { enabled: true, fontAdded() {} };
|
||||
|
||||
// Stepper for pausing/stepping through op list rendering.
|
||||
// Implements the interface expected by InternalRenderTask (pdfBug mode).
|
||||
class ViewerStepper {
|
||||
#onStepped;
|
||||
|
||||
#continueCallback = null;
|
||||
|
||||
// Pass resumeAt to re-pause at a specific index (e.g. after a zoom).
|
||||
constructor(onStepped, resumeAt = null) {
|
||||
this.#onStepped = onStepped;
|
||||
this.nextBreakPoint = resumeAt ?? this.#findNextAfter(-1);
|
||||
this.currentIdx = -1;
|
||||
}
|
||||
|
||||
// Called by executeOperatorList when execution reaches nextBreakPoint.
|
||||
breakIt(i, continueCallback) {
|
||||
this.currentIdx = i;
|
||||
this.#continueCallback = continueCallback;
|
||||
this.#onStepped(i);
|
||||
}
|
||||
|
||||
// Advance one instruction then pause again.
|
||||
// In text-only mode, skip forward to the next text op.
|
||||
stepNext() {
|
||||
if (!this.#continueCallback) {
|
||||
return;
|
||||
}
|
||||
let next = this.currentIdx + 1;
|
||||
if (globalThis.StepperManager._textOnly) {
|
||||
const count = globalThis.StepperManager._opCount();
|
||||
while (next < count && !globalThis.StepperManager._isTextOp(next)) {
|
||||
next++;
|
||||
}
|
||||
if (next >= count) {
|
||||
next = null; // no more text ops; let rendering run to completion
|
||||
}
|
||||
}
|
||||
this.nextBreakPoint = next;
|
||||
const cb = this.#continueCallback;
|
||||
this.#continueCallback = null;
|
||||
cb();
|
||||
}
|
||||
|
||||
// Continue until the next breakpoint (or end).
|
||||
continueToBreakpoint() {
|
||||
if (!this.#continueCallback) {
|
||||
return;
|
||||
}
|
||||
this.nextBreakPoint = this.#findNextAfter(this.currentIdx);
|
||||
const cb = this.#continueCallback;
|
||||
this.#continueCallback = null;
|
||||
cb();
|
||||
}
|
||||
|
||||
shouldSkip(i) {
|
||||
return (
|
||||
globalThis.StepperManager._breakpoints.get(i) === BreakpointType.SKIP ||
|
||||
(globalThis.StepperManager._textOnly &&
|
||||
!globalThis.StepperManager._isTextExecOp(i))
|
||||
);
|
||||
}
|
||||
|
||||
#findNextAfter(idx) {
|
||||
let next = null;
|
||||
for (const [bp, type] of globalThis.StepperManager._breakpoints) {
|
||||
if (
|
||||
type === BreakpointType.PAUSE &&
|
||||
bp > idx &&
|
||||
(next === null || bp < next)
|
||||
) {
|
||||
next = bp;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// Called by InternalRenderTask when the operator list grows (streaming).
|
||||
updateOperatorList() {}
|
||||
|
||||
// Called by InternalRenderTask to initialise the stepper.
|
||||
init() {}
|
||||
|
||||
// Called by InternalRenderTask after recording bboxes (pdfBug mode).
|
||||
setOperatorBBoxes() {}
|
||||
|
||||
getNextBreakPoint() {
|
||||
return this.nextBreakPoint;
|
||||
}
|
||||
}
|
||||
|
||||
const MIN_ZOOM = 0.1;
|
||||
const MAX_ZOOM = 10;
|
||||
const ZOOM_STEP = 1.25;
|
||||
|
||||
class PageView {
|
||||
#pdfDoc = null;
|
||||
|
||||
#gfxStateComp;
|
||||
|
||||
#DebugCanvasFactoryClass;
|
||||
|
||||
#opsView;
|
||||
|
||||
#renderedPage = null;
|
||||
|
||||
#renderScale = null;
|
||||
|
||||
#currentRenderTask = null;
|
||||
|
||||
#currentOpList = null;
|
||||
|
||||
#debugViewGeneration = 0;
|
||||
|
||||
#onMarkLoading;
|
||||
|
||||
#prefersDark;
|
||||
|
||||
#onWindowResize;
|
||||
|
||||
#stepButton;
|
||||
|
||||
#continueButton;
|
||||
|
||||
#zoomLevelEl;
|
||||
|
||||
#zoomOutButton;
|
||||
|
||||
#zoomInButton;
|
||||
|
||||
#redrawButton;
|
||||
|
||||
#textFilterButton;
|
||||
|
||||
#textLayerColorInput;
|
||||
|
||||
#textSpanBorderButton;
|
||||
|
||||
#textFilter = false;
|
||||
|
||||
#highlightCanvas;
|
||||
|
||||
#canvasScrollEl;
|
||||
|
||||
#textLayerEl = null;
|
||||
|
||||
#textLayerInstance = null;
|
||||
|
||||
#fontView;
|
||||
|
||||
#fontViewButton;
|
||||
|
||||
constructor({ onMarkLoading }) {
|
||||
this.#onMarkLoading = onMarkLoading;
|
||||
this.#prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
this.#gfxStateComp = new CanvasContextDetailsView(
|
||||
document.getElementById("gfx-state-panel")
|
||||
);
|
||||
|
||||
this.#stepButton = document.getElementById("step-button");
|
||||
this.#continueButton = document.getElementById("continue-button");
|
||||
|
||||
this.#opsView = new DrawOpsView(
|
||||
document.getElementById("op-list-panel"),
|
||||
document.getElementById("op-detail-panel"),
|
||||
{
|
||||
onHighlight: i => this.#drawHighlight(i),
|
||||
onClearHighlight: () => this.#clearHighlight(),
|
||||
prefersDark: this.#prefersDark,
|
||||
}
|
||||
);
|
||||
|
||||
this.#fontView = new FontView(document.getElementById("font-panel"), {
|
||||
onSelect: loadedName => {
|
||||
if (!this.#textLayerEl) {
|
||||
return;
|
||||
}
|
||||
for (const span of this.#textLayerEl.querySelectorAll(
|
||||
".font-highlighted"
|
||||
)) {
|
||||
span.classList.remove("font-highlighted");
|
||||
}
|
||||
if (loadedName) {
|
||||
for (const span of this.#textLayerEl.querySelectorAll(
|
||||
`[data-font-name="${CSS.escape(loadedName)}"]`
|
||||
)) {
|
||||
span.classList.add("font-highlighted");
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
this.#fontViewButton = document.getElementById("font-view-button");
|
||||
globalThis.FontInspector.fontAdded = font => this.#fontView.fontAdded(font);
|
||||
|
||||
// Install a StepperManager so InternalRenderTask (pdfBug mode) picks it up.
|
||||
// A new instance is set on each redraw; null means no stepping.
|
||||
globalThis.StepperManager = {
|
||||
get enabled() {
|
||||
return globalThis.StepperManager._active !== null;
|
||||
},
|
||||
_active: null,
|
||||
_breakpoints: this.#opsView.breakpoints,
|
||||
_textOnly: false,
|
||||
// Returns true when op index i is a text op shown in the filtered list.
|
||||
_isTextOp: i => TEXT_OP_IDS.has(this.#currentOpList?.fnArray[i]),
|
||||
// Returns true when op index i must be executed (not skipped) in text
|
||||
// mode.
|
||||
_isTextExecOp: i => TEXT_EXEC_OP_IDS.has(this.#currentOpList?.fnArray[i]),
|
||||
// Returns the total number of ops in the current op list.
|
||||
_opCount: () => this.#currentOpList?.fnArray.length ?? 0,
|
||||
create() {
|
||||
return globalThis.StepperManager._active;
|
||||
},
|
||||
};
|
||||
|
||||
// Keep --dpr in sync so CSS can scale temp canvases correctly.
|
||||
this.#updateDPR();
|
||||
this.#onWindowResize = () => this.#updateDPR();
|
||||
window.addEventListener("resize", this.#onWindowResize);
|
||||
|
||||
this.#DebugCanvasFactoryClass = this.#makeDebugCanvasFactory();
|
||||
|
||||
this.#setupSplits();
|
||||
|
||||
this.#zoomLevelEl = document.getElementById("zoom-level");
|
||||
this.#zoomOutButton = document.getElementById("zoom-out-button");
|
||||
this.#zoomInButton = document.getElementById("zoom-in-button");
|
||||
this.#redrawButton = document.getElementById("redraw-button");
|
||||
this.#textFilterButton = document.getElementById("text-filter-button");
|
||||
this.#textLayerColorInput = document.getElementById(
|
||||
"text-layer-color-input"
|
||||
);
|
||||
this.#textSpanBorderButton = document.getElementById(
|
||||
"text-span-border-button"
|
||||
);
|
||||
this.#highlightCanvas = document.getElementById("highlight-canvas");
|
||||
this.#canvasScrollEl = document.getElementById("canvas-scroll");
|
||||
|
||||
this.#setupEventListeners();
|
||||
}
|
||||
|
||||
// Expose DebugCanvasFactory class so caller can pass to getDocument().
|
||||
get DebugCanvasFactory() {
|
||||
return this.#DebugCanvasFactoryClass;
|
||||
}
|
||||
|
||||
// Show the debug view for a given page.
|
||||
async show(pdfDoc, pageNum) {
|
||||
this.#pdfDoc = pdfDoc;
|
||||
if (this.#currentOpList === null) {
|
||||
await this.#showRenderView(pageNum);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset all debug state (call when navigating to tree or loading new doc).
|
||||
reset() {
|
||||
this.#debugViewGeneration++;
|
||||
this.#cancelTextLayer();
|
||||
this.#currentRenderTask?.cancel();
|
||||
this.#currentRenderTask = null;
|
||||
this.#renderedPage?.cleanup();
|
||||
this.#renderedPage = this.#renderScale = this.#currentOpList = null;
|
||||
this.#clearPausedState();
|
||||
this.#opsView.clear();
|
||||
this.#fontView.clear();
|
||||
this.#gfxStateComp.clear();
|
||||
this.#pdfDoc?.canvasFactory.clear();
|
||||
|
||||
const mainCanvas = document.getElementById("render-canvas");
|
||||
mainCanvas.width = mainCanvas.height = 0;
|
||||
this.#highlightCanvas.width = this.#highlightCanvas.height = 0;
|
||||
|
||||
this.#zoomLevelEl.textContent = "";
|
||||
this.#zoomOutButton.disabled = false;
|
||||
this.#zoomInButton.disabled = false;
|
||||
this.#redrawButton.disabled = true;
|
||||
}
|
||||
|
||||
#updateDPR() {
|
||||
document.documentElement.style.setProperty(
|
||||
"--dpr",
|
||||
window.devicePixelRatio || 1
|
||||
);
|
||||
}
|
||||
|
||||
#makeDebugCanvasFactory() {
|
||||
const gfxStateComp = this.#gfxStateComp;
|
||||
// Custom CanvasFactory that tracks temporary canvases created during
|
||||
// rendering. When stepping, each temporary canvas is shown below the main
|
||||
// page canvas to inspect intermediate compositing targets (masks, etc).
|
||||
return class DebugCanvasFactory extends DOMCanvasFactory {
|
||||
// Wrapper objects currently alive: { canvas, context, wrapper, label }.
|
||||
#alive = [];
|
||||
|
||||
// getDocument passes { ownerDocument, enableHWA } to the constructor.
|
||||
constructor({ ownerDocument, enableHWA } = {}) {
|
||||
super({ ownerDocument: ownerDocument ?? document, enableHWA });
|
||||
}
|
||||
|
||||
create(width, height) {
|
||||
const canvasAndCtx = super.create(width, height);
|
||||
const label = `Temp ${this.#alive.length + 1}`;
|
||||
canvasAndCtx.context = gfxStateComp.wrapCanvasGetContext(
|
||||
canvasAndCtx.canvas,
|
||||
label
|
||||
);
|
||||
if (globalThis.StepperManager._active !== null) {
|
||||
this.#attach(canvasAndCtx, width, height, label);
|
||||
}
|
||||
return canvasAndCtx;
|
||||
}
|
||||
|
||||
reset(canvasAndCtx, width, height) {
|
||||
super.reset(canvasAndCtx, width, height);
|
||||
const entry = this.#alive.find(e => e.canvasAndCtx === canvasAndCtx);
|
||||
if (entry) {
|
||||
entry.labelEl.textContent = `${entry.labelEl.textContent.split("—")[0].trim()} — ${width}×${height}`;
|
||||
}
|
||||
}
|
||||
|
||||
destroy(canvasAndCtx) {
|
||||
const idx = this.#alive.findIndex(e => e.canvasAndCtx === canvasAndCtx);
|
||||
if (idx !== -1) {
|
||||
const { wrapper, ctxLabel } = this.#alive[idx];
|
||||
wrapper.remove();
|
||||
gfxStateComp.removeContext(ctxLabel);
|
||||
this.#alive.splice(idx, 1);
|
||||
}
|
||||
super.destroy(canvasAndCtx);
|
||||
}
|
||||
|
||||
// Show all currently-alive canvases (called when stepping starts).
|
||||
showAll() {
|
||||
for (const entry of this.#alive) {
|
||||
if (!entry.wrapper.isConnected) {
|
||||
this.#attachWrapper(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all temporary canvases from the DOM and clear tracking state.
|
||||
clear() {
|
||||
for (const entry of this.#alive) {
|
||||
entry.wrapper.remove();
|
||||
entry.canvasAndCtx.canvas.width = 0;
|
||||
entry.canvasAndCtx.canvas.height = 0;
|
||||
}
|
||||
this.#alive.length = 0;
|
||||
}
|
||||
|
||||
#attach(canvasAndCtx, width, height, ctxLabel) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "temp-canvas-wrapper";
|
||||
wrapper.addEventListener("click", () =>
|
||||
gfxStateComp.scrollToSection(ctxLabel)
|
||||
);
|
||||
const labelEl = document.createElement("div");
|
||||
labelEl.className = "temp-canvas-label";
|
||||
labelEl.textContent = `${ctxLabel} — ${width}×${height}`;
|
||||
const checker = document.createElement("div");
|
||||
checker.className = "canvas-checker";
|
||||
checker.append(canvasAndCtx.canvas);
|
||||
wrapper.append(labelEl, checker);
|
||||
const entry = { canvasAndCtx, wrapper, labelEl, ctxLabel };
|
||||
this.#alive.push(entry);
|
||||
this.#attachWrapper(entry);
|
||||
}
|
||||
|
||||
#attachWrapper(entry) {
|
||||
document.getElementById("canvas-scroll").append(entry.wrapper);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#setupSplits() {
|
||||
// Build the three SplitView instances that make up the debug view layout.
|
||||
// Inner splits are created first so outer splits can wrap the new
|
||||
// containers.
|
||||
// Layout: splitHor(splitVer(splitHor(op-list, gfx-state), op-detail),
|
||||
// canvas)
|
||||
|
||||
// Inner row split: op-list on the left, gfx-state on the right (hidden by
|
||||
// default).
|
||||
const opTopSplit = new SplitView(
|
||||
document.getElementById("op-list-panel"),
|
||||
document.getElementById("gfx-state-panel"),
|
||||
{ direction: "row", minSize: 60 }
|
||||
);
|
||||
|
||||
// Column split: op-list+gfx-state on top, op-detail on the bottom.
|
||||
const instructionsSplit = new SplitView(
|
||||
opTopSplit.element,
|
||||
document.getElementById("op-detail-panel"),
|
||||
{ direction: "column", minSize: 40 }
|
||||
);
|
||||
|
||||
// Row split: canvas on the left, font panel on the right (hidden by
|
||||
// default).
|
||||
const canvasFontSplit = new SplitView(
|
||||
document.getElementById("canvas-panel"),
|
||||
document.getElementById("font-panel"),
|
||||
{ direction: "row", minSize: 150, onResize: () => this.#rerenderCanvas() }
|
||||
);
|
||||
|
||||
// Outer row split: instructions column on the left, canvas+font on the
|
||||
// right.
|
||||
const renderSplit = new SplitView(
|
||||
instructionsSplit.element,
|
||||
canvasFontSplit.element,
|
||||
{ direction: "row", minSize: 100, onResize: () => this.#rerenderCanvas() }
|
||||
);
|
||||
|
||||
const renderPanels = document.getElementById("render-panels");
|
||||
renderPanels.replaceWith(renderSplit.element);
|
||||
renderSplit.element.id = "render-panels";
|
||||
}
|
||||
|
||||
#setupEventListeners() {
|
||||
this.#zoomInButton.addEventListener("click", () =>
|
||||
this.#zoomRenderCanvas(
|
||||
Math.min(
|
||||
MAX_ZOOM,
|
||||
(this.#renderScale ?? this.#getFitScale()) * ZOOM_STEP
|
||||
)
|
||||
)
|
||||
);
|
||||
this.#zoomOutButton.addEventListener("click", () =>
|
||||
this.#zoomRenderCanvas(
|
||||
Math.max(
|
||||
MIN_ZOOM,
|
||||
(this.#renderScale ?? this.#getFitScale()) / ZOOM_STEP
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
this.#redrawButton.addEventListener("click", async () => {
|
||||
if (!this.#renderedPage || !this.#currentOpList) {
|
||||
return;
|
||||
}
|
||||
this.#clearPausedState();
|
||||
// Reset recorded bboxes so they get re-recorded for the modified op
|
||||
// list.
|
||||
this.#renderedPage.recordedBBoxes = null;
|
||||
if (this.#textFilter || this.#opsView.breakpoints.size > 0) {
|
||||
globalThis.StepperManager._active = new ViewerStepper(i =>
|
||||
this.#onStepped(i)
|
||||
);
|
||||
}
|
||||
await this.#renderCanvas();
|
||||
});
|
||||
|
||||
this.#stepButton.addEventListener("click", () => {
|
||||
if (globalThis.StepperManager._active) {
|
||||
this.#gfxStateComp.freeze();
|
||||
globalThis.StepperManager._active.stepNext();
|
||||
}
|
||||
});
|
||||
|
||||
this.#continueButton.addEventListener("click", () => {
|
||||
if (globalThis.StepperManager._active) {
|
||||
this.#gfxStateComp.freeze();
|
||||
globalThis.StepperManager._active.continueToBreakpoint();
|
||||
}
|
||||
});
|
||||
|
||||
const TEXT_LAYER_COLOR_KEY = "debugger.textLayerColor";
|
||||
const DEFAULT_TEXT_LAYER_COLOR = "#c03030";
|
||||
const applyColor = color => {
|
||||
this.#textLayerColorInput.value = color;
|
||||
document.documentElement.style.setProperty("--text-layer-color", color);
|
||||
};
|
||||
|
||||
applyColor(
|
||||
localStorage.getItem(TEXT_LAYER_COLOR_KEY) ?? DEFAULT_TEXT_LAYER_COLOR
|
||||
);
|
||||
|
||||
document
|
||||
.getElementById("text-layer-color-button")
|
||||
.addEventListener("click", () => this.#textLayerColorInput.click());
|
||||
|
||||
this.#textLayerColorInput.addEventListener("input", () => {
|
||||
const color = this.#textLayerColorInput.value;
|
||||
applyColor(color);
|
||||
localStorage.setItem(TEXT_LAYER_COLOR_KEY, color);
|
||||
});
|
||||
|
||||
const SPAN_BORDERS_KEY = "debugger.spanBorders";
|
||||
const applySpanBorders = enabled => {
|
||||
this.#textSpanBorderButton.setAttribute("aria-pressed", String(enabled));
|
||||
document
|
||||
.getElementById("canvas-wrapper")
|
||||
.classList.toggle("show-span-borders", enabled);
|
||||
};
|
||||
|
||||
applySpanBorders(localStorage.getItem(SPAN_BORDERS_KEY) === "true");
|
||||
|
||||
this.#textSpanBorderButton.addEventListener("click", () => {
|
||||
const next =
|
||||
this.#textSpanBorderButton.getAttribute("aria-pressed") !== "true";
|
||||
applySpanBorders(next);
|
||||
localStorage.setItem(SPAN_BORDERS_KEY, String(next));
|
||||
});
|
||||
|
||||
this.#fontViewButton.addEventListener("click", () => {
|
||||
const next = this.#fontViewButton.getAttribute("aria-pressed") !== "true";
|
||||
this.#fontViewButton.setAttribute("aria-pressed", String(next));
|
||||
const fontPanelEl = this.#fontView.element;
|
||||
if (next && !fontPanelEl.style.flexGrow) {
|
||||
// On first reveal, size the font panel to its SplitView minSize (150px)
|
||||
// and give the canvas panel the remaining space.
|
||||
// Both panels need flex-basis:0 for SplitView's pixel-weight math, so
|
||||
// we must set both flexGrow values explicitly here.
|
||||
const FONT_PANEL_MIN = 150;
|
||||
const RESIZER_SIZE = 6;
|
||||
const available =
|
||||
fontPanelEl.parentElement.getBoundingClientRect().width -
|
||||
RESIZER_SIZE;
|
||||
fontPanelEl.style.flexGrow = FONT_PANEL_MIN;
|
||||
document.getElementById("canvas-panel").style.flexGrow = Math.max(
|
||||
100,
|
||||
available - FONT_PANEL_MIN
|
||||
);
|
||||
}
|
||||
fontPanelEl.hidden = !next;
|
||||
this.#rerenderCanvas();
|
||||
});
|
||||
|
||||
this.#textFilterButton.addEventListener("click", () => {
|
||||
const pressed =
|
||||
this.#textFilterButton.getAttribute("aria-pressed") === "true";
|
||||
const next = !pressed;
|
||||
this.#textFilterButton.setAttribute("aria-pressed", String(next));
|
||||
this.#textFilter = next;
|
||||
globalThis.StepperManager._textOnly = next;
|
||||
this.#opsView.setTextFilter(next);
|
||||
this.#redrawButton.click();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", e => {
|
||||
if (
|
||||
e.target.matches("input, textarea, [contenteditable]") ||
|
||||
e.altKey ||
|
||||
e.ctrlKey ||
|
||||
e.metaKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const stepper = globalThis.StepperManager._active;
|
||||
if (!stepper) {
|
||||
return;
|
||||
}
|
||||
if (e.key === "s") {
|
||||
e.preventDefault();
|
||||
this.#gfxStateComp.freeze();
|
||||
stepper.stepNext();
|
||||
} else if (e.key === "c") {
|
||||
e.preventDefault();
|
||||
this.#gfxStateComp.freeze();
|
||||
stepper.continueToBreakpoint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#onStepped(i) {
|
||||
this.#opsView.markPaused(i);
|
||||
this.#stepButton.disabled = this.#continueButton.disabled = false;
|
||||
this.#gfxStateComp.build();
|
||||
}
|
||||
|
||||
#clearPausedState() {
|
||||
this.#opsView.clearPaused();
|
||||
globalThis.StepperManager._active = null;
|
||||
this.#stepButton.disabled = this.#continueButton.disabled = true;
|
||||
this.#gfxStateComp.hide();
|
||||
}
|
||||
|
||||
#getFitScale() {
|
||||
return (
|
||||
(this.#canvasScrollEl.clientWidth - 24) /
|
||||
this.#renderedPage.getViewport({ scale: 1 }).width
|
||||
);
|
||||
}
|
||||
|
||||
// Re-render preserving any current pause position and text-only state.
|
||||
// Used by both zoom and resize so neither loses stepper or filter state.
|
||||
#rerenderCanvas() {
|
||||
const stepper = globalThis.StepperManager._active;
|
||||
let resumeAt = null;
|
||||
if (stepper !== null) {
|
||||
resumeAt =
|
||||
stepper.currentIdx >= 0 ? stepper.currentIdx : stepper.nextBreakPoint;
|
||||
}
|
||||
this.#clearPausedState();
|
||||
if (resumeAt !== null || this.#textFilter) {
|
||||
globalThis.StepperManager._active = new ViewerStepper(
|
||||
i => this.#onStepped(i),
|
||||
resumeAt
|
||||
);
|
||||
}
|
||||
return this.#renderCanvas();
|
||||
}
|
||||
|
||||
#zoomRenderCanvas(newScale) {
|
||||
this.#renderScale = newScale;
|
||||
return this.#rerenderCanvas();
|
||||
}
|
||||
|
||||
#cancelTextLayer() {
|
||||
this.#textLayerInstance?.cancel();
|
||||
this.#textLayerEl?.remove();
|
||||
this.#textLayerInstance = null;
|
||||
this.#textLayerEl = null;
|
||||
}
|
||||
|
||||
async #buildTextLayer(scale) {
|
||||
const container = document.createElement("div");
|
||||
container.className = "textLayer";
|
||||
// --total-scale-factor is required by text_layer_builder.css to compute
|
||||
// font sizes. setLayerDimensions (called inside TextLayer) consumes it but
|
||||
// never sets it, so we must provide it here.
|
||||
container.style.setProperty("--total-scale-factor", scale);
|
||||
container.style.setProperty("--scale-round-x", "1px");
|
||||
container.style.setProperty("--scale-round-y", "1px");
|
||||
document.getElementById("canvas-wrapper").append(container);
|
||||
this.#textLayerEl = container;
|
||||
|
||||
const viewport = this.#renderedPage.getViewport({ scale });
|
||||
const textLayer = new TextLayer({
|
||||
textContentSource: this.#renderedPage.streamTextContent(),
|
||||
container,
|
||||
viewport,
|
||||
});
|
||||
this.#textLayerInstance = textLayer;
|
||||
|
||||
try {
|
||||
await textLayer.render();
|
||||
} catch (err) {
|
||||
if (err?.name !== "AbortException") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #renderCanvas() {
|
||||
if (!this.#renderedPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cancel any in-progress render before starting a new one.
|
||||
// Hide the text layer immediately so it isn't visible at the wrong scale
|
||||
// during the render; it is shown again once the canvas is ready.
|
||||
if (this.#textLayerEl) {
|
||||
this.#textLayerEl.style.visibility = "hidden";
|
||||
}
|
||||
this.#currentRenderTask?.cancel();
|
||||
this.#currentRenderTask = null;
|
||||
|
||||
const highlight = this.#highlightCanvas;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const scale = this.#renderScale ?? this.#getFitScale();
|
||||
this.#zoomLevelEl.textContent = `${Math.round(scale * 100)}%`;
|
||||
this.#zoomOutButton.disabled = scale <= MIN_ZOOM;
|
||||
this.#zoomInButton.disabled = scale >= MAX_ZOOM;
|
||||
const viewport = this.#renderedPage.getViewport({ scale: scale * dpr });
|
||||
const cssW = `${viewport.width / dpr}px`;
|
||||
const cssH = `${viewport.height / dpr}px`;
|
||||
|
||||
// Size the highlight canvas immediately so it stays in sync.
|
||||
highlight.width = viewport.width;
|
||||
highlight.height = viewport.height;
|
||||
highlight.style.width = cssW;
|
||||
highlight.style.height = cssH;
|
||||
|
||||
// Render into a fresh canvas. When stepping, insert it into the DOM
|
||||
// immediately so the user sees each instruction drawn live. For normal
|
||||
// renders, swap only after completion so there's no blank flash.
|
||||
const newCanvas = document.createElement("canvas");
|
||||
newCanvas.id = "render-canvas";
|
||||
newCanvas.width = viewport.width;
|
||||
newCanvas.height = viewport.height;
|
||||
newCanvas.style.width = cssW;
|
||||
newCanvas.style.height = cssH;
|
||||
newCanvas.addEventListener("click", () =>
|
||||
this.#gfxStateComp.scrollToSection("Page")
|
||||
);
|
||||
|
||||
const isStepping = globalThis.StepperManager._active !== null;
|
||||
if (isStepping) {
|
||||
const oldCanvas = document.getElementById("render-canvas");
|
||||
oldCanvas.width = oldCanvas.height = 0;
|
||||
oldCanvas.replaceWith(newCanvas);
|
||||
// Show any temporary canvases that survived from the previous render
|
||||
// (e.g. after a zoom-while-stepping, the factory may already have
|
||||
// entries).
|
||||
this.#pdfDoc?.canvasFactory.showAll();
|
||||
} else {
|
||||
// Starting a fresh non-stepping render: remove leftover temp canvases.
|
||||
this.#pdfDoc?.canvasFactory.clear();
|
||||
}
|
||||
|
||||
// Record bboxes only on the first render; they stay valid for subsequent
|
||||
// re-renders because BBoxReader returns normalised [0, 1] fractions.
|
||||
const firstRender = !this.#renderedPage.recordedBBoxes;
|
||||
const renderTask = this.#renderedPage.render({
|
||||
canvasContext: this.#gfxStateComp.wrapCanvasGetContext(newCanvas, "Page"),
|
||||
viewport,
|
||||
recordOperations: firstRender,
|
||||
});
|
||||
this.#currentRenderTask = renderTask;
|
||||
|
||||
try {
|
||||
await renderTask.promise;
|
||||
} catch (err) {
|
||||
if (err?.name === "RenderingCancelledException") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (this.#currentRenderTask === renderTask) {
|
||||
this.#currentRenderTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Render completed fully — stepping session is over.
|
||||
this.#clearPausedState();
|
||||
this.#pdfDoc?.canvasFactory.clear();
|
||||
this.#redrawButton.disabled = false;
|
||||
|
||||
if (!isStepping) {
|
||||
// Swap the completed canvas in, replacing the previous one. Zero out the
|
||||
// old canvas dimensions to release its GPU memory.
|
||||
const oldCanvas = document.getElementById("render-canvas");
|
||||
oldCanvas.width = oldCanvas.height = 0;
|
||||
oldCanvas.replaceWith(newCanvas);
|
||||
}
|
||||
|
||||
// In text-only mode, overlay the text layer on the finished canvas.
|
||||
// If a layer already exists (e.g. after a zoom/resize), rescale it in place
|
||||
// by updating the CSS variable and calling update() — no rebuild needed.
|
||||
// If the filter is now off, discard any existing layer.
|
||||
if (this.#textFilter) {
|
||||
if (this.#textLayerInstance) {
|
||||
this.#textLayerEl.style.setProperty("--total-scale-factor", scale);
|
||||
this.#textLayerInstance.update({
|
||||
viewport: this.#renderedPage.getViewport({ scale }),
|
||||
});
|
||||
this.#textLayerEl.style.visibility = "";
|
||||
} else {
|
||||
await this.#buildTextLayer(scale);
|
||||
}
|
||||
} else {
|
||||
this.#cancelTextLayer();
|
||||
}
|
||||
|
||||
// Return the task on first render so the caller can extract the operator
|
||||
// list without a separate getOperatorList() call (dev/testing builds only).
|
||||
return firstRender ? renderTask : null;
|
||||
}
|
||||
|
||||
#drawHighlight(opIdx) {
|
||||
const bboxes = this.#renderedPage?.recordedBBoxes;
|
||||
if (!bboxes || opIdx >= bboxes.length || bboxes.isEmpty(opIdx)) {
|
||||
this.#clearHighlight();
|
||||
return;
|
||||
}
|
||||
const canvas = document.getElementById("render-canvas");
|
||||
const highlight = this.#highlightCanvas;
|
||||
const cssW = parseFloat(canvas.style.width);
|
||||
const cssH = parseFloat(canvas.style.height);
|
||||
const x = bboxes.minX(opIdx) * cssW;
|
||||
const y = bboxes.minY(opIdx) * cssH;
|
||||
const w = (bboxes.maxX(opIdx) - bboxes.minX(opIdx)) * cssW;
|
||||
const h = (bboxes.maxY(opIdx) - bboxes.minY(opIdx)) * cssH;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const ctx = highlight.getContext("2d");
|
||||
ctx.clearRect(0, 0, highlight.width, highlight.height);
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.fillStyle = "rgba(255, 165, 0, 0.3)";
|
||||
ctx.strokeStyle = "rgba(255, 140, 0, 0.9)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.fillRect(x, y, w, h);
|
||||
ctx.strokeRect(x, y, w, h);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
#clearHighlight() {
|
||||
this.#highlightCanvas
|
||||
.getContext("2d")
|
||||
.clearRect(
|
||||
0,
|
||||
0,
|
||||
this.#highlightCanvas.width,
|
||||
this.#highlightCanvas.height
|
||||
);
|
||||
}
|
||||
|
||||
async #showRenderView(pageNum) {
|
||||
const generation = this.#debugViewGeneration;
|
||||
const opListEl = document.getElementById("op-list");
|
||||
|
||||
const spinner = document.createElement("div");
|
||||
spinner.role = "status";
|
||||
spinner.textContent = "Loading…";
|
||||
opListEl.replaceChildren(spinner);
|
||||
document.getElementById("op-detail-panel").replaceChildren();
|
||||
|
||||
this.#renderScale = null;
|
||||
this.#onMarkLoading(1);
|
||||
try {
|
||||
this.#renderedPage = await this.#pdfDoc.getPage(pageNum);
|
||||
if (this.#debugViewGeneration !== generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Render the page (records bboxes too). Reuse the operator list from
|
||||
// the render task when available (dev/testing builds); fall back to a
|
||||
// separate getOperatorList() call otherwise.
|
||||
const renderTask = await this.#renderCanvas();
|
||||
if (this.#debugViewGeneration !== generation) {
|
||||
return;
|
||||
}
|
||||
this.#currentOpList =
|
||||
renderTask?.getOperatorList?.() ??
|
||||
(await this.#renderedPage.getOperatorList());
|
||||
if (this.#debugViewGeneration !== generation) {
|
||||
return;
|
||||
}
|
||||
this.#opsView.load(this.#currentOpList, this.#renderedPage);
|
||||
this.#fontView.showForOpList(this.#currentOpList, OPS);
|
||||
// If text-only filter is active, re-render immediately using only text
|
||||
// ops so the canvas matches the filtered op list.
|
||||
if (this.#textFilter) {
|
||||
if (this.#debugViewGeneration !== generation) {
|
||||
return;
|
||||
}
|
||||
this.#renderedPage.recordedBBoxes = null;
|
||||
globalThis.StepperManager._active = new ViewerStepper(i =>
|
||||
this.#onStepped(i)
|
||||
);
|
||||
await this.#renderCanvas();
|
||||
}
|
||||
} catch (err) {
|
||||
const errEl = document.createElement("div");
|
||||
errEl.role = "alert";
|
||||
errEl.textContent = `Error: ${err.message}`;
|
||||
opListEl.replaceChildren(errEl);
|
||||
} finally {
|
||||
this.#onMarkLoading(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { PageView };
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
213
web/internal/split_view.js
Normal file
213
web/internal/split_view.js
Normal file
@@ -0,0 +1,213 @@
|
||||
/* Copyright 2026 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { MathClamp } from "pdfjs-lib";
|
||||
|
||||
/**
|
||||
* Wraps two elements with a drag-to-resize handle between them.
|
||||
*
|
||||
* @param {HTMLElement} firstEl
|
||||
* @param {HTMLElement} secondEl
|
||||
* @param {object} [options]
|
||||
* @param {"row"|"column"} [options.direction="row"] Layout axis.
|
||||
* @param {number} [options.minSize=40] Min px for each panel.
|
||||
* @param {Function} [options.onResize] Called after each resize.
|
||||
*/
|
||||
class SplitView {
|
||||
#container;
|
||||
|
||||
#resizer;
|
||||
|
||||
#isRow;
|
||||
|
||||
#minSize;
|
||||
|
||||
#onResize;
|
||||
|
||||
#onPointerDown = null;
|
||||
|
||||
#onKeyDown = null;
|
||||
|
||||
constructor(
|
||||
firstEl,
|
||||
secondEl,
|
||||
{ direction = "row", minSize = 40, onResize } = {}
|
||||
) {
|
||||
this.#isRow = direction === "row";
|
||||
this.#minSize = minSize;
|
||||
this.#onResize = onResize;
|
||||
|
||||
const resizer = (this.#resizer = document.createElement("div"));
|
||||
resizer.className = "spc-resizer";
|
||||
resizer.role = "separator";
|
||||
resizer.tabIndex = 0;
|
||||
resizer.ariaOrientation = this.#isRow ? "vertical" : "horizontal";
|
||||
resizer.ariaValueMin = 0;
|
||||
resizer.ariaValueMax = 100;
|
||||
resizer.ariaValueNow = 50;
|
||||
|
||||
this.#container = document.createElement("div");
|
||||
this.#container.className = `spc-container spc-${direction}`;
|
||||
this.#container.append(firstEl, resizer, secondEl);
|
||||
|
||||
this.#setupResizer();
|
||||
}
|
||||
|
||||
get element() {
|
||||
return this.#container;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.#onPointerDown) {
|
||||
this.#resizer.removeEventListener("pointerdown", this.#onPointerDown);
|
||||
this.#onPointerDown = null;
|
||||
}
|
||||
if (this.#onKeyDown) {
|
||||
this.#resizer.removeEventListener("keydown", this.#onKeyDown);
|
||||
this.#onKeyDown = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Always read the live first/last child so callers can swap panels in-place.
|
||||
get #first() {
|
||||
return this.#container.firstElementChild;
|
||||
}
|
||||
|
||||
get #second() {
|
||||
return this.#container.lastElementChild;
|
||||
}
|
||||
|
||||
#dimension() {
|
||||
return this.#isRow ? "width" : "height";
|
||||
}
|
||||
|
||||
#updateAria(containerSize, resizerSize) {
|
||||
const total = containerSize - resizerSize;
|
||||
if (total <= 0) {
|
||||
return;
|
||||
}
|
||||
const firstSize = this.#first.getBoundingClientRect()[this.#dimension()];
|
||||
this.#resizer.ariaValueNow = Math.round((firstSize / total) * 100);
|
||||
}
|
||||
|
||||
#clampFirstSize(total, requestedFirst) {
|
||||
if (total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (total <= this.#minSize * 2) {
|
||||
return MathClamp(0, requestedFirst, total);
|
||||
}
|
||||
return MathClamp(total - this.#minSize, this.#minSize, requestedFirst);
|
||||
}
|
||||
|
||||
#resize(newFirst) {
|
||||
const dimension = this.#dimension();
|
||||
const containerSize = this.#container.getBoundingClientRect()[dimension];
|
||||
const resizerSize = this.#resizer.getBoundingClientRect()[dimension];
|
||||
this.#resizeWithMetrics(newFirst, containerSize, resizerSize);
|
||||
}
|
||||
|
||||
#resizeWithMetrics(newFirst, containerSize, resizerSize) {
|
||||
const total = containerSize - resizerSize;
|
||||
const clamped = this.#clampFirstSize(total, newFirst);
|
||||
this.#first.style.flexGrow = clamped;
|
||||
this.#second.style.flexGrow = total - clamped;
|
||||
this.#updateAria(containerSize, resizerSize);
|
||||
}
|
||||
|
||||
#setupResizer() {
|
||||
const axis = this.#isRow ? "clientX" : "clientY";
|
||||
const cursor = this.#isRow ? "col-resize" : "row-resize";
|
||||
|
||||
this.#onPointerDown = e => {
|
||||
if (e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
|
||||
const dimension = this.#dimension();
|
||||
const containerSize = this.#container.getBoundingClientRect()[dimension];
|
||||
const resizerSize = this.#resizer.getBoundingClientRect()[dimension];
|
||||
const startPos = e[axis];
|
||||
const startFirst = this.#first.getBoundingClientRect()[dimension];
|
||||
|
||||
this.#resizer.classList.add("dragging");
|
||||
document.body.style.cursor = cursor;
|
||||
|
||||
const ac = new AbortController();
|
||||
const { signal } = ac;
|
||||
|
||||
const cancelDrag = () => {
|
||||
ac.abort();
|
||||
this.#resizer.classList.remove("dragging");
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
"pointermove",
|
||||
ev => {
|
||||
this.#resizeWithMetrics(
|
||||
startFirst + ev[axis] - startPos,
|
||||
containerSize,
|
||||
resizerSize
|
||||
);
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
window.addEventListener(
|
||||
"pointerup",
|
||||
() => {
|
||||
cancelDrag();
|
||||
this.#updateAria(
|
||||
containerSize,
|
||||
this.#resizer.getBoundingClientRect()[dimension]
|
||||
);
|
||||
this.#onResize?.();
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
window.addEventListener("blur", cancelDrag, { signal });
|
||||
};
|
||||
this.#resizer.addEventListener("pointerdown", this.#onPointerDown);
|
||||
|
||||
this.#onKeyDown = e => {
|
||||
let delta = 0;
|
||||
if (
|
||||
(this.#isRow && e.key === "ArrowLeft") ||
|
||||
(!this.#isRow && e.key === "ArrowUp")
|
||||
) {
|
||||
delta = -(e.shiftKey ? 50 : 10);
|
||||
} else if (
|
||||
(this.#isRow && e.key === "ArrowRight") ||
|
||||
(!this.#isRow && e.key === "ArrowDown")
|
||||
) {
|
||||
delta = e.shiftKey ? 50 : 10;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const dimension = this.#dimension();
|
||||
const inlineCurrent = parseFloat(this.#first.style.flexGrow);
|
||||
const currentFirst = isNaN(inlineCurrent)
|
||||
? this.#first.getBoundingClientRect()[dimension]
|
||||
: inlineCurrent;
|
||||
this.#resize(currentFirst + delta);
|
||||
this.#onResize?.();
|
||||
};
|
||||
this.#resizer.addEventListener("keydown", this.#onKeyDown);
|
||||
}
|
||||
}
|
||||
|
||||
export { SplitView };
|
||||
187
web/internal/tree_view.css
Normal file
187
web/internal/tree_view.css
Normal file
@@ -0,0 +1,187 @@
|
||||
/* Copyright 2026 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
#tree.loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#tree {
|
||||
padding: 8px 12px;
|
||||
background: var(--surface-bg);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
min-height: 60px;
|
||||
|
||||
.node {
|
||||
display: block;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.key {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: var(--muted-color);
|
||||
}
|
||||
|
||||
[role="button"] {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
font-size: 0.7em;
|
||||
color: var(--muted-color);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
[role="group"] {
|
||||
padding-left: 20px;
|
||||
border-left: 1px dashed var(--border-subtle-color);
|
||||
margin-left: 2px;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ref {
|
||||
color: var(--ref-color);
|
||||
cursor: pointer;
|
||||
text-decoration: underline dotted;
|
||||
|
||||
&:hover {
|
||||
color: var(--ref-hover-color);
|
||||
}
|
||||
}
|
||||
|
||||
.str-value {
|
||||
color: var(--string-color);
|
||||
white-space: preserve;
|
||||
}
|
||||
|
||||
.num-value {
|
||||
color: var(--number-color);
|
||||
}
|
||||
|
||||
.bool-value {
|
||||
color: var(--bool-color);
|
||||
}
|
||||
|
||||
.null-value {
|
||||
color: var(--null-color);
|
||||
}
|
||||
|
||||
.name-value {
|
||||
color: var(--name-color);
|
||||
}
|
||||
|
||||
.bracket {
|
||||
color: var(--muted-color);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: light-dark(#444, #bbb);
|
||||
}
|
||||
}
|
||||
|
||||
.stream-label {
|
||||
color: var(--stream-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
[role="status"] {
|
||||
color: var(--muted-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
[role="alert"] {
|
||||
color: var(--changed-color);
|
||||
}
|
||||
|
||||
.bytes-content {
|
||||
padding-left: 20px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 1em;
|
||||
opacity: 0.85;
|
||||
color: var(--string-color);
|
||||
}
|
||||
|
||||
.bytes-hex {
|
||||
font-family: monospace;
|
||||
color: var(--bool-color);
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
max-width: 40%;
|
||||
height: auto;
|
||||
image-rendering: pixelated;
|
||||
border: 1px solid var(--border-subtle-color);
|
||||
}
|
||||
|
||||
.token-cmd {
|
||||
color: var(--accent-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.token-num {
|
||||
color: var(--number-color);
|
||||
}
|
||||
|
||||
.token-str {
|
||||
color: var(--string-color);
|
||||
}
|
||||
|
||||
.token-name {
|
||||
color: var(--name-color);
|
||||
}
|
||||
|
||||
.token-bool {
|
||||
color: var(--bool-color);
|
||||
}
|
||||
|
||||
.token-null {
|
||||
color: var(--null-color);
|
||||
}
|
||||
|
||||
.token-ref {
|
||||
color: var(--ref-color);
|
||||
}
|
||||
|
||||
.token-array,
|
||||
.token-dict {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Cap height when a MultilineView is embedded in the tree. */
|
||||
.mlc-scroll {
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Content-stream line styles. */
|
||||
.content-stm-instruction {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
padding-inline-start: 0.5em;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.raw-bytes-stream {
|
||||
color: var(--string-color);
|
||||
}
|
||||
983
web/internal/tree_view.js
Normal file
983
web/internal/tree_view.js
Normal file
@@ -0,0 +1,983 @@
|
||||
/* Copyright 2026 Mozilla Foundation
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { MultilineView } from "./multiline_view.js";
|
||||
|
||||
const ARROW_COLLAPSED = "▶";
|
||||
const ARROW_EXPANDED = "▼";
|
||||
|
||||
// Matches indirect object references such as "10 0 R".
|
||||
const REF_RE = /^\d+ \d+ R$/;
|
||||
|
||||
/**
|
||||
* Renders and manages the PDF internal structure tree.
|
||||
*
|
||||
* @param {HTMLElement} treeEl
|
||||
* @param {object} options
|
||||
* @param {Function} options.onMarkLoading Called with +1/-1 to track
|
||||
* in-flight requests.
|
||||
*/
|
||||
class TreeView {
|
||||
#treeEl;
|
||||
|
||||
#onMarkLoading;
|
||||
|
||||
// Cache for getRawData results, keyed by "num:gen". Cleared on each new
|
||||
// document.
|
||||
#refCache = new Map();
|
||||
|
||||
constructor(treeEl, { onMarkLoading }) {
|
||||
this.#treeEl = treeEl;
|
||||
this.#onMarkLoading = onMarkLoading;
|
||||
this.#setupKeyboardNav();
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
/**
|
||||
* Fetch and render a tree for the given ref/page from doc.
|
||||
* @param {{ ref?: object, page?: number }} data
|
||||
* @param {string|null} rootLabel
|
||||
* @param {PDFDocumentProxy} doc
|
||||
*/
|
||||
async load(data, rootLabel, doc) {
|
||||
this.#treeEl.classList.add("loading");
|
||||
this.#onMarkLoading(1);
|
||||
try {
|
||||
const rootNode = this.#renderNode(
|
||||
rootLabel,
|
||||
await doc.getRawData(data),
|
||||
doc
|
||||
);
|
||||
this.#treeEl.replaceChildren(rootNode);
|
||||
rootNode.querySelector("[role='button']")?.click();
|
||||
const firstTreeItem = this.#treeEl.querySelector("[role='treeitem']");
|
||||
if (firstTreeItem) {
|
||||
firstTreeItem.tabIndex = 0;
|
||||
}
|
||||
} finally {
|
||||
this.#treeEl.classList.remove("loading");
|
||||
this.#onMarkLoading(-1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Append a role=alert error node to the tree element. */
|
||||
showError(message) {
|
||||
this.#treeEl.append(this.#makeErrorNode(message));
|
||||
}
|
||||
|
||||
/** Clear the ref cache (call when a new document is opened). */
|
||||
clearCache() {
|
||||
this.#refCache.clear();
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
#moveFocus(from, to) {
|
||||
if (!to) {
|
||||
return;
|
||||
}
|
||||
if (from) {
|
||||
from.tabIndex = -1;
|
||||
}
|
||||
to.tabIndex = 0;
|
||||
to.focus();
|
||||
}
|
||||
|
||||
#getVisibleItems() {
|
||||
return Array.from(
|
||||
this.#treeEl.querySelectorAll("[role='treeitem']")
|
||||
).filter(item => {
|
||||
let el = item.parentElement;
|
||||
while (el && el !== this.#treeEl) {
|
||||
if (el.role === "group" && el.classList.contains("hidden")) {
|
||||
return false;
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
#makeErrorNode(message) {
|
||||
const el = document.createElement("div");
|
||||
el.role = "alert";
|
||||
el.textContent = `Error: ${message}`;
|
||||
return el;
|
||||
}
|
||||
|
||||
#setupKeyboardNav() {
|
||||
this.#treeEl.addEventListener("keydown", e => {
|
||||
const { key } = e;
|
||||
if (
|
||||
key !== "ArrowDown" &&
|
||||
key !== "ArrowUp" &&
|
||||
key !== "ArrowRight" &&
|
||||
key !== "ArrowLeft" &&
|
||||
key !== "Home" &&
|
||||
key !== "End"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const focused =
|
||||
document.activeElement instanceof HTMLElement &&
|
||||
this.#treeEl.contains(document.activeElement)
|
||||
? document.activeElement
|
||||
: null;
|
||||
|
||||
// ArrowRight/Left operate on the focused treeitem directly without
|
||||
// needing a full list of visible items.
|
||||
if (key === "ArrowRight" || key === "ArrowLeft") {
|
||||
if (!focused || focused.role !== "treeitem") {
|
||||
return;
|
||||
}
|
||||
if (key === "ArrowRight") {
|
||||
// Find the toggle button inside this treeitem (not inside a child
|
||||
// group).
|
||||
const toggle = focused.querySelector(":scope > [role='button']");
|
||||
if (!toggle) {
|
||||
return;
|
||||
}
|
||||
if (toggle.ariaExpanded === "false") {
|
||||
toggle.click();
|
||||
} else {
|
||||
// Already expanded — move to first child treeitem.
|
||||
const group = focused.querySelector(
|
||||
":scope > [role='group']:not(.hidden)"
|
||||
);
|
||||
const firstChild = group?.querySelector("[role='treeitem']");
|
||||
this.#moveFocus(focused, firstChild);
|
||||
}
|
||||
} else {
|
||||
// Collapsed or no children — move to parent treeitem.
|
||||
const toggle = focused.querySelector(":scope > [role='button']");
|
||||
if (toggle?.ariaExpanded === "true") {
|
||||
toggle.click();
|
||||
} else {
|
||||
const parentGroup = focused.closest("[role='group']");
|
||||
const parentItem = parentGroup?.closest("[role='treeitem']");
|
||||
this.#moveFocus(focused, parentItem);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ArrowDown/Up/Home/End need the full ordered list of visible treeitems.
|
||||
const visibleItems = this.#getVisibleItems();
|
||||
if (visibleItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
const idx = visibleItems.indexOf(focused);
|
||||
|
||||
if (key === "ArrowDown") {
|
||||
const next = visibleItems[idx >= 0 ? idx + 1 : 0];
|
||||
this.#moveFocus(focused, next);
|
||||
} else if (key === "ArrowUp") {
|
||||
const prev = idx >= 0 ? visibleItems[idx - 1] : visibleItems.at(-1);
|
||||
this.#moveFocus(focused, prev);
|
||||
} else if (key === "Home") {
|
||||
const first = visibleItems[0];
|
||||
if (first !== focused) {
|
||||
this.#moveFocus(focused, first);
|
||||
}
|
||||
} else if (key === "End") {
|
||||
const last = visibleItems.at(-1);
|
||||
if (last !== focused) {
|
||||
this.#moveFocus(focused, last);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a bare div.node treeitem with an optional "key: " prefix. */
|
||||
#makeNodeEl(key) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "node";
|
||||
node.role = "treeitem";
|
||||
node.tabIndex = -1;
|
||||
if (key !== null) {
|
||||
node.append(
|
||||
this.#makeSpan("key", key),
|
||||
this.#makeSpan("separator", ": ")
|
||||
);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one key/value pair as a <div class="node">.
|
||||
* @param {string|null} key Dict key, array index, or null for root.
|
||||
* @param {*} value
|
||||
* @param {PDFDocumentProxy} doc
|
||||
*/
|
||||
#renderNode(key, value, doc) {
|
||||
const node = this.#makeNodeEl(key);
|
||||
node.append(this.#renderValue(value, doc));
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate a container element with the direct children of a value.
|
||||
* Used both by renderValue (inside expandables) and renderRef (directly
|
||||
* into the ref's children container, avoiding an extra toggle level).
|
||||
*/
|
||||
#buildChildren(value, doc, container) {
|
||||
if (this.#isPSFunction(value)) {
|
||||
for (const [k, v] of Object.entries(value.dict)) {
|
||||
container.append(this.#renderNode(k, v, doc));
|
||||
}
|
||||
const srcNode = this.#makeNodeEl("source");
|
||||
const srcLabel = `[PostScript, ${value.psLines.length} lines]`;
|
||||
const srcLabelEl = this.#makeSpan("stream-label", srcLabel);
|
||||
srcNode.append(
|
||||
this.#makeExpandable(srcLabelEl, srcLabel, c =>
|
||||
this.#buildPSFunctionPanel(value, c, srcLabelEl)
|
||||
)
|
||||
);
|
||||
container.append(srcNode);
|
||||
if (value.jsCode !== null) {
|
||||
const jsNode = this.#makeNodeEl("js");
|
||||
const jsLabel = "[JS equivalent]";
|
||||
const jsLabelEl = this.#makeSpan("stream-label", jsLabel);
|
||||
jsNode.append(
|
||||
this.#makeExpandable(jsLabelEl, jsLabel, c =>
|
||||
this.#buildJSCodePanel(value.jsCode, c)
|
||||
)
|
||||
);
|
||||
container.append(jsNode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.#isStream(value)) {
|
||||
for (const [k, v] of Object.entries(value.dict)) {
|
||||
container.append(this.#renderNode(k, v, doc));
|
||||
}
|
||||
if (this.#isImageStream(value)) {
|
||||
container.append(this.#renderImageData(value.imageData));
|
||||
} else if (this.#isFormXObjectStream(value)) {
|
||||
const contentNode = this.#makeNodeEl("content");
|
||||
const csLabel = `[Content Stream, ${value.instructions.length} instructions]`;
|
||||
const csLabelEl = this.#makeSpan("stream-label", csLabel);
|
||||
contentNode.append(
|
||||
this.#makeExpandable(csLabelEl, csLabel, c =>
|
||||
this.#buildContentStreamPanel(value, c, csLabelEl)
|
||||
)
|
||||
);
|
||||
container.append(contentNode);
|
||||
} else {
|
||||
const byteNode = this.#makeNodeEl("bytes");
|
||||
byteNode.append(
|
||||
this.#makeSpan("stream-label", `<${value.bytes.length} raw bytes>`)
|
||||
);
|
||||
container.append(byteNode);
|
||||
|
||||
const bytesContentEl = document.createElement("div");
|
||||
bytesContentEl.className = "bytes-content";
|
||||
bytesContentEl.append(this.#formatBytes(value.bytes));
|
||||
container.append(bytesContentEl);
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
value.forEach((v, i) =>
|
||||
container.append(this.#renderNode(String(i), v, doc))
|
||||
);
|
||||
} else if (value !== null && typeof value === "object") {
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
container.append(this.#renderNode(k, v, doc));
|
||||
}
|
||||
} else {
|
||||
container.append(this.#renderNode(null, value, doc));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single content-stream token as a styled span.
|
||||
*/
|
||||
#renderToken(token) {
|
||||
if (!token) {
|
||||
return this.#makeSpan("token-null", "null");
|
||||
}
|
||||
switch (token.type) {
|
||||
case "cmd":
|
||||
return this.#makeSpan("token-cmd", token.value);
|
||||
case "name":
|
||||
return this.#makeSpan("token-name", "/" + token.value);
|
||||
case "ref":
|
||||
return this.#makeSpan("token-ref", `${token.num} ${token.gen} R`);
|
||||
case "number":
|
||||
return this.#makeSpan("token-num", String(token.value));
|
||||
case "string":
|
||||
return this.#makeSpan("token-str", JSON.stringify(token.value));
|
||||
case "boolean":
|
||||
return this.#makeSpan("token-bool", String(token.value));
|
||||
case "null":
|
||||
return this.#makeSpan("token-null", "null");
|
||||
case "array": {
|
||||
const span = document.createElement("span");
|
||||
span.className = "token-array";
|
||||
span.append(this.#makeSpan("bracket", "["));
|
||||
for (const item of token.value) {
|
||||
span.append(document.createTextNode(" "));
|
||||
span.append(this.#renderToken(item));
|
||||
}
|
||||
span.append(document.createTextNode(" "));
|
||||
span.append(this.#makeSpan("bracket", "]"));
|
||||
return span;
|
||||
}
|
||||
case "brace":
|
||||
return this.#makeSpan("bracket", token.value);
|
||||
case "dict": {
|
||||
const span = document.createElement("span");
|
||||
span.className = "token-dict";
|
||||
span.append(this.#makeSpan("bracket", "<<"));
|
||||
for (const [k, v] of Object.entries(token.value)) {
|
||||
span.append(document.createTextNode(" "));
|
||||
span.append(this.#makeSpan("token-name", `/${k}`));
|
||||
span.append(document.createTextNode(" "));
|
||||
span.append(this.#renderToken(v));
|
||||
}
|
||||
span.append(document.createTextNode(" "));
|
||||
span.append(this.#makeSpan("bracket", ">>"));
|
||||
return span;
|
||||
}
|
||||
default:
|
||||
return this.#makeSpan(
|
||||
"token-unknown",
|
||||
String(token.value ?? token.type)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the plain-text representation of a token (mirrors #renderToken).
|
||||
* Used to build searchable strings for every instruction.
|
||||
*/
|
||||
#tokenToText(token) {
|
||||
if (!token) {
|
||||
return "null";
|
||||
}
|
||||
switch (token.type) {
|
||||
case "cmd":
|
||||
return token.value;
|
||||
case "name":
|
||||
return "/" + token.value;
|
||||
case "ref":
|
||||
return `${token.num} ${token.gen} R`;
|
||||
case "number":
|
||||
return String(token.value);
|
||||
case "string":
|
||||
return JSON.stringify(token.value);
|
||||
case "boolean":
|
||||
return String(token.value);
|
||||
case "null":
|
||||
return "null";
|
||||
case "brace":
|
||||
return token.value;
|
||||
case "array":
|
||||
return `[ ${token.value.map(t => this.#tokenToText(t)).join(" ")} ]`;
|
||||
case "dict": {
|
||||
const inner = Object.entries(token.value)
|
||||
.map(([k, v]) => `/${k} ${this.#tokenToText(v)}`)
|
||||
.join(" ");
|
||||
return `<< ${inner} >>`;
|
||||
}
|
||||
default:
|
||||
return String(token.value ?? token.type);
|
||||
}
|
||||
}
|
||||
|
||||
#buildInstructionLines(val, container, actions = null) {
|
||||
const { instructions, cmdNames } = val;
|
||||
const total = instructions.length;
|
||||
|
||||
// Pre-compute indentation depth for every instruction so that any
|
||||
// slice [from, to) can be rendered without replaying from the start.
|
||||
const depths = new Int32Array(total);
|
||||
let d = 0;
|
||||
for (let i = 0; i < total; i++) {
|
||||
const cmd = instructions[i].cmd;
|
||||
if (cmd === "ET" || cmd === "Q" || cmd === "EMC") {
|
||||
d = Math.max(0, d - 1);
|
||||
}
|
||||
depths[i] = d;
|
||||
if (cmd === "BT" || cmd === "q" || cmd === "BDC") {
|
||||
d++;
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-compute a plain-text string per instruction for searching.
|
||||
const instrTexts = instructions.map(instr => {
|
||||
const parts = instr.args.map(t => this.#tokenToText(t));
|
||||
if (instr.cmd !== null) {
|
||||
parts.push(instr.cmd);
|
||||
}
|
||||
return parts.join(" ");
|
||||
});
|
||||
|
||||
const mc = new MultilineView({
|
||||
total,
|
||||
lineClass: "content-stream",
|
||||
getText: i => instrTexts[i],
|
||||
actions,
|
||||
makeLineEl: (i, isHighlighted) => {
|
||||
const line = document.createElement("div");
|
||||
line.className = "content-stm-instruction";
|
||||
if (isHighlighted) {
|
||||
line.classList.add("mlc-match");
|
||||
}
|
||||
// Wrap the instruction content so that indentation shifts the tokens.
|
||||
const content = document.createElement("span");
|
||||
if (depths[i] > 0) {
|
||||
content.style.paddingInlineStart = `${depths[i] * 1.5}em`;
|
||||
}
|
||||
const instr = instructions[i];
|
||||
for (const arg of instr.args) {
|
||||
content.append(this.#renderToken(arg));
|
||||
content.append(document.createTextNode(" "));
|
||||
}
|
||||
if (instr.cmd !== null) {
|
||||
const cmdEl = this.#makeSpan("token-cmd", instr.cmd);
|
||||
const opsName = cmdNames[instr.cmd];
|
||||
if (opsName) {
|
||||
cmdEl.title = opsName;
|
||||
}
|
||||
content.append(cmdEl);
|
||||
}
|
||||
line.append(content);
|
||||
return line;
|
||||
},
|
||||
});
|
||||
container.append(mc.element);
|
||||
return mc;
|
||||
}
|
||||
|
||||
// Fills container with a raw-bytes virtual-scroll panel.
|
||||
#buildRawBytesPanel(rawBytes, container, actions = null) {
|
||||
const lines = rawBytes.split(/\r?\n|\r/);
|
||||
if (lines.at(-1) === "") {
|
||||
lines.pop();
|
||||
}
|
||||
const mc = new MultilineView({
|
||||
total: lines.length,
|
||||
lineClass: "content-stream raw-bytes-stream",
|
||||
getText: i => lines[i],
|
||||
actions,
|
||||
makeLineEl: (i, isHighlighted) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "content-stm-instruction";
|
||||
if (isHighlighted) {
|
||||
el.classList.add("mlc-match");
|
||||
}
|
||||
el.append(this.#formatBytes(lines[i]));
|
||||
return el;
|
||||
},
|
||||
});
|
||||
container.append(mc.element);
|
||||
return mc;
|
||||
}
|
||||
|
||||
// Creates a "Parsed" toggle button. aria-pressed=true means the parsed view
|
||||
// is currently active; clicking switches to the other view.
|
||||
#makeParseToggleBtn(isParsed, onToggle) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "mlc-nav-button";
|
||||
btn.textContent = "Parsed";
|
||||
btn.ariaPressed = String(isParsed);
|
||||
btn.title = isParsed ? "Show raw bytes" : "Show parsed instructions";
|
||||
btn.addEventListener("click", onToggle);
|
||||
return btn;
|
||||
}
|
||||
|
||||
// Fills container with a PostScript source panel (indented, token-coloured).
|
||||
#buildPSSourcePanel(psLines, container, actions = null) {
|
||||
const mc = new MultilineView({
|
||||
total: psLines.length,
|
||||
lineClass: "content-stream ps-source-stream",
|
||||
getText: i => {
|
||||
const { tokens } = psLines[i];
|
||||
return tokens.map(t => this.#tokenToText(t)).join(" ");
|
||||
},
|
||||
actions,
|
||||
makeLineEl: (i, isHighlighted) => {
|
||||
const line = document.createElement("div");
|
||||
line.className = "content-stm-instruction";
|
||||
if (isHighlighted) {
|
||||
line.classList.add("mlc-match");
|
||||
}
|
||||
const content = document.createElement("span");
|
||||
const { indent, tokens } = psLines[i];
|
||||
if (indent > 0) {
|
||||
content.style.paddingInlineStart = `${indent * 1.5}em`;
|
||||
}
|
||||
for (let j = 0; j < tokens.length; j++) {
|
||||
if (j > 0) {
|
||||
content.append(document.createTextNode(" "));
|
||||
}
|
||||
content.append(this.#renderToken(tokens[j]));
|
||||
}
|
||||
line.append(content);
|
||||
return line;
|
||||
},
|
||||
});
|
||||
container.append(mc.element);
|
||||
return mc;
|
||||
}
|
||||
|
||||
// Fills container with a JS code panel (plain monospace lines).
|
||||
#buildJSCodePanel(jsCode, container, actions = null) {
|
||||
const lines = jsCode.split("\n");
|
||||
while (lines.at(-1) === "") {
|
||||
lines.pop();
|
||||
}
|
||||
const mc = new MultilineView({
|
||||
total: lines.length,
|
||||
lineClass: "content-stream js-code-stream",
|
||||
getText: i => lines[i],
|
||||
actions,
|
||||
makeLineEl: (i, isHighlighted) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "content-stm-instruction";
|
||||
if (isHighlighted) {
|
||||
el.classList.add("mlc-match");
|
||||
}
|
||||
el.append(document.createTextNode(lines[i]));
|
||||
return el;
|
||||
},
|
||||
});
|
||||
container.append(mc.element);
|
||||
return mc;
|
||||
}
|
||||
|
||||
// PS source panel with parsed/raw toggle and an expandable JS equivalent.
|
||||
#buildPSFunctionPanel(val, container, labelEl = null) {
|
||||
let isParsed = true;
|
||||
let currentPanel = null;
|
||||
const rawLines = val.source.split(/\r?\n|\r/);
|
||||
if (rawLines.at(-1) === "") {
|
||||
rawLines.pop();
|
||||
}
|
||||
const parsedLabel = `[PostScript, ${val.psLines.length} lines]`;
|
||||
const rawLabel = `[PostScript, ${rawLines.length} raw lines]`;
|
||||
|
||||
const rebuild = () => {
|
||||
currentPanel?.destroy();
|
||||
currentPanel = null;
|
||||
container.replaceChildren();
|
||||
if (labelEl) {
|
||||
labelEl.textContent = isParsed ? parsedLabel : rawLabel;
|
||||
}
|
||||
const btn = this.#makeParseToggleBtn(isParsed, () => {
|
||||
isParsed = !isParsed;
|
||||
rebuild();
|
||||
});
|
||||
currentPanel = isParsed
|
||||
? this.#buildPSSourcePanel(val.psLines, container, btn)
|
||||
: this.#buildRawBytesPanel(val.source, container, btn);
|
||||
};
|
||||
|
||||
rebuild();
|
||||
}
|
||||
|
||||
// Fills container with the content stream panel (parsed or raw), with a
|
||||
// toggle button in the toolbar that swaps the view in-place.
|
||||
#buildContentStreamPanel(val, container, labelEl = null) {
|
||||
let isParsed = true;
|
||||
let currentPanel = null;
|
||||
const rawBytes = val.rawBytes ?? val.bytes;
|
||||
const rawLines = rawBytes ? rawBytes.split(/\r?\n|\r/) : [];
|
||||
if (rawLines.at(-1) === "") {
|
||||
rawLines.pop();
|
||||
}
|
||||
const parsedLabel = `[Content Stream, ${val.instructions.length} instructions]`;
|
||||
const rawLabel = `[Content Stream, ${rawLines.length} lines]`;
|
||||
|
||||
const rebuild = () => {
|
||||
currentPanel?.destroy();
|
||||
currentPanel = null;
|
||||
container.replaceChildren();
|
||||
if (labelEl) {
|
||||
labelEl.textContent = isParsed ? parsedLabel : rawLabel;
|
||||
}
|
||||
const btn = this.#makeParseToggleBtn(isParsed, () => {
|
||||
isParsed = !isParsed;
|
||||
rebuild();
|
||||
});
|
||||
currentPanel = isParsed
|
||||
? this.#buildInstructionLines(val, container, btn)
|
||||
: this.#buildRawBytesPanel(rawBytes, container, btn);
|
||||
};
|
||||
|
||||
rebuild();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render Page content stream as an expandable panel with a Parsed/Raw toggle.
|
||||
*/
|
||||
#renderContentStream(val) {
|
||||
const label = `[Content Stream, ${val.instructions.length} instructions]`;
|
||||
const labelEl = this.#makeSpan("stream-label", label);
|
||||
return this.#makeExpandable(labelEl, label, container =>
|
||||
this.#buildContentStreamPanel(val, container, labelEl)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a value inline (primitive) or as an expandable widget.
|
||||
* Returns a Node or DocumentFragment suitable for appendChild().
|
||||
*/
|
||||
#renderValue(value, doc) {
|
||||
// Ref string ("10 0 R") – lazy expandable via getRawData()
|
||||
if (typeof value === "string" && REF_RE.test(value)) {
|
||||
return this.#renderRef(value, doc);
|
||||
}
|
||||
|
||||
// Ref object { num, gen } – lazy expandable via getRawData()
|
||||
if (this.#isRefObject(value)) {
|
||||
return this.#renderRef(value, doc);
|
||||
}
|
||||
|
||||
// PDF Name → /Name
|
||||
if (this.#isPDFName(value)) {
|
||||
return this.#makeSpan("name-value", `/${value.name}`);
|
||||
}
|
||||
|
||||
// Content stream (Page Contents) → expandable with Parsed/Raw toggle
|
||||
if (this.#isContentStream(value)) {
|
||||
return this.#renderContentStream(value);
|
||||
}
|
||||
|
||||
// PostScript Type 4 function stream
|
||||
if (this.#isPSFunction(value)) {
|
||||
return this.#renderExpandable(
|
||||
"[PostScript Function]",
|
||||
"stream-label",
|
||||
container => this.#buildChildren(value, doc, container)
|
||||
);
|
||||
}
|
||||
|
||||
// Stream → expandable showing dict entries + byte count or image preview
|
||||
if (this.#isStream(value)) {
|
||||
return this.#renderExpandable("[Stream]", "stream-label", container =>
|
||||
this.#buildChildren(value, doc, container)
|
||||
);
|
||||
}
|
||||
|
||||
// Plain object (dict)
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length === 0) {
|
||||
return this.#makeSpan("bracket", "{}");
|
||||
}
|
||||
return this.#renderExpandable(`{${keys.length}}`, "bracket", container =>
|
||||
this.#buildChildren(value, doc, container)
|
||||
);
|
||||
}
|
||||
|
||||
// Array
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return this.#makeSpan("bracket", "[]");
|
||||
}
|
||||
return this.#renderExpandable(`[${value.length}]`, "bracket", container =>
|
||||
this.#buildChildren(value, doc, container)
|
||||
);
|
||||
}
|
||||
|
||||
// Primitives
|
||||
if (typeof value === "string") {
|
||||
return this.#makeSpan("str-value", JSON.stringify(value));
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return this.#makeSpan("num-value", String(value));
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return this.#makeSpan("bool-value", String(value));
|
||||
}
|
||||
return this.#makeSpan("null-value", "null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a lazy-loading expand/collapse widget for a ref (string or object).
|
||||
* Results are cached in #refCache keyed by "num:gen".
|
||||
*/
|
||||
#renderRef(ref, doc) {
|
||||
// Derive the cache key and display label from whichever form we received.
|
||||
// String refs look like "10 0 R"; object refs are { num, gen }.
|
||||
let cacheKey, label;
|
||||
if (typeof ref === "string") {
|
||||
const parts = ref.split(" ");
|
||||
cacheKey = `${parts[0]}:${parts[1]}`;
|
||||
label = ref;
|
||||
} else {
|
||||
cacheKey = `${ref.num}:${ref.gen}`;
|
||||
label = this.#refLabel(ref);
|
||||
}
|
||||
return this.#makeExpandable(
|
||||
this.#makeSpan("ref", label),
|
||||
`reference ${label}`,
|
||||
childrenEl => {
|
||||
const spinner = document.createElement("div");
|
||||
spinner.role = "status";
|
||||
spinner.textContent = "Loading…";
|
||||
childrenEl.append(spinner);
|
||||
this.#onMarkLoading(1);
|
||||
if (!this.#refCache.has(cacheKey)) {
|
||||
this.#refCache.set(cacheKey, doc.getRawData({ ref }));
|
||||
}
|
||||
this.#refCache
|
||||
.get(cacheKey)
|
||||
.then(result => {
|
||||
childrenEl.replaceChildren();
|
||||
this.#buildChildren(result, doc, childrenEl);
|
||||
})
|
||||
.catch(err =>
|
||||
childrenEl.replaceChildren(this.#makeErrorNode(err.message))
|
||||
)
|
||||
.finally(() => this.#onMarkLoading(-1));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a shared expand/collapse widget.
|
||||
* labelEl is the element shown between the toggle arrow and the children.
|
||||
* ariaLabel is used for the toggle and group aria-labels.
|
||||
* onFirstOpen(childrenEl) is called once when first expanded (may be async).
|
||||
*/
|
||||
#makeExpandable(labelEl, ariaLabel, onFirstOpen) {
|
||||
const toggleEl = document.createElement("span");
|
||||
toggleEl.textContent = ARROW_COLLAPSED;
|
||||
toggleEl.role = "button";
|
||||
toggleEl.tabIndex = 0;
|
||||
toggleEl.ariaExpanded = "false";
|
||||
toggleEl.ariaLabel = `Expand ${ariaLabel}`;
|
||||
labelEl.ariaHidden = "true";
|
||||
|
||||
const childrenEl = document.createElement("div");
|
||||
childrenEl.className = "hidden";
|
||||
childrenEl.role = "group";
|
||||
childrenEl.ariaLabel = `Contents of ${ariaLabel}`;
|
||||
|
||||
let open = false,
|
||||
done = false;
|
||||
const toggle = () => {
|
||||
open = !open;
|
||||
toggleEl.textContent = open ? ARROW_EXPANDED : ARROW_COLLAPSED;
|
||||
toggleEl.ariaExpanded = String(open);
|
||||
childrenEl.classList.toggle("hidden", !open);
|
||||
if (open && !done) {
|
||||
done = true;
|
||||
onFirstOpen(childrenEl);
|
||||
}
|
||||
};
|
||||
toggleEl.addEventListener("click", toggle);
|
||||
toggleEl.addEventListener("keydown", e => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
});
|
||||
labelEl.addEventListener("click", toggle);
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
frag.append(toggleEl, labelEl, childrenEl);
|
||||
return frag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a synchronous expand/collapse widget.
|
||||
* @param {string} label Text shown on the collapsed line.
|
||||
* @param {string} labelClass CSS class for the label.
|
||||
* @param {Function} buildFn Called with (containerEl) on first open.
|
||||
*/
|
||||
#renderExpandable(label, labelClass, buildFn) {
|
||||
return this.#makeExpandable(
|
||||
this.#makeSpan(labelClass, label),
|
||||
label,
|
||||
buildFn
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render image data (RGBA Uint8ClampedArray) into a <canvas> node.
|
||||
*/
|
||||
#renderImageData({ width, height, data }) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "node";
|
||||
const keyEl = document.createElement("span");
|
||||
keyEl.className = "key";
|
||||
keyEl.textContent = "imageData";
|
||||
const sep = document.createElement("span");
|
||||
sep.className = "separator";
|
||||
sep.textContent = ": ";
|
||||
const info = document.createElement("span");
|
||||
info.className = "stream-label";
|
||||
info.textContent = `<${width}×${height}>`;
|
||||
node.append(keyEl, sep, info);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "image-preview";
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.style.width = `${width / dpr}px`;
|
||||
canvas.style.aspectRatio = `${width} / ${height}`;
|
||||
canvas.ariaLabel = `Image preview ${width}×${height}`;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const imgData = new ImageData(new Uint8ClampedArray(data), width, height);
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
node.append(canvas);
|
||||
return node;
|
||||
}
|
||||
|
||||
#isMostlyText(str) {
|
||||
let printable = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const c = str.charCodeAt(i);
|
||||
if (c >= 0x20 && c <= 0x7e) {
|
||||
printable++;
|
||||
}
|
||||
}
|
||||
return str.length > 0 && printable / str.length >= 0.8;
|
||||
}
|
||||
|
||||
#formatBytes(str) {
|
||||
const mostlyText = this.#isMostlyText(str);
|
||||
const frag = document.createDocumentFragment();
|
||||
|
||||
if (!mostlyText) {
|
||||
// Binary content: render every byte as hex in a single span.
|
||||
const span = document.createElement("span");
|
||||
span.className = "bytes-hex";
|
||||
const hexParts = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hexParts.push(
|
||||
str.charCodeAt(i).toString(16).toUpperCase().padStart(2, "0")
|
||||
);
|
||||
}
|
||||
span.textContent = hexParts.join("\u00B7\u200B");
|
||||
frag.append(span);
|
||||
return frag;
|
||||
}
|
||||
|
||||
// Text content: printable ASCII + 0x0A as-is, other bytes as hex spans.
|
||||
const isPrintable = c => (c >= 0x20 && c <= 0x7e) || c === 0x0a;
|
||||
let i = 0;
|
||||
while (i < str.length) {
|
||||
const code = str.charCodeAt(i);
|
||||
if (isPrintable(code)) {
|
||||
let run = "";
|
||||
while (i < str.length && isPrintable(str.charCodeAt(i))) {
|
||||
run += str[i++];
|
||||
}
|
||||
frag.append(document.createTextNode(run));
|
||||
} else {
|
||||
const span = document.createElement("span");
|
||||
span.className = "bytes-hex";
|
||||
const hexParts = [];
|
||||
while (i < str.length && !isPrintable(str.charCodeAt(i))) {
|
||||
hexParts.push(
|
||||
str.charCodeAt(i).toString(16).toUpperCase().padStart(2, "0")
|
||||
);
|
||||
i++;
|
||||
}
|
||||
span.textContent = hexParts.join("\u00B7\u200B");
|
||||
frag.append(span);
|
||||
}
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
|
||||
// Create a <span> with the given class and text content.
|
||||
#makeSpan(className, text) {
|
||||
const span = document.createElement("span");
|
||||
span.className = className;
|
||||
span.textContent = text;
|
||||
return span;
|
||||
}
|
||||
|
||||
#isPDFName(val) {
|
||||
return (
|
||||
val !== null &&
|
||||
typeof val === "object" &&
|
||||
!Array.isArray(val) &&
|
||||
typeof val.name === "string" &&
|
||||
Object.keys(val).length === 1
|
||||
);
|
||||
}
|
||||
|
||||
// Ref objects arrive as { num: N, gen: G } after structured clone.
|
||||
#isRefObject(val) {
|
||||
return (
|
||||
val !== null &&
|
||||
typeof val === "object" &&
|
||||
!Array.isArray(val) &&
|
||||
typeof val.num === "number" &&
|
||||
typeof val.gen === "number" &&
|
||||
Object.keys(val).length === 2
|
||||
);
|
||||
}
|
||||
|
||||
#refLabel(ref) {
|
||||
return ref.gen !== 0 ? `${ref.num}R${ref.gen}` : `${ref.num}R`;
|
||||
}
|
||||
|
||||
// Page content streams:
|
||||
// { contentStream: true, instructions, cmdNames, rawContents }.
|
||||
#isContentStream(val) {
|
||||
return (
|
||||
val !== null &&
|
||||
typeof val === "object" &&
|
||||
val.contentStream === true &&
|
||||
Array.isArray(val.instructions) &&
|
||||
Array.isArray(val.rawContents)
|
||||
);
|
||||
}
|
||||
|
||||
// Streams: { dict, bytes }, { dict, imageData },
|
||||
// or { dict, contentStream: true, instructions, cmdNames } (Form XObject).
|
||||
#isStream(val) {
|
||||
return (
|
||||
val !== null &&
|
||||
typeof val === "object" &&
|
||||
!Array.isArray(val) &&
|
||||
Object.hasOwn(val, "dict") &&
|
||||
(Object.hasOwn(val, "bytes") ||
|
||||
Object.hasOwn(val, "imageData") ||
|
||||
val.contentStream === true)
|
||||
);
|
||||
}
|
||||
|
||||
#isImageStream(val) {
|
||||
return this.#isStream(val) && Object.hasOwn(val, "imageData");
|
||||
}
|
||||
|
||||
#isFormXObjectStream(val) {
|
||||
return this.#isStream(val) && val.contentStream === true;
|
||||
}
|
||||
|
||||
// PostScript Type 4 function: { dict, psFunction: true, psLines, jsCode }.
|
||||
#isPSFunction(val) {
|
||||
return (
|
||||
val !== null &&
|
||||
typeof val === "object" &&
|
||||
!Array.isArray(val) &&
|
||||
Object.hasOwn(val, "dict") &&
|
||||
val.psFunction === true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { TreeView };
|
||||
Reference in New Issue
Block a user