mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-04-09 23:04:02 +02:00
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:
parent
394727a28c
commit
cf3b3fa900
@ -792,7 +792,9 @@ class CanvasGraphics {
|
||||
return i;
|
||||
}
|
||||
if (stepper.shouldSkip(i)) {
|
||||
i++;
|
||||
if (++i === argsArrayLen) {
|
||||
return i;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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
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);
|
||||
}
|
||||
251
web/internal/font_view.js
Normal file
251
web/internal/font_view.js
Normal 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 };
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user