Add the possibility to debug only text rendering by filtering the op list.

And a specific view for inspecting font information and the text layer on top of the canvas.
This commit is contained in:
calixteman 2026-03-17 11:44:36 +01:00 committed by Calixte Denizet
parent 394727a28c
commit cf3b3fa900
11 changed files with 1001 additions and 60 deletions

View File

@ -792,7 +792,9 @@ class CanvasGraphics {
return i;
}
if (stepper.shouldSkip(i)) {
i++;
if (++i === argsArrayLen) {
return i;
}
continue;
}
}

View File

@ -86,10 +86,18 @@ class CanvasContextDetailsView {
// 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.
@ -202,6 +210,7 @@ class CanvasContextDetailsView {
* Shows the panel if it was hidden.
*/
build() {
this.#frozen = false;
this.#panel.hidden = false;
this.#panel.replaceChildren();
this.#gfxStateValueElements.clear();
@ -361,10 +370,10 @@ class CanvasContextDetailsView {
}
}
// Update DOM for a live setter — skipped when the user is browsing a saved
// state so that live updates don't overwrite the frozen view.
// 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.#ctxStackViewIdx.get(label) !== null) {
if (this.#frozen || this.#ctxStackViewIdx.get(label) !== null) {
return;
}
this.#applyPropEl(label, prop, value);
@ -388,6 +397,9 @@ class CanvasContextDetailsView {
// 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;

View File

@ -15,6 +15,7 @@
@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);
@ -35,6 +36,7 @@
--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);
@ -285,8 +287,8 @@ body:has(#debug-view:not([hidden])) {
color: var(--muted-color);
font-style: italic;
}
#debug-button,
#debug-back-button {
#tree-button,
#debug-button {
padding: 4px 12px;
border-radius: 3px;
border: 1px solid var(--input-border-color);
@ -299,4 +301,9 @@ body:has(#debug-view:not([hidden])) {
&:hover {
background: var(--button-hover-bg);
}
&:disabled {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
}

View File

@ -20,6 +20,7 @@ limitations under the License.
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PDF.js — Debugging tools</title>
<link rel="stylesheet" href="debugger.css" />
<link rel="stylesheet" href="../text_layer_builder.css" />
</head>
<body>
<div id="header">
@ -34,8 +35,8 @@ limitations under the License.
<span id="goto-input-hint" class="sr-only">
Enter a page number (e.g. 5), a reference as numR (e.g. 10R) or numRgen (e.g. 10R2). Press Enter to navigate.
</span>
<button id="debug-button" hidden>Debug page</button>
<button id="debug-back-button" hidden>← Back to tree</button>
<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">
@ -55,14 +56,41 @@ limitations under the License.
<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">
<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>
<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 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">

View File

@ -67,8 +67,8 @@ function markLoading(delta) {
}
// Cache frequently accessed elements.
const treeButton = document.getElementById("tree-button");
const debugButton = document.getElementById("debug-button");
const debugBackButton = document.getElementById("debug-back-button");
const debugViewEl = document.getElementById("debug-view");
const treeEl = document.getElementById("tree");
const statusEl = document.getElementById("status");
@ -81,8 +81,7 @@ const treeView = new TreeView(treeEl, { onMarkLoading: markLoading });
async function loadTree(data, rootLabel = null) {
currentPage = typeof data.page === "number" ? data.page : null;
debugButton.hidden = currentPage === null;
debugBackButton.hidden = true;
debugButton.disabled = currentPage === null;
pageView.reset();
debugViewEl.hidden = true;
treeEl.hidden = false;
@ -113,6 +112,7 @@ async function openDocument(source, name) {
wasmUrl: "../web/wasm/",
useWorkerFetch: true,
pdfBug: true,
fontExtraProperties: true,
CanvasFactory: pageView.DebugCanvasFactory,
});
loadingTask.onPassword = (updateCallback, reason) => {
@ -230,9 +230,17 @@ gotoInput.addEventListener("keydown", async ({ key, target }) => {
return;
}
target.removeAttribute("aria-invalid");
await (result.page !== undefined
? loadTree({ page: result.page })
: loadTree({ ref: result.ref }));
// 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 }) => {
@ -242,14 +250,14 @@ gotoInput.addEventListener("input", ({ target }) => {
});
debugButton.addEventListener("click", async () => {
debugButton.hidden = treeEl.hidden = true;
debugBackButton.hidden = debugViewEl.hidden = false;
treeEl.hidden = true;
debugViewEl.hidden = false;
// Only render if not already loaded for this page; re-entering from the
// back button keeps the existing debug state (op-list, canvas, breakpoints).
// tree button keeps the existing debug state (op-list, canvas, breakpoints).
await pageView.show(pdfDoc, currentPage);
});
debugBackButton.addEventListener("click", () => {
debugBackButton.hidden = debugViewEl.hidden = true;
debugButton.hidden = treeEl.hidden = false;
treeButton.addEventListener("click", () => {
debugViewEl.hidden = true;
treeEl.hidden = false;
});

View File

@ -23,6 +23,47 @@ 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,
@ -409,8 +450,17 @@ class DrawOpsView {
#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>
@ -444,22 +494,50 @@ class DrawOpsView {
load(opList, renderedPage) {
this.#renderedPage = renderedPage;
this.#opLines = [];
const opTexts = [];
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);
opTexts.push(text);
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: opList.fnArray.length,
getText: i => opTexts[i],
total: this.#visibleLines.length,
getText: i => this.#opTexts[+this.#visibleLines[i].dataset.opIdx],
makeLineEl: (i, isHighlighted) => {
this.#opLines[i].classList.toggle("mlc-match", isHighlighted);
return this.#opLines[i];
this.#visibleLines[i].classList.toggle("mlc-match", isHighlighted);
return this.#visibleLines[i];
},
});
multilineView.element.classList.add("op-list-panel-wrapper");
@ -469,7 +547,7 @@ class DrawOpsView {
multilineView.inner.addEventListener("keydown", e => {
const { key } = e;
const lines = this.#opLines;
const lines = this.#visibleLines;
if (!lines.length) {
return;
}
@ -504,7 +582,7 @@ class DrawOpsView {
}
});
this.#listPanelEl.replaceWith(multilineView.element);
anchor.replaceWith(multilineView.element);
this.#multilineView = multilineView;
}
@ -517,6 +595,8 @@ class DrawOpsView {
document.getElementById("op-list").replaceChildren();
this.#detailView.clear();
this.#opLines = [];
this.#opTexts = [];
this.#visibleLines = [];
this.#selectedLine = null;
this.#originalColors.clear();
this.#breakpoints.clear();
@ -529,7 +609,11 @@ class DrawOpsView {
}
this.#pausedAtIdx = i;
this.#opLines[i]?.classList.add("paused");
this.#multilineView?.scrollToLine(i);
// 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() {
@ -558,6 +642,8 @@ class DrawOpsView {
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");
@ -675,4 +761,4 @@ class DrawOpsView {
}
}
export { BreakpointType, DrawOpsView };
export { BreakpointType, DrawOpsView, TEXT_EXEC_OP_IDS, TEXT_OP_IDS };

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

@ -0,0 +1,164 @@
/* Copyright 2026 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#font-panel {
flex: 1 1 0;
overflow: auto;
min-width: 0;
min-height: 0;
background: var(--surface-bg);
border-radius: 4px;
border: 1px solid var(--border-color);
}
.font-list {
list-style: none;
margin: 0;
padding: 4px 0;
}
.font-item {
padding: 6px 10px;
border-bottom: 1px solid var(--border-subtle-color);
cursor: pointer;
&:last-child {
border-bottom: none;
}
&:hover {
background: var(--hover-bg);
}
&.selected {
background: color-mix(
in srgb,
var(--font-highlight-color, #0070c1) 12%,
transparent
);
}
}
.font-download-button {
box-sizing: border-box;
width: 24px;
height: 24px;
padding: 0;
border-radius: 3px;
border: 1px solid var(--input-border-color);
background: var(--button-bg);
color: inherit;
cursor: pointer;
&::before {
content: "";
display: block;
width: 14px;
height: 14px;
margin: auto;
background: currentColor;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z'/%3E%3Cpolyline points='17 21 17 13 7 13 7 21'/%3E%3Cpolyline points='7 3 7 8 15 8'/%3E%3C/svg%3E");
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
}
&:hover:not(:disabled) {
background: var(--button-hover-bg);
}
&:disabled {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
}
.font-name {
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.font-loaded-name {
font-size: 0.85em;
color: var(--muted-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}
.font-tags {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-top: 3px;
}
.font-tag {
font-size: 0.75em;
padding: 1px 5px;
border-radius: 3px;
background: var(--hover-bg);
color: var(--muted-color);
border: 1px solid var(--border-subtle-color);
}
.font-empty {
padding: 8px 10px;
color: var(--muted-color);
font-style: italic;
list-style: none;
}
#font-view-button {
font-family: Georgia, "Times New Roman", serif;
font-weight: bold;
font-style: normal;
}
.font-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-bottom: 1px solid var(--border-color);
}
.font-color-button {
box-sizing: border-box;
width: 24px;
height: 24px;
padding: 3px;
border-radius: 3px;
border: 1px solid var(--input-border-color);
background: var(--button-bg);
cursor: pointer;
&:hover {
background: var(--button-hover-bg);
}
}
.font-color-swatch {
display: block;
width: 100%;
height: 100%;
border-radius: 2px;
background: var(--font-highlight-color, #0070c1);
}

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

@ -0,0 +1,251 @@
/* 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(
/[^a-z0-9_-]/gi,
"_"
);
const blob = new Blob([font.data], { type: font.mimetype });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${name}.${ext}`;
a.click();
URL.revokeObjectURL(url);
});
toolbar.append(colorBtn, colorInput, downloadBtn);
return toolbar;
}
get element() {
return this.#container;
}
// Called by FontInspector.fontAdded whenever a font face is bound.
fontAdded(font) {
this.#fontMap.set(font.loadedName, font);
}
// Show the subset of known fonts that appear in the given op list.
// Uses setFont ops to determine which fonts are actually used on the page.
showForOpList({ fnArray, argsArray }, OPS) {
const usedNames = new Set();
for (let i = 0, len = fnArray.length; i < len; i++) {
if (fnArray[i] === OPS.setFont) {
usedNames.add(argsArray[i][0]);
}
}
const fonts = [];
for (const name of usedNames) {
const font = this.#fontMap.get(name);
if (font) {
fonts.push(font);
}
}
this.#render(fonts);
}
clear() {
this.#selectedName = null;
this.#downloadBtn.disabled = true;
this.#list.replaceChildren();
}
#render(fonts) {
if (fonts.length === 0) {
const li = document.createElement("li");
li.className = "font-empty";
li.textContent = "No fonts on this page.";
this.#list.replaceChildren(li);
return;
}
const frag = document.createDocumentFragment();
for (const font of fonts) {
const li = document.createElement("li");
li.className = "font-item";
li.dataset.loadedName = font.loadedName;
if (font.loadedName === this.#selectedName) {
li.classList.add("selected");
}
li.addEventListener("click", () => {
const next =
font.loadedName === this.#selectedName ? null : font.loadedName;
this.#selectedName = next;
for (const item of this.#list.querySelectorAll(".font-item")) {
item.classList.toggle("selected", item.dataset.loadedName === next);
}
const selectedFont = next ? this.#fontMap.get(next) : null;
this.#downloadBtn.disabled = !selectedFont?.data;
this.#onSelect?.(next);
});
const nameEl = document.createElement("div");
nameEl.className = "font-name";
nameEl.textContent = font.name || font.loadedName;
li.append(nameEl);
const tags = [];
const fmt = MIMETYPE_TO_FORMAT.get(font.mimetype);
if (fmt) {
tags.push(fmt);
}
if (font.isType3Font) {
tags.push("Type3");
}
if (font.bold) {
tags.push("Bold");
}
if (font.italic) {
tags.push("Italic");
}
if (font.vertical) {
tags.push("Vertical");
}
if (font.disableFontFace) {
tags.push("System");
}
if (font.missingFile) {
tags.push("Missing");
}
if (tags.length) {
const tagsEl = document.createElement("div");
tagsEl.className = "font-tags";
for (const tag of tags) {
const span = document.createElement("span");
span.className = "font-tag";
span.textContent = tag;
tagsEl.append(span);
}
li.append(tagsEl);
}
const loadedEl = document.createElement("div");
loadedEl.className = "font-loaded-name";
loadedEl.textContent = font.loadedName;
li.append(loadedEl);
frag.append(li);
}
this.#list.replaceChildren(frag);
}
}
export { FontView };

View File

@ -289,6 +289,39 @@ class MultilineView {
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) {
@ -302,10 +335,16 @@ class MultilineView {
if (this.#endIndex - this.#startIndex > MAX_RENDERED) {
const removeCount = this.#endIndex - this.#startIndex - MAX_RENDERED;
const heightBefore = this.#pre.scrollHeight;
for (let i = 0; i < removeCount; i++) {
this.#topSentinel.nextElementSibling?.remove();
this.#numCol.firstElementChild?.remove();
}
this.#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;
@ -329,10 +368,18 @@ class MultilineView {
// Trim from bottom if the window exceeds MAX_RENDERED.
if (this.#endIndex - this.#startIndex > MAX_RENDERED) {
const removeCount = this.#endIndex - this.#startIndex - MAX_RENDERED;
for (let i = 0; i < removeCount; i++) {
this.#bottomSentinel.previousElementSibling?.remove();
this.#numCol.lastElementChild?.remove();
}
this.#removeChildren(
this.#pre,
this.#bottomSentinel.previousElementSibling,
removeCount,
true
);
this.#removeChildren(
this.#numCol,
this.#numCol.lastElementChild,
removeCount,
true
);
this.#endIndex -= removeCount;
}
}

View File

@ -33,11 +33,16 @@
--spc-resizer-hover-color: var(--accent-color);
}
#render-panels {
/* instructionsSplit (spc-column) takes 70% of the width next to canvas. */
/* 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;
@ -63,16 +68,22 @@
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: center;
justify-content: space-between;
gap: 6px;
padding: 4px 8px;
border-bottom: 1px solid var(--border-color);
button {
padding: 1px 8px;
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);
@ -89,6 +100,66 @@
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 {
@ -111,6 +182,28 @@
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;

View File

@ -13,11 +13,25 @@
* limitations under the License.
*/
import { BreakpointType, DrawOpsView } from "./draw_ops_view.js";
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 {
@ -40,11 +54,22 @@ class ViewerStepper {
}
// Advance one instruction then pause again.
// In text-only mode, skip forward to the next text op.
stepNext() {
if (!this.#continueCallback) {
return;
}
this.nextBreakPoint = this.currentIdx + 1;
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();
@ -63,7 +88,9 @@ class ViewerStepper {
shouldSkip(i) {
return (
globalThis.StepperManager._breakpoints.get(i) === BreakpointType.SKIP
globalThis.StepperManager._breakpoints.get(i) === BreakpointType.SKIP ||
(globalThis.StepperManager._textOnly &&
!globalThis.StepperManager._isTextExecOp(i))
);
}
@ -136,10 +163,26 @@ class PageView {
#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)");
@ -161,6 +204,28 @@ class PageView {
}
);
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 = {
@ -169,6 +234,14 @@ class PageView {
},
_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;
},
@ -187,6 +260,13 @@ class PageView {
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");
@ -209,12 +289,14 @@ class PageView {
// 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();
@ -344,11 +426,20 @@ class PageView {
{ direction: "column", minSize: 40 }
);
// Outer row split: instructions column on the left, canvas on the right.
// 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,
document.getElementById("canvas-panel"),
{ direction: "row", minSize: 100, onResize: () => this.#renderCanvas() }
canvasFontSplit.element,
{ direction: "row", minSize: 100, onResize: () => this.#rerenderCanvas() }
);
const renderPanels = document.getElementById("render-panels");
@ -382,7 +473,7 @@ class PageView {
// Reset recorded bboxes so they get re-recorded for the modified op
// list.
this.#renderedPage.recordedBBoxes = null;
if (this.#opsView.breakpoints.size > 0) {
if (this.#textFilter || this.#opsView.breakpoints.size > 0) {
globalThis.StepperManager._active = new ViewerStepper(i =>
this.#onStepped(i)
);
@ -395,7 +486,83 @@ class PageView {
});
this.#continueButton.addEventListener("click", () => {
globalThis.StepperManager._active?.continueToBreakpoint();
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 => {
@ -441,9 +608,9 @@ class PageView {
);
}
#zoomRenderCanvas(newScale) {
// If zoomed again while a re-render is already running (not yet re-paused),
// pausedAtIdx is null but the active stepper still knows the target index.
// 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) {
@ -451,8 +618,7 @@ class PageView {
stepper.currentIdx >= 0 ? stepper.currentIdx : stepper.nextBreakPoint;
}
this.#clearPausedState();
this.#renderScale = newScale;
if (resumeAt !== null) {
if (resumeAt !== null || this.#textFilter) {
globalThis.StepperManager._active = new ViewerStepper(
i => this.#onStepped(i),
resumeAt
@ -461,12 +627,58 @@ class PageView {
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;
@ -549,6 +761,24 @@ class PageView {
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;
@ -624,6 +854,19 @@ class PageView {
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";