mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-07-14 11:05:52 +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;
|
return i;
|
||||||
}
|
}
|
||||||
if (stepper.shouldSkip(i)) {
|
if (stepper.shouldSkip(i)) {
|
||||||
i++;
|
if (++i === argsArrayLen) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,10 +86,18 @@ class CanvasContextDetailsView {
|
|||||||
// Map<label, {container, prevBtn, pos, nextBtn}> — stack-nav DOM elements.
|
// Map<label, {container, prevBtn, pos, nextBtn}> — stack-nav DOM elements.
|
||||||
#gfxStateNavElements = new Map();
|
#gfxStateNavElements = new Map();
|
||||||
|
|
||||||
|
// When true, suppress live DOM updates; build() re-reads state and resets it.
|
||||||
|
#frozen = false;
|
||||||
|
|
||||||
constructor(panelEl) {
|
constructor(panelEl) {
|
||||||
this.#panel = 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.
|
* Wrap a CanvasRenderingContext2D to track its graphics state.
|
||||||
* Returns a Proxy that keeps internal state in sync and updates the DOM.
|
* 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.
|
* Shows the panel if it was hidden.
|
||||||
*/
|
*/
|
||||||
build() {
|
build() {
|
||||||
|
this.#frozen = false;
|
||||||
this.#panel.hidden = false;
|
this.#panel.hidden = false;
|
||||||
this.#panel.replaceChildren();
|
this.#panel.replaceChildren();
|
||||||
this.#gfxStateValueElements.clear();
|
this.#gfxStateValueElements.clear();
|
||||||
@ -361,10 +370,10 @@ class CanvasContextDetailsView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update DOM for a live setter — skipped when the user is browsing a saved
|
// Update DOM for a live setter — skipped when frozen (continuous execution)
|
||||||
// state so that live updates don't overwrite the frozen view.
|
// or when the user is browsing a saved state.
|
||||||
#updatePropEl(label, prop, value) {
|
#updatePropEl(label, prop, value) {
|
||||||
if (this.#ctxStackViewIdx.get(label) !== null) {
|
if (this.#frozen || this.#ctxStackViewIdx.get(label) !== null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.#applyPropEl(label, prop, value);
|
this.#applyPropEl(label, prop, value);
|
||||||
@ -388,6 +397,9 @@ class CanvasContextDetailsView {
|
|||||||
|
|
||||||
// Sync the stack-nav button states and position counter for a context.
|
// Sync the stack-nav button states and position counter for a context.
|
||||||
#updateStackNav(label) {
|
#updateStackNav(label) {
|
||||||
|
if (this.#frozen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const nav = this.#gfxStateNavElements.get(label);
|
const nav = this.#gfxStateNavElements.get(label);
|
||||||
if (!nav) {
|
if (!nav) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
@import url(canvas_context_details_view.css);
|
@import url(canvas_context_details_view.css);
|
||||||
@import url(draw_ops_view.css);
|
@import url(draw_ops_view.css);
|
||||||
|
@import url(font_view.css);
|
||||||
@import url(multiline_view.css);
|
@import url(multiline_view.css);
|
||||||
@import url(page_view.css);
|
@import url(page_view.css);
|
||||||
@import url(split_view.css);
|
@import url(split_view.css);
|
||||||
@ -35,6 +36,7 @@
|
|||||||
--text-color: light-dark(#1e1e1e, #d4d4d4);
|
--text-color: light-dark(#1e1e1e, #d4d4d4);
|
||||||
--muted-color: light-dark(#6e6e6e, #888);
|
--muted-color: light-dark(#6e6e6e, #888);
|
||||||
--accent-color: light-dark(#0070c1, #9cdcfe);
|
--accent-color: light-dark(#0070c1, #9cdcfe);
|
||||||
|
--accent-fg: light-dark(white, #1e1e1e);
|
||||||
|
|
||||||
/* Borders */
|
/* Borders */
|
||||||
--border-color: light-dark(#e0e0e0, #3c3c3c);
|
--border-color: light-dark(#e0e0e0, #3c3c3c);
|
||||||
@ -285,8 +287,8 @@ body:has(#debug-view:not([hidden])) {
|
|||||||
color: var(--muted-color);
|
color: var(--muted-color);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
#debug-button,
|
#tree-button,
|
||||||
#debug-back-button {
|
#debug-button {
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
border: 1px solid var(--input-border-color);
|
border: 1px solid var(--input-border-color);
|
||||||
@ -299,4 +301,9 @@ body:has(#debug-view:not([hidden])) {
|
|||||||
&:hover {
|
&:hover {
|
||||||
background: var(--button-hover-bg);
|
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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>PDF.js — Debugging tools</title>
|
<title>PDF.js — Debugging tools</title>
|
||||||
<link rel="stylesheet" href="debugger.css" />
|
<link rel="stylesheet" href="debugger.css" />
|
||||||
|
<link rel="stylesheet" href="../text_layer_builder.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="header">
|
<div id="header">
|
||||||
@ -34,8 +35,8 @@ limitations under the License.
|
|||||||
<span id="goto-input-hint" class="sr-only">
|
<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.
|
Enter a page number (e.g. 5), a reference as numR (e.g. 10R) or numRgen (e.g. 10R2). Press Enter to navigate.
|
||||||
</span>
|
</span>
|
||||||
<button id="debug-button" hidden>Debug page</button>
|
<button id="tree-button">Tree</button>
|
||||||
<button id="debug-back-button" hidden>← Back to 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>
|
<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">
|
<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">
|
<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="op-detail-panel"></div>
|
||||||
<div id="gfx-state-panel" aria-label="Graphics state" hidden></div>
|
<div id="gfx-state-panel" aria-label="Graphics state" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="font-panel" hidden></div>
|
||||||
<div id="canvas-panel">
|
<div id="canvas-panel">
|
||||||
<div id="canvas-toolbar" role="toolbar" aria-label="Zoom controls">
|
<div id="canvas-toolbar" role="toolbar" aria-label="Zoom controls">
|
||||||
<button id="zoom-out-button" title="Zoom out">−</button>
|
<div class="toolbar-left">
|
||||||
<span id="zoom-level" aria-live="polite"></span>
|
<button id="text-filter-button" title="Show only text drawing operations" aria-pressed="false">T</button>
|
||||||
<button id="zoom-in-button" title="Zoom in">+</button>
|
<button id="text-layer-color-button" title="Text layer color">
|
||||||
<button id="redraw-button" title="Redraw page">Redraw</button>
|
<span id="text-layer-color-swatch"></span>
|
||||||
<button id="step-button" title="Step one instruction" disabled><u>S</u>tep</button>
|
</button>
|
||||||
<button id="continue-button" title="Continue to next breakpoint" disabled><u>C</u>ontinue</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>
|
||||||
<div id="canvas-scroll">
|
<div id="canvas-scroll">
|
||||||
<div id="canvas-wrapper">
|
<div id="canvas-wrapper">
|
||||||
|
|||||||
@ -67,8 +67,8 @@ function markLoading(delta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache frequently accessed elements.
|
// Cache frequently accessed elements.
|
||||||
|
const treeButton = document.getElementById("tree-button");
|
||||||
const debugButton = document.getElementById("debug-button");
|
const debugButton = document.getElementById("debug-button");
|
||||||
const debugBackButton = document.getElementById("debug-back-button");
|
|
||||||
const debugViewEl = document.getElementById("debug-view");
|
const debugViewEl = document.getElementById("debug-view");
|
||||||
const treeEl = document.getElementById("tree");
|
const treeEl = document.getElementById("tree");
|
||||||
const statusEl = document.getElementById("status");
|
const statusEl = document.getElementById("status");
|
||||||
@ -81,8 +81,7 @@ const treeView = new TreeView(treeEl, { onMarkLoading: markLoading });
|
|||||||
|
|
||||||
async function loadTree(data, rootLabel = null) {
|
async function loadTree(data, rootLabel = null) {
|
||||||
currentPage = typeof data.page === "number" ? data.page : null;
|
currentPage = typeof data.page === "number" ? data.page : null;
|
||||||
debugButton.hidden = currentPage === null;
|
debugButton.disabled = currentPage === null;
|
||||||
debugBackButton.hidden = true;
|
|
||||||
pageView.reset();
|
pageView.reset();
|
||||||
debugViewEl.hidden = true;
|
debugViewEl.hidden = true;
|
||||||
treeEl.hidden = false;
|
treeEl.hidden = false;
|
||||||
@ -113,6 +112,7 @@ async function openDocument(source, name) {
|
|||||||
wasmUrl: "../web/wasm/",
|
wasmUrl: "../web/wasm/",
|
||||||
useWorkerFetch: true,
|
useWorkerFetch: true,
|
||||||
pdfBug: true,
|
pdfBug: true,
|
||||||
|
fontExtraProperties: true,
|
||||||
CanvasFactory: pageView.DebugCanvasFactory,
|
CanvasFactory: pageView.DebugCanvasFactory,
|
||||||
});
|
});
|
||||||
loadingTask.onPassword = (updateCallback, reason) => {
|
loadingTask.onPassword = (updateCallback, reason) => {
|
||||||
@ -230,9 +230,17 @@ gotoInput.addEventListener("keydown", async ({ key, target }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
target.removeAttribute("aria-invalid");
|
target.removeAttribute("aria-invalid");
|
||||||
await (result.page !== undefined
|
// If we're in debug view and navigating to a page, stay in debug view
|
||||||
? loadTree({ page: result.page })
|
// without switching to the tree at all.
|
||||||
: loadTree({ ref: result.ref }));
|
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 }) => {
|
gotoInput.addEventListener("input", ({ target }) => {
|
||||||
@ -242,14 +250,14 @@ gotoInput.addEventListener("input", ({ target }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
debugButton.addEventListener("click", async () => {
|
debugButton.addEventListener("click", async () => {
|
||||||
debugButton.hidden = treeEl.hidden = true;
|
treeEl.hidden = true;
|
||||||
debugBackButton.hidden = debugViewEl.hidden = false;
|
debugViewEl.hidden = false;
|
||||||
// Only render if not already loaded for this page; re-entering from the
|
// 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);
|
await pageView.show(pdfDoc, currentPage);
|
||||||
});
|
});
|
||||||
|
|
||||||
debugBackButton.addEventListener("click", () => {
|
treeButton.addEventListener("click", () => {
|
||||||
debugBackButton.hidden = debugViewEl.hidden = true;
|
debugViewEl.hidden = true;
|
||||||
debugButton.hidden = treeEl.hidden = false;
|
treeEl.hidden = false;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -23,6 +23,47 @@ for (const [name, id] of Object.entries(OPS)) {
|
|||||||
OPS_TO_NAME[id] = name;
|
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 = {
|
const BreakpointType = {
|
||||||
PAUSE: 0,
|
PAUSE: 0,
|
||||||
SKIP: 1,
|
SKIP: 1,
|
||||||
@ -409,8 +450,17 @@ class DrawOpsView {
|
|||||||
|
|
||||||
#multilineView = null;
|
#multilineView = null;
|
||||||
|
|
||||||
|
// All op lines indexed by original op index.
|
||||||
#opLines = [];
|
#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;
|
#selectedLine = null;
|
||||||
|
|
||||||
// Map<opIndex, BreakpointType>
|
// Map<opIndex, BreakpointType>
|
||||||
@ -444,22 +494,50 @@ class DrawOpsView {
|
|||||||
load(opList, renderedPage) {
|
load(opList, renderedPage) {
|
||||||
this.#renderedPage = renderedPage;
|
this.#renderedPage = renderedPage;
|
||||||
this.#opLines = [];
|
this.#opLines = [];
|
||||||
const opTexts = [];
|
this.#opTexts = [];
|
||||||
|
|
||||||
for (let i = 0; i < opList.fnArray.length; i++) {
|
for (let i = 0; i < opList.fnArray.length; i++) {
|
||||||
const name = OPS_TO_NAME[opList.fnArray[i]] ?? `op${opList.fnArray[i]}`;
|
const name = OPS_TO_NAME[opList.fnArray[i]] ?? `op${opList.fnArray[i]}`;
|
||||||
const args = opList.argsArray[i] ?? [];
|
const args = opList.argsArray[i] ?? [];
|
||||||
const { line, text } = this.#buildLine(i, name, args);
|
const { line, text } = this.#buildLine(i, name, args);
|
||||||
this.#opLines.push(line);
|
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({
|
const multilineView = new MultilineView({
|
||||||
total: opList.fnArray.length,
|
total: this.#visibleLines.length,
|
||||||
getText: i => opTexts[i],
|
getText: i => this.#opTexts[+this.#visibleLines[i].dataset.opIdx],
|
||||||
makeLineEl: (i, isHighlighted) => {
|
makeLineEl: (i, isHighlighted) => {
|
||||||
this.#opLines[i].classList.toggle("mlc-match", isHighlighted);
|
this.#visibleLines[i].classList.toggle("mlc-match", isHighlighted);
|
||||||
return this.#opLines[i];
|
return this.#visibleLines[i];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
multilineView.element.classList.add("op-list-panel-wrapper");
|
multilineView.element.classList.add("op-list-panel-wrapper");
|
||||||
@ -469,7 +547,7 @@ class DrawOpsView {
|
|||||||
|
|
||||||
multilineView.inner.addEventListener("keydown", e => {
|
multilineView.inner.addEventListener("keydown", e => {
|
||||||
const { key } = e;
|
const { key } = e;
|
||||||
const lines = this.#opLines;
|
const lines = this.#visibleLines;
|
||||||
if (!lines.length) {
|
if (!lines.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -504,7 +582,7 @@ class DrawOpsView {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.#listPanelEl.replaceWith(multilineView.element);
|
anchor.replaceWith(multilineView.element);
|
||||||
this.#multilineView = multilineView;
|
this.#multilineView = multilineView;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -517,6 +595,8 @@ class DrawOpsView {
|
|||||||
document.getElementById("op-list").replaceChildren();
|
document.getElementById("op-list").replaceChildren();
|
||||||
this.#detailView.clear();
|
this.#detailView.clear();
|
||||||
this.#opLines = [];
|
this.#opLines = [];
|
||||||
|
this.#opTexts = [];
|
||||||
|
this.#visibleLines = [];
|
||||||
this.#selectedLine = null;
|
this.#selectedLine = null;
|
||||||
this.#originalColors.clear();
|
this.#originalColors.clear();
|
||||||
this.#breakpoints.clear();
|
this.#breakpoints.clear();
|
||||||
@ -529,7 +609,11 @@ class DrawOpsView {
|
|||||||
}
|
}
|
||||||
this.#pausedAtIdx = i;
|
this.#pausedAtIdx = i;
|
||||||
this.#opLines[i]?.classList.add("paused");
|
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() {
|
clearPaused() {
|
||||||
@ -558,6 +642,8 @@ class DrawOpsView {
|
|||||||
line.role = "option";
|
line.role = "option";
|
||||||
line.ariaSelected = "false";
|
line.ariaSelected = "false";
|
||||||
line.tabIndex = i === 0 ? 0 : -1;
|
line.tabIndex = i === 0 ? 0 : -1;
|
||||||
|
line.dataset.opName = name;
|
||||||
|
line.dataset.opIdx = i;
|
||||||
|
|
||||||
// Breakpoint gutter — click cycles: none → pause (●) → skip (✕) → none.
|
// Breakpoint gutter — click cycles: none → pause (●) → skip (✕) → none.
|
||||||
const gutter = document.createElement("span");
|
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);
|
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() {
|
#loadBottom() {
|
||||||
const newEnd = Math.min(this.#endIndex + BATCH_SIZE, this.#total);
|
const newEnd = Math.min(this.#endIndex + BATCH_SIZE, this.#total);
|
||||||
if (newEnd === this.#endIndex) {
|
if (newEnd === this.#endIndex) {
|
||||||
@ -302,10 +335,16 @@ class MultilineView {
|
|||||||
if (this.#endIndex - this.#startIndex > MAX_RENDERED) {
|
if (this.#endIndex - this.#startIndex > MAX_RENDERED) {
|
||||||
const removeCount = this.#endIndex - this.#startIndex - MAX_RENDERED;
|
const removeCount = this.#endIndex - this.#startIndex - MAX_RENDERED;
|
||||||
const heightBefore = this.#pre.scrollHeight;
|
const heightBefore = this.#pre.scrollHeight;
|
||||||
for (let i = 0; i < removeCount; i++) {
|
this.#removeChildren(
|
||||||
this.#topSentinel.nextElementSibling?.remove();
|
this.#pre,
|
||||||
this.#numCol.firstElementChild?.remove();
|
this.#topSentinel.nextElementSibling,
|
||||||
}
|
removeCount
|
||||||
|
);
|
||||||
|
this.#removeChildren(
|
||||||
|
this.#numCol,
|
||||||
|
this.#numCol.firstElementChild,
|
||||||
|
removeCount
|
||||||
|
);
|
||||||
this.#startIndex += removeCount;
|
this.#startIndex += removeCount;
|
||||||
// Compensate so visible content doesn't jump upward.
|
// Compensate so visible content doesn't jump upward.
|
||||||
this.#innerEl.scrollTop -= heightBefore - this.#pre.scrollHeight;
|
this.#innerEl.scrollTop -= heightBefore - this.#pre.scrollHeight;
|
||||||
@ -329,10 +368,18 @@ class MultilineView {
|
|||||||
// Trim from bottom if the window exceeds MAX_RENDERED.
|
// Trim from bottom if the window exceeds MAX_RENDERED.
|
||||||
if (this.#endIndex - this.#startIndex > MAX_RENDERED) {
|
if (this.#endIndex - this.#startIndex > MAX_RENDERED) {
|
||||||
const removeCount = this.#endIndex - this.#startIndex - MAX_RENDERED;
|
const removeCount = this.#endIndex - this.#startIndex - MAX_RENDERED;
|
||||||
for (let i = 0; i < removeCount; i++) {
|
this.#removeChildren(
|
||||||
this.#bottomSentinel.previousElementSibling?.remove();
|
this.#pre,
|
||||||
this.#numCol.lastElementChild?.remove();
|
this.#bottomSentinel.previousElementSibling,
|
||||||
}
|
removeCount,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
this.#removeChildren(
|
||||||
|
this.#numCol,
|
||||||
|
this.#numCol.lastElementChild,
|
||||||
|
removeCount,
|
||||||
|
true
|
||||||
|
);
|
||||||
this.#endIndex -= removeCount;
|
this.#endIndex -= removeCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,11 +33,16 @@
|
|||||||
--spc-resizer-hover-color: var(--accent-color);
|
--spc-resizer-hover-color: var(--accent-color);
|
||||||
}
|
}
|
||||||
#render-panels {
|
#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 {
|
> .spc-column {
|
||||||
flex: 1 1 0;
|
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. */
|
/* opTopSplit (spc-row) takes 70% of the instructions column height. */
|
||||||
> .spc-column > .spc-row {
|
> .spc-column > .spc-row {
|
||||||
flex: 7 1 0;
|
flex: 7 1 0;
|
||||||
@ -63,16 +68,22 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
#canvas-toolbar {
|
#canvas-toolbar {
|
||||||
|
--btn-h: calc(
|
||||||
|
1.1em * 1.4 + 4px
|
||||||
|
); /* font-size * line-height + 2×(padding+border) */
|
||||||
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: space-between;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
button {
|
button {
|
||||||
padding: 1px 8px;
|
box-sizing: border-box;
|
||||||
|
height: var(--btn-h);
|
||||||
|
padding: 0 8px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
border: 1px solid var(--input-border-color);
|
border: 1px solid var(--input-border-color);
|
||||||
background: var(--button-bg);
|
background: var(--button-bg);
|
||||||
@ -89,6 +100,66 @@
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: default;
|
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 {
|
#zoom-level {
|
||||||
@ -111,6 +182,28 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
line-height: 0;
|
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 {
|
.temp-canvas-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -13,11 +13,25 @@
|
|||||||
* limitations under the License.
|
* 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 { CanvasContextDetailsView } from "./canvas_context_details_view.js";
|
||||||
import { DOMCanvasFactory } from "pdfjs/display/canvas_factory.js";
|
import { DOMCanvasFactory } from "pdfjs/display/canvas_factory.js";
|
||||||
|
import { FontView } from "./font_view.js";
|
||||||
import { SplitView } from "./split_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.
|
// Stepper for pausing/stepping through op list rendering.
|
||||||
// Implements the interface expected by InternalRenderTask (pdfBug mode).
|
// Implements the interface expected by InternalRenderTask (pdfBug mode).
|
||||||
class ViewerStepper {
|
class ViewerStepper {
|
||||||
@ -40,11 +54,22 @@ class ViewerStepper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Advance one instruction then pause again.
|
// Advance one instruction then pause again.
|
||||||
|
// In text-only mode, skip forward to the next text op.
|
||||||
stepNext() {
|
stepNext() {
|
||||||
if (!this.#continueCallback) {
|
if (!this.#continueCallback) {
|
||||||
return;
|
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;
|
const cb = this.#continueCallback;
|
||||||
this.#continueCallback = null;
|
this.#continueCallback = null;
|
||||||
cb();
|
cb();
|
||||||
@ -63,7 +88,9 @@ class ViewerStepper {
|
|||||||
|
|
||||||
shouldSkip(i) {
|
shouldSkip(i) {
|
||||||
return (
|
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;
|
#redrawButton;
|
||||||
|
|
||||||
|
#textFilterButton;
|
||||||
|
|
||||||
|
#textLayerColorInput;
|
||||||
|
|
||||||
|
#textSpanBorderButton;
|
||||||
|
|
||||||
|
#textFilter = false;
|
||||||
|
|
||||||
#highlightCanvas;
|
#highlightCanvas;
|
||||||
|
|
||||||
#canvasScrollEl;
|
#canvasScrollEl;
|
||||||
|
|
||||||
|
#textLayerEl = null;
|
||||||
|
|
||||||
|
#textLayerInstance = null;
|
||||||
|
|
||||||
|
#fontView;
|
||||||
|
|
||||||
|
#fontViewButton;
|
||||||
|
|
||||||
constructor({ onMarkLoading }) {
|
constructor({ onMarkLoading }) {
|
||||||
this.#onMarkLoading = onMarkLoading;
|
this.#onMarkLoading = onMarkLoading;
|
||||||
this.#prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
|
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.
|
// Install a StepperManager so InternalRenderTask (pdfBug mode) picks it up.
|
||||||
// A new instance is set on each redraw; null means no stepping.
|
// A new instance is set on each redraw; null means no stepping.
|
||||||
globalThis.StepperManager = {
|
globalThis.StepperManager = {
|
||||||
@ -169,6 +234,14 @@ class PageView {
|
|||||||
},
|
},
|
||||||
_active: null,
|
_active: null,
|
||||||
_breakpoints: this.#opsView.breakpoints,
|
_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() {
|
create() {
|
||||||
return globalThis.StepperManager._active;
|
return globalThis.StepperManager._active;
|
||||||
},
|
},
|
||||||
@ -187,6 +260,13 @@ class PageView {
|
|||||||
this.#zoomOutButton = document.getElementById("zoom-out-button");
|
this.#zoomOutButton = document.getElementById("zoom-out-button");
|
||||||
this.#zoomInButton = document.getElementById("zoom-in-button");
|
this.#zoomInButton = document.getElementById("zoom-in-button");
|
||||||
this.#redrawButton = document.getElementById("redraw-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.#highlightCanvas = document.getElementById("highlight-canvas");
|
||||||
this.#canvasScrollEl = document.getElementById("canvas-scroll");
|
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 all debug state (call when navigating to tree or loading new doc).
|
||||||
reset() {
|
reset() {
|
||||||
this.#debugViewGeneration++;
|
this.#debugViewGeneration++;
|
||||||
|
this.#cancelTextLayer();
|
||||||
this.#currentRenderTask?.cancel();
|
this.#currentRenderTask?.cancel();
|
||||||
this.#currentRenderTask = null;
|
this.#currentRenderTask = null;
|
||||||
this.#renderedPage?.cleanup();
|
this.#renderedPage?.cleanup();
|
||||||
this.#renderedPage = this.#renderScale = this.#currentOpList = null;
|
this.#renderedPage = this.#renderScale = this.#currentOpList = null;
|
||||||
this.#clearPausedState();
|
this.#clearPausedState();
|
||||||
this.#opsView.clear();
|
this.#opsView.clear();
|
||||||
|
this.#fontView.clear();
|
||||||
this.#gfxStateComp.clear();
|
this.#gfxStateComp.clear();
|
||||||
this.#pdfDoc?.canvasFactory.clear();
|
this.#pdfDoc?.canvasFactory.clear();
|
||||||
|
|
||||||
@ -344,11 +426,20 @@ class PageView {
|
|||||||
{ direction: "column", minSize: 40 }
|
{ 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(
|
const renderSplit = new SplitView(
|
||||||
instructionsSplit.element,
|
instructionsSplit.element,
|
||||||
document.getElementById("canvas-panel"),
|
canvasFontSplit.element,
|
||||||
{ direction: "row", minSize: 100, onResize: () => this.#renderCanvas() }
|
{ direction: "row", minSize: 100, onResize: () => this.#rerenderCanvas() }
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderPanels = document.getElementById("render-panels");
|
const renderPanels = document.getElementById("render-panels");
|
||||||
@ -382,7 +473,7 @@ class PageView {
|
|||||||
// Reset recorded bboxes so they get re-recorded for the modified op
|
// Reset recorded bboxes so they get re-recorded for the modified op
|
||||||
// list.
|
// list.
|
||||||
this.#renderedPage.recordedBBoxes = null;
|
this.#renderedPage.recordedBBoxes = null;
|
||||||
if (this.#opsView.breakpoints.size > 0) {
|
if (this.#textFilter || this.#opsView.breakpoints.size > 0) {
|
||||||
globalThis.StepperManager._active = new ViewerStepper(i =>
|
globalThis.StepperManager._active = new ViewerStepper(i =>
|
||||||
this.#onStepped(i)
|
this.#onStepped(i)
|
||||||
);
|
);
|
||||||
@ -395,7 +486,83 @@ class PageView {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.#continueButton.addEventListener("click", () => {
|
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 => {
|
document.addEventListener("keydown", e => {
|
||||||
@ -441,9 +608,9 @@ class PageView {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#zoomRenderCanvas(newScale) {
|
// Re-render preserving any current pause position and text-only state.
|
||||||
// If zoomed again while a re-render is already running (not yet re-paused),
|
// Used by both zoom and resize so neither loses stepper or filter state.
|
||||||
// pausedAtIdx is null but the active stepper still knows the target index.
|
#rerenderCanvas() {
|
||||||
const stepper = globalThis.StepperManager._active;
|
const stepper = globalThis.StepperManager._active;
|
||||||
let resumeAt = null;
|
let resumeAt = null;
|
||||||
if (stepper !== null) {
|
if (stepper !== null) {
|
||||||
@ -451,8 +618,7 @@ class PageView {
|
|||||||
stepper.currentIdx >= 0 ? stepper.currentIdx : stepper.nextBreakPoint;
|
stepper.currentIdx >= 0 ? stepper.currentIdx : stepper.nextBreakPoint;
|
||||||
}
|
}
|
||||||
this.#clearPausedState();
|
this.#clearPausedState();
|
||||||
this.#renderScale = newScale;
|
if (resumeAt !== null || this.#textFilter) {
|
||||||
if (resumeAt !== null) {
|
|
||||||
globalThis.StepperManager._active = new ViewerStepper(
|
globalThis.StepperManager._active = new ViewerStepper(
|
||||||
i => this.#onStepped(i),
|
i => this.#onStepped(i),
|
||||||
resumeAt
|
resumeAt
|
||||||
@ -461,12 +627,58 @@ class PageView {
|
|||||||
return this.#renderCanvas();
|
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() {
|
async #renderCanvas() {
|
||||||
if (!this.#renderedPage) {
|
if (!this.#renderedPage) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel any in-progress render before starting a new one.
|
// 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?.cancel();
|
||||||
this.#currentRenderTask = null;
|
this.#currentRenderTask = null;
|
||||||
|
|
||||||
@ -549,6 +761,24 @@ class PageView {
|
|||||||
oldCanvas.replaceWith(newCanvas);
|
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
|
// Return the task on first render so the caller can extract the operator
|
||||||
// list without a separate getOperatorList() call (dev/testing builds only).
|
// list without a separate getOperatorList() call (dev/testing builds only).
|
||||||
return firstRender ? renderTask : null;
|
return firstRender ? renderTask : null;
|
||||||
@ -624,6 +854,19 @@ class PageView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.#opsView.load(this.#currentOpList, this.#renderedPage);
|
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) {
|
} catch (err) {
|
||||||
const errEl = document.createElement("div");
|
const errEl = document.createElement("div");
|
||||||
errEl.role = "alert";
|
errEl.role = "alert";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user