From cf3b3fa90070535da3669d9642456cd0df772ece Mon Sep 17 00:00:00 2001 From: calixteman Date: Tue, 17 Mar 2026 11:44:36 +0100 Subject: [PATCH] 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. --- src/display/canvas.js | 4 +- web/internal/canvas_context_details_view.js | 18 +- web/internal/debugger.css | 11 +- web/internal/debugger.html | 44 +++- web/internal/debugger.js | 32 ++- web/internal/draw_ops_view.js | 106 +++++++- web/internal/font_view.css | 164 ++++++++++++ web/internal/font_view.js | 251 ++++++++++++++++++ web/internal/multiline_view.js | 63 ++++- web/internal/page_view.css | 99 ++++++- web/internal/page_view.js | 269 +++++++++++++++++++- 11 files changed, 1001 insertions(+), 60 deletions(-) create mode 100644 web/internal/font_view.css create mode 100644 web/internal/font_view.js diff --git a/src/display/canvas.js b/src/display/canvas.js index 2b4645bf9..66f9bb43f 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -792,7 +792,9 @@ class CanvasGraphics { return i; } if (stepper.shouldSkip(i)) { - i++; + if (++i === argsArrayLen) { + return i; + } continue; } } diff --git a/web/internal/canvas_context_details_view.js b/web/internal/canvas_context_details_view.js index df5c9c2d4..5ec4f59cc 100644 --- a/web/internal/canvas_context_details_view.js +++ b/web/internal/canvas_context_details_view.js @@ -86,10 +86,18 @@ class CanvasContextDetailsView { // Map — 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; diff --git a/web/internal/debugger.css b/web/internal/debugger.css index 730eb1583..0a7ae3d60 100644 --- a/web/internal/debugger.css +++ b/web/internal/debugger.css @@ -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; + } } diff --git a/web/internal/debugger.html b/web/internal/debugger.html index 37ceb0a5f..6500b32e2 100644 --- a/web/internal/debugger.html +++ b/web/internal/debugger.html @@ -20,6 +20,7 @@ limitations under the License. PDF.js — Debugging tools + +
diff --git a/web/internal/debugger.js b/web/internal/debugger.js index a3b17e1c6..09548a9dc 100644 --- a/web/internal/debugger.js +++ b/web/internal/debugger.js @@ -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; }); diff --git a/web/internal/draw_ops_view.js b/web/internal/draw_ops_view.js index 5d1d500c2..26b090275 100644 --- a/web/internal/draw_ops_view.js +++ b/web/internal/draw_ops_view.js @@ -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 @@ -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 }; diff --git a/web/internal/font_view.css b/web/internal/font_view.css new file mode 100644 index 000000000..a40fa9ffe --- /dev/null +++ b/web/internal/font_view.css @@ -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); +} diff --git a/web/internal/font_view.js b/web/internal/font_view.js new file mode 100644 index 000000000..dbe304d4d --- /dev/null +++ b/web/internal/font_view.js @@ -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 }; diff --git a/web/internal/multiline_view.js b/web/internal/multiline_view.js index 3b63750ff..bc8fdcccc 100644 --- a/web/internal/multiline_view.js +++ b/web/internal/multiline_view.js @@ -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; } } diff --git a/web/internal/page_view.css b/web/internal/page_view.css index 656acefb5..29c83ec54 100644 --- a/web/internal/page_view.css +++ b/web/internal/page_view.css @@ -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; diff --git a/web/internal/page_view.js b/web/internal/page_view.js index 4d9c55afa..e840cd213 100644 --- a/web/internal/page_view.js +++ b/web/internal/page_view.js @@ -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";