From 2381ac6b16c228ca52831d75bbf1013b414e484d Mon Sep 17 00:00:00 2001 From: calixteman Date: Tue, 10 Mar 2026 21:55:52 +0100 Subject: [PATCH] Update the internal viewer to use a new debugger. It has few cool features: - all the canvas used during the rendering can be viewed; - the different properties in the graphics state can be viewed; - the different paths can be viewed. --- src/core/internal_viewer_utils.js | 16 +- web/pdf_internal_viewer.css | 842 ++++++++- web/pdf_internal_viewer.html | 43 +- web/pdf_internal_viewer.js | 2751 ++++++++++++++++++++++++++--- 4 files changed, 3350 insertions(+), 302 deletions(-) diff --git a/src/core/internal_viewer_utils.js b/src/core/internal_viewer_utils.js index f5c8877d80582..0b5cd4cef41df 100644 --- a/src/core/internal_viewer_utils.js +++ b/src/core/internal_viewer_utils.js @@ -56,6 +56,7 @@ const InternalViewerUtils = { const refs = Array.isArray(contentsVal) ? contentsVal : [contentsVal]; const rawContents = []; const tokens = []; + const rawBytesArr = []; for (const rawRef of refs) { if (rawRef instanceof Ref) { rawContents.push({ num: rawRef.num, gen: rawRef.gen }); @@ -64,10 +65,21 @@ const InternalViewerUtils = { if (!(stream instanceof BaseStream)) { continue; } - tokens.push(...this.tokenizeStream(stream, xref)); + rawBytesArr.push(stream.getString()); + stream.reset(); + for (const token of this.tokenizeStream(stream, xref)) { + tokens.push(token); + } } + const rawBytes = rawBytesArr.join("\n"); const { instructions, cmdNames } = this.groupIntoInstructions(tokens); - return { contentStream: true, instructions, cmdNames, rawContents }; + return { + contentStream: true, + instructions, + cmdNames, + rawContents, + rawBytes, + }; }, // Lazily-built reverse map: OPS numeric id → property name string. diff --git a/web/pdf_internal_viewer.css b/web/pdf_internal_viewer.css index 553ef45b15ac3..67019554e9c37 100644 --- a/web/pdf_internal_viewer.css +++ b/web/pdf_internal_viewer.css @@ -15,18 +15,166 @@ :root { color-scheme: light dark; + + /* Backgrounds */ + --bg-color: light-dark(#fff, #1e1e1e); + --surface-bg: light-dark(#f3f3f3, #252526); + --input-bg: light-dark(#fff, #3c3c3c); + --button-bg: light-dark(#f3f3f3, #3c3c3c); + --button-hover-bg: light-dark(#e0e0e0, #4a4a4a); + --clr-canvas-bg: var(--surface-bg); + + /* Text */ + --text-color: light-dark(#1e1e1e, #d4d4d4); + --muted-color: light-dark(#6e6e6e, #888); + --accent-color: light-dark(#0070c1, #9cdcfe); + + /* Borders */ + --border-color: light-dark(#e0e0e0, #3c3c3c); + --border-subtle-color: light-dark(#d0d0d0, #444); + --input-border-color: light-dark(#c8c8c8, #555); + + /* Interactive states */ + --hover-bg: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.05)); + --hover-color: currentColor; + --paused-bg: light-dark(rgb(255 165 0 / 0.15), rgb(255 165 0 / 0.2)); + --paused-outline-color: rgb(255 140 0 / 0.6); + --paused-color: currentColor; + + /* Semantic */ + --ref-color: light-dark(#007b6e, #4ec9b0); + --ref-hover-color: light-dark(#065, #89d9c8); + --changed-bg: transparent; + --changed-color: light-dark(#c00, #f66); + --match-bg: light-dark(rgb(255 200 0 / 0.35), rgb(255 200 0 / 0.25)); + --match-outline-color: light-dark(rgb(200 140 0 / 0.8), rgb(255 200 0 / 0.6)); + + /* Syntax highlighting */ + --string-color: light-dark(#a31515, #ce9178); + --number-color: light-dark(#098658, #b5cea8); + --bool-color: light-dark(#00f, #569cd6); + --null-color: light-dark(#767676, #808080); + --name-color: light-dark(#795e26, #dcdcaa); + --stream-color: light-dark(#af00db, #c586c0); +} + +@media (forced-colors: active) { + :root { + /* Backgrounds */ + --bg-color: Canvas; + --surface-bg: Canvas; + --input-bg: Field; + --button-bg: ButtonFace; + --button-hover-bg: Highlight; + --clr-canvas-bg: var(--surface-bg); + + /* Text */ + --text-color: CanvasText; + --muted-color: GrayText; + --accent-color: CanvasText; + + /* Borders */ + --border-color: ButtonBorder; + --border-subtle-color: ButtonBorder; + --input-border-color: ButtonBorder; + + /* Interactive states */ + --hover-bg: Highlight; + --hover-color: HighlightText; + --paused-bg: Mark; + --paused-outline-color: ButtonBorder; + --paused-color: MarkText; + + /* Semantic */ + --ref-color: LinkText; + --ref-hover-color: ActiveText; + --changed-bg: Mark; + --changed-color: MarkText; + --match-bg: Mark; + --match-outline-color: ButtonBorder; + + /* Syntax highlighting — replaced by plain text in HCM */ + --string-color: CanvasText; + --number-color: CanvasText; + --bool-color: CanvasText; + --null-color: GrayText; + --name-color: CanvasText; + --stream-color: CanvasText; + } + + /* Resizer hover: accent color → CanvasText is wrong for a background. */ + #render-resizer, + #op-resizer, + #op-gfx-state-resizer { + &:hover, + &.dragging { + background: Highlight; + } + } + + /* Opacity trick for breakpoint glyph visibility → use Canvas color to hide. */ + .bp-gutter::before { + opacity: 1; + color: Canvas; + } + .bp-gutter:hover::before { + color: ButtonBorder; + } + .bp-gutter.active::before { + color: ButtonText; + } + + /* Opacity-only disabled style → explicit GrayText. */ + button:disabled, + input:disabled { + color: GrayText; + border-color: GrayText; + opacity: 1; + } + + /* Color swatch preserves the actual PDF color value. */ + .color-swatch { + forced-color-adjust: none; + } } + * { box-sizing: border-box; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +body.loading { + cursor: wait; +} +html { + height: 100%; +} body { font-family: "Courier New", Courier, monospace; margin: 0; padding: 16px; - background: light-dark(#fff, #1e1e1e); - color: light-dark(#1e1e1e, #d4d4d4); + background: var(--bg-color); + color: var(--text-color); font-size: 13px; line-height: 1.5; + display: flex; + flex-direction: column; +} + +/* In debug mode the body must be viewport-height so #debug-view can fill it. + In tree mode body is auto-height so the tree can grow and the page scrolls. */ +body:has(#debug-view:not([hidden])) { + height: 100%; + overflow: hidden; } #header { display: flex; @@ -35,7 +183,7 @@ body { margin-bottom: 12px; h1 { - color: light-dark(#0070c1, #9cdcfe); + color: var(--accent-color); font-size: 1.2em; margin: 0; } @@ -44,13 +192,13 @@ body { font-family: system-ui, sans-serif; font-size: 1.15em; font-weight: 500; - color: light-dark(#1e1e1e, #d4d4d4); + color: var(--text-color); } } #password-dialog { - background: light-dark(#fff, #2d2d2d); - color: light-dark(#1e1e1e, #d4d4d4); - border: 1px solid light-dark(#ccc, #555); + background: var(--input-bg); + color: var(--text-color); + border: 1px solid var(--input-border-color); border-radius: 6px; padding: 20px; min-width: 320px; @@ -67,9 +215,9 @@ body { display: block; width: 100%; margin-top: 4px; - background: light-dark(#fff, #3c3c3c); - color: light-dark(#1e1e1e, #d4d4d4); - border: 1px solid light-dark(#c8c8c8, #555); + background: var(--input-bg); + color: var(--text-color); + border: 1px solid var(--input-border-color); border-radius: 3px; padding: 4px 8px; font-family: inherit; @@ -85,15 +233,15 @@ body { button { padding: 4px 14px; border-radius: 3px; - border: 1px solid light-dark(#c8c8c8, #555); - background: light-dark(#f3f3f3, #3c3c3c); + border: 1px solid var(--input-border-color); + background: var(--button-bg); color: inherit; cursor: pointer; font-family: inherit; font-size: inherit; &:hover { - background: light-dark(#e0e0e0, #4a4a4a); + background: var(--button-hover-bg); } } } @@ -108,26 +256,26 @@ body { gap: 12px; margin-bottom: 16px; padding: 10px 14px; - background: light-dark(#f3f3f3, #252526); + background: var(--surface-bg); border-radius: 4px; - border: 1px solid light-dark(#e0e0e0, #3c3c3c); + border: 1px solid var(--border-color); label { display: flex; align-items: center; gap: 4px; - color: light-dark(#6e6e6e, #888); + color: var(--muted-color); } #github-link { margin-inline-start: auto; display: flex; align-items: center; - color: light-dark(#6e6e6e, #aaa); + color: var(--muted-color); text-decoration: none; &:hover { - color: light-dark(#1e1e1e, #fff); + color: var(--text-color); } svg { @@ -138,9 +286,9 @@ body { } } #goto-input { - background: light-dark(#fff, #3c3c3c); - color: light-dark(#1e1e1e, #d4d4d4); - border: 1px solid light-dark(#c8c8c8, #555); + background: var(--input-bg); + color: var(--text-color); + border: 1px solid var(--input-border-color); border-radius: 3px; padding: 2px 6px; font-family: inherit; @@ -150,18 +298,21 @@ body { opacity: 0.4; } &[aria-invalid="true"] { - border-color: #f66; + border-color: var(--changed-color); } } #status { - color: light-dark(#6e6e6e, #888); + color: var(--muted-color); font-style: italic; } +#tree.loading { + pointer-events: none; +} #tree { padding: 8px 12px; - background: light-dark(#f3f3f3, #252526); + background: var(--surface-bg); border-radius: 4px; - border: 1px solid light-dark(#e0e0e0, #3c3c3c); + border: 1px solid var(--border-color); min-height: 60px; .node { @@ -169,23 +320,23 @@ body { padding: 1px 0; } .key { - color: light-dark(#0070c1, #9cdcfe); + color: var(--accent-color); } .separator { - color: light-dark(#6e6e6e, #888); + color: var(--muted-color); } [role="button"] { display: inline-block; width: 14px; font-size: 0.7em; - color: light-dark(#666, #aaa); + color: var(--muted-color); cursor: pointer; user-select: none; vertical-align: middle; } [role="group"] { padding-left: 20px; - border-left: 1px dashed light-dark(#d0d0d0, #444); + border-left: 1px dashed var(--border-subtle-color); margin-left: 2px; &.hidden { @@ -193,31 +344,31 @@ body { } } .ref { - color: light-dark(#007b6e, #4ec9b0); + color: var(--ref-color); cursor: pointer; text-decoration: underline dotted; &:hover { - color: light-dark(#065, #89d9c8); + color: var(--ref-hover-color); } } .str-value { - color: light-dark(#a31515, #ce9178); + color: var(--string-color); } .num-value { - color: light-dark(#098658, #b5cea8); + color: var(--number-color); } .bool-value { - color: light-dark(#00f, #569cd6); + color: var(--bool-color); } .null-value { - color: light-dark(#767676, #808080); + color: var(--null-color); } .name-value { - color: light-dark(#795e26, #dcdcaa); + color: var(--name-color); } .bracket { - color: light-dark(#6e6e6e, #888); + color: var(--muted-color); cursor: pointer; user-select: none; @@ -226,79 +377,630 @@ body { } } .stream-label { - color: light-dark(#af00db, #c586c0); + color: var(--stream-color); font-style: italic; } [role="status"] { - color: light-dark(#6e6e6e, #888); + color: var(--muted-color); font-style: italic; } [role="alert"] { - color: #f66; + color: var(--changed-color); } .bytes-content { padding-left: 20px; white-space: pre-wrap; font-size: 1em; opacity: 0.85; - color: light-dark(#a31515, #ce9178); + color: var(--string-color); } .bytes-hex { font-family: monospace; - color: light-dark(#00f, #569cd6); + color: var(--bool-color); } .image-preview { display: block; margin-top: 4px; max-width: 40%; + height: auto; image-rendering: pixelated; - border: 1px solid light-dark(#ccc, #444); - } - .content-stream-parsed { - display: none; - } - .content-stream-raw { - display: inline; + border: 1px solid var(--border-subtle-color); } - &.parse-cs-active { - .content-stream-parsed { - display: inline; - } - .content-stream-raw { - display: none; - } + .content-stm-scroll { + display: flex; + flex-direction: column; + max-height: 60vh; + border: 1px solid var(--border-subtle-color); + border-radius: 3px; + overflow: hidden; } - .content-stream { - line-height: 1.8; - } - .cs-instruction { - display: block; - white-space: nowrap; + .content-stm-load-sentinel { + height: 1px; } .token-cmd { - color: light-dark(#0070c1, #9cdcfe); + color: var(--accent-color); font-weight: bold; } .token-num { - color: light-dark(#098658, #b5cea8); + color: var(--number-color); } .token-str { - color: light-dark(#a31515, #ce9178); + color: var(--string-color); } .token-name { - color: light-dark(#795e26, #dcdcaa); + color: var(--name-color); } .token-bool { - color: light-dark(#00f, #569cd6); + color: var(--bool-color); } .token-null { - color: light-dark(#767676, #808080); + color: var(--null-color); } .token-ref { - color: light-dark(#007b6e, #4ec9b0); + color: var(--ref-color); } .token-array, .token-dict { - color: light-dark(#1e1e1e, #d4d4d4); + color: var(--text-color); + } +} +#debug-btn, +#debug-back-btn { + padding: 4px 12px; + border-radius: 3px; + border: 1px solid var(--input-border-color); + background: var(--button-bg); + color: inherit; + cursor: pointer; + font-family: inherit; + font-size: inherit; + + &:hover { + background: var(--button-hover-bg); + } +} +#debug-view { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; + + &[hidden] { + display: none; + } +} +#render-panels { + flex: 1; + min-height: 0; + display: flex; + flex-direction: row; + gap: 0; + align-items: stretch; +} +#render-resizer { + flex-shrink: 0; + width: 6px; + align-self: stretch; + cursor: col-resize; + background: var(--border-color); + + &:hover, + &.dragging { + background: var(--accent-color); + } +} +/* ── Shared code-view layout (content stream and op-list) ──────────────── */ +/* Row wrapper that sits between the toolbar and the scrollable content. + Hosts the frozen line-number column and the actual scroll container. */ +.content-stm-body, +.op-list-body { + flex: 1; + display: flex; + flex-direction: row; + min-height: 0; +} +.content-stm-body { + line-height: 1.8; +} +/* The line-number column lives *outside* the scroll container so it is + never affected by horizontal or vertical scroll. Its scrollTop is kept + in sync with the adjacent scroll container via a JS scroll listener. */ +.cs-line-nums-col { + overflow: hidden; + flex-shrink: 0; + background: var(--surface-bg); + border-inline-end: 1px solid var(--border-subtle-color); +} +.content-stm-inner { + flex: 1; + overflow: auto; + /* Disable scroll anchoring so manual scrollTop corrections aren't doubled. */ + overflow-anchor: none; +} +.content-stm-instruction { + display: block; + white-space: nowrap; + padding-inline-start: 0.5em; +} +.raw-bytes-stream { + color: var(--string-color); +} +/* ── Shared search/goto toolbar (used in .content-stm-scroll and #op-list-panel) ── */ +.cs-goto-bar { + position: sticky; + top: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 3px 4px; + background: var(--surface-bg); + border-bottom: 1px solid var(--border-subtle-color); + z-index: 1; + + .cs-search-group { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + } + + .cs-search-input, + .cs-goto { + font: inherit; + font-size: 0.85em; + padding: 2px 6px; + border: 1px solid var(--input-border-color); + border-radius: 3px; + background: var(--input-bg); + color: var(--text-color); + + &:focus { + outline: 2px solid var(--accent-color); + outline-offset: 0; + } + + &[aria-invalid="true"] { + border-color: red; + } + } + + .cs-search-input { + width: 140px; + } + + .cs-goto { + width: 110px; + margin-inline-start: auto; + } + + .cs-nav-btn { + font: inherit; + font-size: 0.85em; + padding: 1px 6px; + border: 1px solid var(--input-border-color); + border-radius: 3px; + background: var(--button-bg); + color: var(--text-color); + cursor: pointer; + line-height: 1.4; + + &:hover:not(:disabled) { + background: var(--button-hover-bg); + } + + &:disabled { + opacity: 0.4; + cursor: default; + } + + &[aria-pressed="true"] { + background: var(--accent-color); + color: light-dark(white, black); + border-color: var(--accent-color); + } + } + + .cs-match-info { + font-size: 0.8em; + color: var(--muted-color); + white-space: nowrap; + min-width: 4ch; + } + + .cs-check-label { + display: flex; + align-items: center; + gap: 3px; + font-size: 0.85em; + cursor: pointer; + white-space: nowrap; + } +} +.cs-num-item { + display: block; + white-space: nowrap; +} +/* Op-list num items need padding/spacing to match .op-line and #op-list-panel. */ +.op-list-body .cs-num-item { + padding: 1px 0; +} +.op-list-body .cs-line-nums-col { + padding-block: 8px; +} +.cs-num-item.cs-match { + background: var(--match-bg); +} +.cs-line-num { + display: inline-block; + min-width: var(--line-num-width, 3ch); + padding-inline: 0.4em; + text-align: right; + font-family: monospace; + font-size: 0.8em; + color: var(--muted-color); + user-select: none; +} +.cs-match { + background: var(--match-bg); + outline: 1px solid var(--match-outline-color); + border-radius: 2px; +} + +#op-left-col { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} +#op-resizer { + flex-shrink: 0; + height: 6px; + cursor: row-resize; + background: var(--border-color); + + &:hover, + &.dragging { + background: var(--accent-color); + } +} +#op-top-row { + flex: 4 1 0; + display: flex; + flex-direction: row; + min-height: 0; +} +/* Wrapper injected by showRenderView() to host the toolbar above #op-list-panel + without it being part of the horizontal-scroll area. */ +.op-list-panel-wrapper { + flex: 1 1 0; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + + & > .cs-goto-bar { + position: static; + border: 1px solid var(--border-color); + border-bottom-color: var(--border-subtle-color); + border-radius: 4px 4px 0 0; + } + + & > .op-list-body > #op-list-panel { + flex: 1 1 0; + border-radius: 0 0 4px 4px; + border-top: none; + } +} +#op-list-panel { + flex: 1 1 0; + overflow: auto; + min-width: 0; + min-height: 0; + padding: 8px 12px; + background: var(--surface-bg); + border-radius: 4px; + border: 1px solid var(--border-color); +} +#op-list { + min-width: max-content; +} +#op-gfx-state-resizer { + flex-shrink: 0; + width: 6px; + align-self: stretch; + cursor: col-resize; + background: var(--border-color); + + &:hover, + &.dragging { + background: var(--accent-color); + } +} +#gfx-state-panel { + flex: 0 0 auto; + width: 40ch; + overflow: auto; + min-height: 0; + padding: 8px 12px; + background: var(--surface-bg); + border-radius: 4px; + border: 1px solid var(--border-color); + + .gfx-state-section + .gfx-state-section { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-color); + } + .gfx-state-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + color: var(--accent-color); + font-weight: bold; + margin-bottom: 4px; + } + .gfx-state-stack-nav { + display: flex; + align-items: center; + gap: 2px; + font-weight: normal; + font-size: 0.8em; + + &[hidden] { + display: none; + } + } + .gfx-state-stack-btn { + padding: 0 3px; + border: 1px solid currentcolor; + border-radius: 2px; + background: transparent; + color: inherit; + cursor: pointer; + line-height: 1.3; + + &:disabled { + opacity: 0.35; + cursor: default; + } + } + .gfx-state-stack-pos { + min-width: 4ch; + text-align: center; + font-variant-numeric: tabular-nums; + } + .gfx-state-row { + display: flex; + align-items: center; + gap: 8px; + padding: 1px 0; + } + .gfx-state-key { + color: var(--muted-color); + flex-shrink: 0; + min-width: 20ch; + } + .gfx-state-val { + color: var(--number-color); + flex: 1 1 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} +#op-detail-panel { + flex: 1 1 0; + container-type: size; + overflow: auto; + min-height: 0; + padding: 8px 12px; + background: var(--surface-bg); + border-radius: 4px; + border: 1px solid var(--border-color); + + .detail-name { + color: var(--accent-color); + font-weight: bold; + margin-bottom: 4px; + } + .detail-empty { + color: var(--muted-color); + font-style: italic; + } + .detail-row { + display: flex; + align-items: center; + gap: 8px; + padding: 1px 0; } + .detail-idx { + color: var(--muted-color); + flex-shrink: 0; + } + .detail-val { + color: var(--number-color); + white-space: pre-wrap; + word-break: break-all; + } + .detail-body { + display: flex; + flex-direction: row; + gap: 12px; + align-items: flex-start; + } + .detail-args-col { + flex: 1; + min-width: 0; + } + .detail-img-col { + flex-shrink: 0; + max-width: 45%; + overflow: hidden; + + .image-preview { + height: 90cqh; + width: auto; + max-width: 100%; + margin-top: 0; + } + } + .path-preview { + flex-shrink: 0; + border: 1px solid var(--border-subtle-color); + border-radius: 3px; + background: var(--bg-color); + } +} +#canvas-panel { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + background: var(--surface-bg); + border-radius: 4px; + border: 1px solid var(--border-color); +} +#canvas-toolbar { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 4px 8px; + border-bottom: 1px solid var(--border-color); + + button { + padding: 1px 8px; + border-radius: 3px; + border: 1px solid var(--input-border-color); + background: var(--button-bg); + color: inherit; + cursor: pointer; + font-family: inherit; + font-size: 1.1em; + line-height: 1.4; + + &:hover { + background: var(--button-hover-bg); + } + &:disabled { + opacity: 0.4; + cursor: default; + } + } + + #zoom-level { + min-width: 4ch; + text-align: center; + } +} +#canvas-scroll { + flex: 1; + overflow: auto; + padding: 8px 12px; + min-height: 0; + background: var(--clr-canvas-bg); + display: flex; + flex-direction: column; + align-items: safe center; + gap: 12px; +} +#canvas-wrapper { + position: relative; + display: inline-block; + line-height: 0; +} +.temp-canvas-wrapper { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + cursor: pointer; +} +.temp-canvas-label { + font-size: 0.85em; + color: var(--muted-color); + font-style: italic; +} +.temp-canvas-wrapper canvas { + border: 1px solid var(--border-subtle-color); + zoom: calc(1 / var(--dpr, 1)); +} +#render-canvas { + cursor: pointer; +} +#highlight-canvas { + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} +.op-line { + display: flex; + align-items: center; + gap: 0.5ch; + white-space: nowrap; + padding: 1px 0; + cursor: pointer; + + &.selected { + text-decoration: underline; + } + + &:hover { + background: var(--hover-bg); + color: var(--hover-color); + } +} +.color-swatch { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1px solid var(--muted-color); + flex-shrink: 0; +} +.op-name { + color: var(--accent-color); + font-weight: bold; +} +.op-arg { + color: var(--number-color); +} +.changed-value { + font-weight: bold; + background: var(--changed-bg); + color: var(--changed-color); +} +.bp-gutter { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + flex-shrink: 0; + cursor: pointer; + user-select: none; + + &::before { + content: "●"; + color: var(--changed-color); + font-size: 0.75em; + opacity: 0; + } + + &:hover::before { + opacity: 0.4; + } + + &.active::before { + opacity: 1; + } +} +.op-line.paused { + background: var(--paused-bg); + color: var(--paused-color); + outline: 1px solid var(--paused-outline-color); + outline-offset: -1px; } diff --git a/web/pdf_internal_viewer.html b/web/pdf_internal_viewer.html index b31ae57bdccce..e6156f630c0d5 100644 --- a/web/pdf_internal_viewer.html +++ b/web/pdf_internal_viewer.html @@ -18,12 +18,12 @@ - PDF Internal Structure Viewer + PDF.js — Debugging tools
+
diff --git a/web/pdf_internal_viewer.js b/web/pdf_internal_viewer.js index 6504b3cd597c9..a0e5c96d71e55 100644 --- a/web/pdf_internal_viewer.js +++ b/web/pdf_internal_viewer.js @@ -13,7 +13,14 @@ * limitations under the License. */ -import { getDocument, GlobalWorkerOptions, PasswordResponses } from "pdfjs-lib"; +import { + getDocument, + GlobalWorkerOptions, + OPS, + PasswordResponses, +} from "pdfjs-lib"; +import { DOMCanvasFactory } from "pdfjs/display/canvas_factory.js"; +import { makePathFromDrawOPS } from "pdfjs/display/display_utils.js"; GlobalWorkerOptions.workerSrc = typeof PDFJSDev === "undefined" @@ -53,24 +60,778 @@ function parseRefInput(str) { let pdfDoc = null; +// Page number currently displayed in the tree (null when showing a +// ref/trailer). +let currentPage = null; + +// PDFPageProxy currently shown in the render view (null when not rendering). +let renderedPage = null; + +// Explicit zoom scale (CSS pixels per PDF point). null → auto-fit to panel. +let renderScale = null; + +// RenderTask currently in progress, so it can be cancelled on zoom change. +let currentRenderTask = null; + +// Operator list for the currently rendered page. Exposed as a module variable +// so it can be mutated and the page redrawn via the Redraw button. +let currentOpList = null; + +// Incremented by resetRenderView() to cancel any in-flight showRenderView(). +let debugViewGeneration = 0; + +// Original color values before user edits: Map. +// Keyed by op index so showOpDetail can tell whether a value has been changed. +const originalColors = new Map(); + +// Breakpoint state: set of op indices, array of line elements, paused index. +const breakpoints = new Set(); +let opLines = []; +let pausedAtIdx = null; +let selectedOpLine = null; + +// Reverse map: OPS numeric id → string name, built once from the OPS object. +const OPS_TO_NAME = Object.create(null); +for (const [name, id] of Object.entries(OPS)) { + OPS_TO_NAME[id] = name; +} + +// Properties of CanvasRenderingContext2D that we track while stepping. +const TRACKED_CTX_PROPS = new Set([ + "direction", + "fillStyle", + "filter", + "font", + "globalAlpha", + "globalCompositeOperation", + "imageSmoothingEnabled", + "lineCap", + "lineDashOffset", + "lineJoin", + "lineWidth", + "miterLimit", + "strokeStyle", + "textAlign", + "textBaseline", +]); + +// Map> — live graphics state per tracked context. +const ctxStates = new Map(); + +// Map>> — save() stack snapshots per context. +const ctxStateStacks = new Map(); + +// Map — which stack frame is shown in the panel. +// null = live/current; 0..N-1 = index into ctxStateStacks (0 = oldest). +const ctxStackViewIdx = new Map(); + +// Map> — DOM elements for live updates. +const gfxStateValueElements = new Map(); + +// Map — stack-nav DOM elements. +const gfxStateNavElements = new Map(); + +function formatCtxValue(value) { + return Array.isArray(value) ? `[${value.join(", ")}]` : String(value); +} + +// Shallow-copy a state Map (arrays and plain objects are cloned one level +// deep). +function copyState(state) { + const clone = v => { + if (Array.isArray(v)) { + return [...v]; + } + if (typeof v === "object" && v !== null) { + return { ...v }; + } + return v; + }; + return new Map([...state].map(([k, v]) => [k, clone(v)])); +} + +// Apply a single (label, prop, value) update to the DOM unconditionally. +function _applyGfxStatePropEl(label, prop, value) { + const entry = gfxStateValueElements.get(label)?.get(prop); + if (!entry) { + return; + } + if (entry.mnEls) { + for (const k of ["a", "b", "c", "d", "e", "f"]) { + entry.mnEls[k].textContent = formatMatrixValue(value[k]); + } + return; + } + const text = formatCtxValue(value); + entry.valEl.textContent = text; + entry.valEl.title = text; + if (entry.swatchEl) { + entry.swatchEl.style.background = String(value); + } +} + +// Update DOM for a live setter — skipped when the user is browsing a saved +// state so that live updates don't overwrite the frozen view. +function updateGfxStatePropEl(label, prop, value) { + if (ctxStackViewIdx.get(label) !== null) { + return; + } + _applyGfxStatePropEl(label, prop, value); +} + +// Re-render all value DOM elements for label using the currently-viewed state. +function showGfxState(label) { + const viewIdx = ctxStackViewIdx.get(label); + const stateToShow = + viewIdx === null + ? ctxStates.get(label) + : ctxStateStacks.get(label)?.[viewIdx]; + if (!stateToShow) { + return; + } + for (const [prop, value] of stateToShow) { + _applyGfxStatePropEl(label, prop, value); + } +} + +// Sync the stack-nav button states and position counter for a context. +function updateGfxStateStackNav(label) { + const nav = gfxStateNavElements.get(label); + if (!nav) { + return; + } + const stack = ctxStateStacks.get(label) ?? []; + const viewIdx = ctxStackViewIdx.get(label); + nav.container.hidden = stack.length === 0; + if (stack.length === 0) { + return; + } + nav.prevBtn.disabled = viewIdx === 0; + nav.nextBtn.disabled = viewIdx === null; + nav.pos.textContent = + viewIdx === null ? "cur" : `${viewIdx + 1}/${stack.length}`; +} + +/** + * Draw a checkerboard pattern filling the canvas, to reveal transparency. + * Mirrors the pattern used in src/display/editor/stamp.js. + */ +function drawCheckerboard(ctx, width, height) { + const isHCM = prefersHCM.matches; + const isDark = prefersDark.matches; + let light, dark; + if (isHCM) { + light = "white"; + dark = "black"; + } else if (isDark) { + light = "#8f8f9d"; + dark = "#42414d"; + } else { + light = "white"; + dark = "#cfcfd8"; + } + const boxDim = 15; + const pattern = new OffscreenCanvas(boxDim * 2, boxDim * 2); + const patternCtx = pattern.getContext("2d"); + patternCtx.fillStyle = light; + patternCtx.fillRect(0, 0, boxDim * 2, boxDim * 2); + patternCtx.fillStyle = dark; + patternCtx.fillRect(0, 0, boxDim, boxDim); + patternCtx.fillRect(boxDim, boxDim, boxDim, boxDim); + ctx.save(); + ctx.fillStyle = ctx.createPattern(pattern, "repeat"); + ctx.fillRect(0, 0, width, height); + ctx.restore(); +} + +// Override canvas.getContext to return a tracked proxy for "2d" contexts. +// Caches the proxy so repeated getContext("2d") calls return the same wrapper. +function wrapCanvasGetContext(canvas, label) { + let wrappedCtx = null; + const origGetContext = canvas.getContext.bind(canvas); + canvas.getContext = (type, ...args) => { + const ctx = origGetContext(type, ...args); + if (type !== "2d") { + return ctx; + } + if (!wrappedCtx) { + if ( + globalThis.StepperManager._active !== null && + args[0]?.alpha !== false + ) { + drawCheckerboard(ctx, canvas.width, canvas.height); + } + wrappedCtx = wrapContext(ctx, label); + } + return wrappedCtx; + }; + return canvas.getContext("2d"); +} + +// Methods that modify the current transform matrix. +const TRANSFORM_METHODS = new Set([ + "setTransform", + "transform", + "resetTransform", + "translate", + "rotate", + "scale", +]); + +// Maps every tracked context property to a function that reads its current +// value from a CanvasRenderingContext2D. Covers directly-readable properties +// (TRACKED_CTX_PROPS) and method-read ones (lineDash, transform). +const CTX_PROP_READERS = new Map([ + ...Array.from(TRACKED_CTX_PROPS, p => [p, ctx => ctx[p]]), + ["lineDash", ctx => ctx.getLineDash()], + [ + "transform", + ctx => { + const { a, b, c, d, e, f } = ctx.getTransform(); + return { a, b, c, d, e, f }; + }, + ], +]); + +const MATHML_NS = "http://www.w3.org/1998/Math/MathML"; + +function mEl(tag, ...children) { + const el = document.createElementNS(MATHML_NS, tag); + el.append(...children); + return el; +} + +function formatMatrixValue(v) { + return Number.isInteger(v) ? String(v) : String(parseFloat(v.toFixed(4))); +} + +function buildTransformMathML({ a, b, c, d, e, f }) { + const mnEls = {}; + for (const [k, v] of Object.entries({ a, b, c, d, e, f })) { + mnEls[k] = mEl("mn", formatMatrixValue(v)); + } + const math = mEl( + "math", + mEl( + "mrow", + mEl("mo", "["), + mEl( + "mtable", + mEl( + "mtr", + mEl("mtd", mnEls.a), + mEl("mtd", mnEls.c), + mEl("mtd", mnEls.e) + ), + mEl( + "mtr", + mEl("mtd", mnEls.b), + mEl("mtd", mnEls.d), + mEl("mtd", mnEls.f) + ), + mEl( + "mtr", + mEl("mtd", mEl("mn", "0")), + mEl("mtd", mEl("mn", "0")), + mEl("mtd", mEl("mn", "1")) + ) + ), + mEl("mo", "]") + ) + ); + return { math, mnEls }; +} + +// Wrap a CanvasRenderingContext2D so every setter and setLineDash/restore call +// updates `ctxStates` and the live DOM elements for the given label. +function wrapContext(ctx, label) { + const state = new Map(); + for (const [prop, read] of CTX_PROP_READERS) { + state.set(prop, read(ctx)); + } + ctxStates.set(label, state); + ctxStateStacks.set(label, []); + ctxStackViewIdx.set(label, null); + // If the panel is already visible (stepping in progress), rebuild it so the + // new context section is added and its live-update entries are registered. + if (gfxStateValueElements.size > 0) { + buildGfxStatePanel(); + } + + return new Proxy(ctx, { + set(target, prop, value) { + target[prop] = value; + if (TRACKED_CTX_PROPS.has(prop)) { + state.set(prop, value); + updateGfxStatePropEl(label, prop, value); + } + return true; + }, + get(target, prop) { + const val = target[prop]; + if (typeof val !== "function") { + return val; + } + if (prop === "save") { + return function (...args) { + const result = val.apply(target, args); + ctxStateStacks.get(label).push(copyState(state)); + updateGfxStateStackNav(label); + return result; + }; + } + if (prop === "restore") { + return function (...args) { + const result = val.apply(target, args); + for (const [p, read] of CTX_PROP_READERS) { + const v = read(target); + state.set(p, v); + updateGfxStatePropEl(label, p, v); + } + const stack = ctxStateStacks.get(label); + if (stack.length > 0) { + stack.pop(); + // If the viewed frame was just removed, fall back to current. + const viewIndex = ctxStackViewIdx.get(label); + if (viewIndex !== null && viewIndex >= stack.length) { + ctxStackViewIdx.set(label, null); + showGfxState(label); + } + updateGfxStateStackNav(label); + } + return result; + }; + } + if (prop === "setLineDash") { + return function (segments) { + val.call(target, segments); + const dash = target.getLineDash(); + state.set("lineDash", dash); + updateGfxStatePropEl(label, "lineDash", dash); + }; + } + if (TRANSFORM_METHODS.has(prop)) { + return function (...args) { + const result = val.apply(target, args); + const { a, b, c, d, e, f } = target.getTransform(); + const tf = { a, b, c, d, e, f }; + state.set("transform", tf); + updateGfxStatePropEl(label, "transform", tf); + return result; + }; + } + return val.bind(target); + }, + }); +} + +// Custom CanvasFactory that tracks temporary canvases created during rendering. +// When stepping, each temporary canvas is shown below the main page canvas so +// the user can inspect intermediate compositing targets (masks, patterns, etc). +class DebugCanvasFactory extends DOMCanvasFactory { + // Wrapper objects currently alive: { canvas, context, wrapper, label }. + #alive = []; + + // getDocument passes { ownerDocument, enableHWA } to the constructor. + constructor({ ownerDocument, enableHWA } = {}) { + super({ ownerDocument: ownerDocument ?? document, enableHWA }); + } + + create(width, height) { + const canvasAndCtx = super.create(width, height); + const label = `Temp ${this.#alive.length + 1}`; + canvasAndCtx.context = wrapCanvasGetContext(canvasAndCtx.canvas, label); + if (globalThis.StepperManager._active !== null) { + this.#attach(canvasAndCtx, width, height, label); + } + return canvasAndCtx; + } + + reset(canvasAndCtx, width, height) { + super.reset(canvasAndCtx, width, height); + const entry = this.#alive.find(e => e.canvasAndCtx === canvasAndCtx); + if (entry) { + entry.labelEl.textContent = `${entry.labelEl.textContent.split("—")[0].trim()} — ${width}×${height}`; + } + } + + destroy(canvasAndCtx) { + const idx = this.#alive.findIndex(e => e.canvasAndCtx === canvasAndCtx); + if (idx !== -1) { + this.#alive[idx].wrapper.remove(); + this.#alive.splice(idx, 1); + } + super.destroy(canvasAndCtx); + } + + // Show all currently-alive canvases (called when stepping starts). + showAll() { + for (const entry of this.#alive) { + if (!entry.wrapper.isConnected) { + this.#attachWrapper(entry); + } + } + } + + // Remove all temporary canvases from the DOM and clear tracking state. + clear() { + for (const entry of this.#alive) { + entry.wrapper.remove(); + entry.canvasAndCtx.canvas.width = 0; + entry.canvasAndCtx.canvas.height = 0; + } + this.#alive.length = 0; + } + + #attach(canvasAndCtx, width, height, ctxLabel) { + const wrapper = document.createElement("div"); + wrapper.className = "temp-canvas-wrapper"; + wrapper.addEventListener("click", () => scrollToGfxStateSection(ctxLabel)); + const labelEl = document.createElement("div"); + labelEl.className = "temp-canvas-label"; + labelEl.textContent = `${ctxLabel} — ${width}×${height}`; + wrapper.append(labelEl, canvasAndCtx.canvas); + const entry = { canvasAndCtx, wrapper, labelEl }; + this.#alive.push(entry); + this.#attachWrapper(entry); + } + + #attachWrapper(entry) { + document.getElementById("canvas-scroll").append(entry.wrapper); + } +} + // Cache for getRawData results, keyed by "num:gen". Cleared on each new // document. const refCache = new Map(); -function updateParseCSClass() { +// Cached media query for dark mode detection. +const prefersDark = window.matchMedia("(prefers-color-scheme: dark)"); + +// Cached media query for forced-colors (high-contrast) mode detection. +const prefersHCM = window.matchMedia("(forced-colors: active)"); + +// Keep --dpr in sync so CSS can scale temp canvases correctly. +function updateDPR() { + document.documentElement.style.setProperty( + "--dpr", + window.devicePixelRatio || 1 + ); +} +updateDPR(); +window + .matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`) + .addEventListener("change", updateDPR); + +// Stepper for pausing/stepping through op list rendering. +// Implements the interface expected by InternalRenderTask (pdfBug mode). +class ViewerStepper { + #continueCallback = null; + + // Pass resumeAt to re-pause at a specific index (e.g. after a zoom). + constructor(resumeAt = null) { + this.nextBreakPoint = resumeAt ?? this.#findNextAfter(-1); + this.currentIdx = -1; + } + + // Called by executeOperatorList when execution reaches nextBreakPoint. + breakIt(i, continueCallback) { + this.currentIdx = i; + this.#continueCallback = continueCallback; + onStepped(i); + } + + // Advance one instruction then pause again. + stepNext() { + if (!this.#continueCallback) { + return; + } + this.nextBreakPoint = this.currentIdx + 1; + const cb = this.#continueCallback; + this.#continueCallback = null; + cb(); + } + + // Continue until the next breakpoint (or end). + continueToBreakpoint() { + if (!this.#continueCallback) { + return; + } + this.nextBreakPoint = this.#findNextAfter(this.currentIdx); + const cb = this.#continueCallback; + this.#continueCallback = null; + cb(); + } + + #findNextAfter(idx) { + let next = null; + for (const bp of breakpoints) { + if (bp > idx && (next === null || bp < next)) { + next = bp; + } + } + return next; + } + + // Called by InternalRenderTask when the operator list grows (streaming). + updateOperatorList() {} + + // Called by InternalRenderTask to initialise the stepper. + init() {} + + // Called by InternalRenderTask after recording bboxes (pdfBug mode). + setOperatorBBoxes() {} + + getNextBreakPoint() { + return this.nextBreakPoint; + } +} + +// Install a StepperManager so InternalRenderTask (pdfBug mode) picks it up. +// A new instance is set on each redraw; null means no stepping. +globalThis.StepperManager = { + get enabled() { + return globalThis.StepperManager._active !== null; + }, + _active: null, + create() { + return globalThis.StepperManager._active; + }, +}; + +// Color properties whose value is rendered as a swatch. +const COLOR_CTX_PROPS = new Set(["fillStyle", "strokeStyle", "shadowColor"]); + +function scrollToGfxStateSection(label) { document - .getElementById("tree") - .classList.toggle( - "parse-cs-active", - document.getElementById("parse-content-stream").checked + .querySelector(`#gfx-state-panel [data-ctx-label="${CSS.escape(label)}"]`) + ?.scrollIntoView({ block: "nearest" }); +} + +// Navigate the save/restore stack view for a context. +// delta = -1 → older (prev) frame; +1 → newer (next) frame. +function navigateGfxStateStack(label, delta) { + const stack = ctxStateStacks.get(label) ?? []; + const viewIndex = ctxStackViewIdx.get(label); + let newViewIndex; + if (delta < 0) { + newViewIndex = viewIndex === null ? stack.length - 1 : viewIndex - 1; + if (newViewIndex < 0) { + return; + } + } else { + if (viewIndex === null) { + return; + } + newViewIndex = viewIndex >= stack.length - 1 ? null : viewIndex + 1; + } + ctxStackViewIdx.set(label, newViewIndex); + showGfxState(label); + updateGfxStateStackNav(label); +} + +function buildGfxStatePanel() { + const panel = document.getElementById("gfx-state-panel"); + const resizer = document.getElementById("op-gfx-state-resizer"); + panel.hidden = false; + resizer.hidden = false; + panel.replaceChildren(); + gfxStateValueElements.clear(); + gfxStateNavElements.clear(); + for (const [ctxLabel, state] of ctxStates) { + const propEls = new Map(); + gfxStateValueElements.set(ctxLabel, propEls); + + const section = document.createElement("div"); + section.className = "gfx-state-section"; + section.dataset.ctxLabel = ctxLabel; + + // Title row with label and stack-navigation arrows. + const title = document.createElement("div"); + title.className = "gfx-state-title"; + + const titleLabel = document.createElement("span"); + titleLabel.textContent = ctxLabel; + + const navContainer = document.createElement("span"); + navContainer.className = "gfx-state-stack-nav"; + navContainer.hidden = true; + + const prevBtn = document.createElement("button"); + prevBtn.className = "gfx-state-stack-btn"; + prevBtn.setAttribute("aria-label", "View older saved state"); + prevBtn.textContent = "←"; + + const pos = document.createElement("span"); + pos.className = "gfx-state-stack-pos"; + + const nextBtn = document.createElement("button"); + nextBtn.className = "gfx-state-stack-btn"; + nextBtn.setAttribute("aria-label", "View newer saved state"); + nextBtn.textContent = "→"; + + navContainer.append(prevBtn, pos, nextBtn); + title.append(titleLabel, navContainer); + section.append(title); + + gfxStateNavElements.set(ctxLabel, { + container: navContainer, + prevBtn, + pos, + nextBtn, + }); + + prevBtn.addEventListener("click", () => + navigateGfxStateStack(ctxLabel, -1) ); + nextBtn.addEventListener("click", () => + navigateGfxStateStack(ctxLabel, +1) + ); + + for (const [prop, value] of state) { + const row = document.createElement("div"); + row.className = "gfx-state-row"; + + const key = document.createElement("span"); + key.className = "gfx-state-key"; + key.textContent = prop; + + row.append(key); + + if (prop === "transform") { + const { math, mnEls } = buildTransformMathML(value); + row.append(math); + propEls.set(prop, { valEl: math, swatchEl: null, mnEls }); + } else { + const val = document.createElement("span"); + val.className = "gfx-state-val"; + const text = formatCtxValue(value); + val.textContent = text; + val.title = text; + let swatchEl = null; + if (COLOR_CTX_PROPS.has(prop)) { + swatchEl = document.createElement("span"); + swatchEl.className = "color-swatch"; + swatchEl.style.background = String(value); + row.append(swatchEl); + } + row.append(val); + propEls.set(prop, { valEl: val, swatchEl }); + } + section.append(row); + } + panel.append(section); + + // Apply the correct state for the current view index (may be a saved + // frame). + showGfxState(ctxLabel); + updateGfxStateStackNav(ctxLabel); + } +} + +function onStepped(i) { + // Remove previous paused highlight. + if (pausedAtIdx !== null) { + opLines[pausedAtIdx]?.classList.remove("paused"); + } + pausedAtIdx = i; + opLines[i]?.classList.add("paused"); + opLines[i]?.scrollIntoView({ block: "nearest" }); + stepBtn.disabled = false; + continueBtn.disabled = false; + buildGfxStatePanel(); +} + +function clearPausedState() { + if (pausedAtIdx !== null) { + opLines[pausedAtIdx]?.classList.remove("paused"); + pausedAtIdx = null; + } + globalThis.StepperManager._active = null; + stepBtn.disabled = true; + continueBtn.disabled = true; + document.getElementById("gfx-state-panel").hidden = true; + document.getElementById("op-gfx-state-resizer").hidden = true; +} + +// Count of in-flight getRawData calls; drives the body "loading" cursor. +let loadingCount = 0; +function markLoading(delta) { + loadingCount += delta; + document.body.classList.toggle("loading", loadingCount > 0); +} + +function resetRenderView() { + debugViewGeneration++; + currentRenderTask?.cancel(); + currentRenderTask = null; + renderedPage?.cleanup(); + renderedPage = null; + renderScale = null; + currentOpList = null; + originalColors.clear(); + breakpoints.clear(); + opLines = []; + selectedOpLine = null; + clearPausedState(); + + // If a toolbar wrapper was added in showRenderView, unwrap it. + // #op-list-panel is inside opListBody inside the wrapper; replaceWith + // extracts it, discarding the toolbar and line-number column automatically. + const opListWrapper = document.querySelector(".op-list-panel-wrapper"); + if (opListWrapper) { + const opListPanelEl = document.getElementById("op-list-panel"); + opListPanelEl.style.flex = ""; + opListWrapper.replaceWith(opListPanelEl); + } + document.getElementById("op-list").replaceChildren(); + document.getElementById("op-detail-panel").replaceChildren(); + document.getElementById("gfx-state-panel").replaceChildren(); + ctxStates.clear(); + ctxStateStacks.clear(); + ctxStackViewIdx.clear(); + gfxStateValueElements.clear(); + gfxStateNavElements.clear(); + pdfDoc?.canvasFactory.clear(); + + const mainCanvas = document.getElementById("render-canvas"); + mainCanvas.width = mainCanvas.height = 0; + const highlightCanvas = document.getElementById("highlight-canvas"); + highlightCanvas.width = highlightCanvas.height = 0; + + document.getElementById("zoom-level").textContent = ""; + document.getElementById("zoom-out-btn").disabled = false; + document.getElementById("zoom-in-btn").disabled = false; + document.getElementById("redraw-btn").disabled = true; } async function loadTree(data, rootLabel = null) { + currentPage = typeof data.page === "number" ? data.page : null; + document.getElementById("debug-btn").hidden = currentPage === null; + document.getElementById("debug-back-btn").hidden = true; + resetRenderView(); + document.getElementById("debug-view").hidden = true; + document.getElementById("tree").hidden = false; + const treeEl = document.getElementById("tree"); - const rootNode = renderNode(rootLabel, await pdfDoc.getRawData(data), pdfDoc); - treeEl.replaceChildren(rootNode); - rootNode.querySelector("[role='button']").click(); + treeEl.classList.add("loading"); + markLoading(1); + try { + const rootNode = renderNode( + rootLabel, + await pdfDoc.getRawData(data), + pdfDoc + ); + treeEl.replaceChildren(rootNode); + rootNode.querySelector("[role='button']").click(); + const firstTreeItem = treeEl.querySelector("[role='treeitem']"); + if (firstTreeItem) { + firstTreeItem.tabIndex = 0; + } + } finally { + treeEl.classList.remove("loading"); + markLoading(-1); + } } async function openDocument(source, name) { @@ -83,11 +844,21 @@ async function openDocument(source, name) { refCache.clear(); if (pdfDoc) { + resetRenderView(); await pdfDoc.destroy(); pdfDoc = null; } - const loadingTask = getDocument({ ...source, wasmUrl: "wasm/" }); + const loadingTask = getDocument({ + ...source, + cMapUrl: "../external/bcmaps/", + wasmUrl: "../web/wasm/", + iccUrl: "../external/iccs/", + standardFontDataUrl: "../external/standard_fonts/", + useWorkerFetch: true, + pdfBug: true, + CanvasFactory: DebugCanvasFactory, + }); loadingTask.onPassword = (updateCallback, reason) => { const dialog = document.getElementById("password-dialog"); const title = document.getElementById("password-dialog-title"); @@ -128,19 +899,114 @@ async function openDocument(source, name) { function showError(err) { document.getElementById("status").textContent = "Error: " + err.message; - const msg = document.createElement("div"); - msg.setAttribute("role", "alert"); - msg.textContent = err.message; - document.getElementById("tree").append(msg); + document.getElementById("tree").append(makeErrorEl(err.message)); } -document.getElementById("file-input").value = ""; +// Creates a role=alert div with "Error: " text. +function makeErrorEl(message) { + const el = document.createElement("div"); + el.setAttribute("role", "alert"); + el.textContent = `Error: ${message}`; + return el; +} -document - .getElementById("parse-content-stream") - .addEventListener("change", updateParseCSClass); +document.getElementById("file-input").value = ""; -updateParseCSClass(); +document.getElementById("tree").addEventListener("keydown", e => { + const treeEl = document.getElementById("tree"); + // Collect all visible treeitems: those not inside a [hidden] group ancestor. + const allItems = Array.from(treeEl.querySelectorAll("[role='treeitem']")); + const visibleItems = allItems.filter(item => { + let el = item.parentElement; + while (el && el !== treeEl) { + if (el.getAttribute("role") === "group" && el.hidden) { + return false; + } + el = el.parentElement; + } + return true; + }); + const focused = document.activeElement; + const idx = visibleItems.indexOf(focused); + + if (e.key === "ArrowDown") { + e.preventDefault(); + const next = visibleItems[idx + 1]; + if (next) { + focused.tabIndex = -1; + next.tabIndex = 0; + next.focus(); + } + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const prev = visibleItems[idx - 1]; + if (prev) { + focused.tabIndex = -1; + prev.tabIndex = 0; + prev.focus(); + } + } else if (e.key === "ArrowRight") { + e.preventDefault(); + if (!focused || idx < 0) { + return; + } + // Find the toggle button inside this treeitem (not inside a child group). + const toggle = focused.querySelector(":scope > [role='button']"); + if (!toggle) { + return; + } + const expanded = toggle.getAttribute("aria-expanded"); + if (expanded === "false") { + toggle.click(); + } else { + // Already expanded — move to first child treeitem. + const group = focused.querySelector( + ":scope > [role='group']:not(.hidden)" + ); + const firstChild = group?.querySelector("[role='treeitem']"); + if (firstChild) { + focused.tabIndex = -1; + firstChild.tabIndex = 0; + firstChild.focus(); + } + } + } else if (e.key === "ArrowLeft") { + e.preventDefault(); + if (!focused || idx < 0) { + return; + } + const toggle = focused.querySelector(":scope > [role='button']"); + const expanded = toggle?.getAttribute("aria-expanded"); + if (expanded === "true") { + toggle.click(); + } else { + // Collapsed or no children — move to parent treeitem. + const parentGroup = focused.closest("[role='group']"); + const parentItem = parentGroup?.closest("[role='treeitem']"); + if (parentItem) { + focused.tabIndex = -1; + parentItem.tabIndex = 0; + parentItem.focus(); + } + } + } else if (e.key === "Home") { + e.preventDefault(); + const first = visibleItems[0]; + if (first && first !== focused) { + focused.tabIndex = -1; + first.tabIndex = 0; + first.focus(); + } + } else if (e.key === "End") { + e.preventDefault(); + const last = visibleItems.at(-1); + if (last && last !== focused) { + focused.tabIndex = -1; + last.tabIndex = 0; + last.focus(); + } + } +}); document.getElementById("file-input").addEventListener("change", async e => { const file = e.target.files[0]; @@ -204,23 +1070,1013 @@ document.getElementById("goto-input").addEventListener("keydown", async e => { return; } if ( - result.page !== undefined && - (result.page < 1 || result.page > pdfDoc.numPages) + result.page !== undefined && + (result.page < 1 || result.page > pdfDoc.numPages) + ) { + input.setAttribute("aria-invalid", "true"); + return; + } + input.setAttribute("aria-invalid", "false"); + await (result.page !== undefined + ? loadTree({ page: result.page }) + : loadTree({ ref: result.ref })); +}); + +document.getElementById("goto-input").addEventListener("input", e => { + if (e.target.value.trim() === "") { + e.target.setAttribute("aria-invalid", "false"); + } +}); + +document.getElementById("debug-btn").addEventListener("click", async () => { + document.getElementById("debug-btn").hidden = true; + document.getElementById("debug-back-btn").hidden = false; + document.getElementById("tree").hidden = true; + document.getElementById("debug-view").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). + if (currentOpList === null) { + await showRenderView(currentPage); + } +}); + +document.getElementById("debug-back-btn").addEventListener("click", () => { + document.getElementById("debug-back-btn").hidden = true; + document.getElementById("debug-btn").hidden = false; + document.getElementById("debug-view").hidden = true; + document.getElementById("tree").hidden = false; +}); + +/** + * Attach a drag-to-resize handler to a resizer element. + * @param {string} resizerId ID of the resizer element. + * @param {string} firstId ID of the first panel (before the resizer). + * @param {string} secondId ID of the second panel (after the resizer). + * @param {"horizontal"|"vertical"} direction + * @param {number} minSize Minimum size in px for each panel. + * @param {Function} [onDone] Optional callback invoked after drag ends. + */ +function updateResizerAria(resizer, firstPanel, containerSize, resizerSize) { + const dimension = + resizer.getAttribute("aria-orientation") === "vertical" + ? "width" + : "height"; + const total = containerSize - resizerSize; + if (total <= 0) { + return; + } + const firstSize = firstPanel.getBoundingClientRect()[dimension]; + resizer.setAttribute( + "aria-valuenow", + String(Math.round((firstSize / containerSize) * 100)) + ); +} + +function makeResizer( + resizerId, + firstArg, + secondArg, + direction, + minSize, + onDone +) { + const isHorizontal = direction === "horizontal"; + const axis = isHorizontal ? "clientX" : "clientY"; + const dimension = isHorizontal ? "width" : "height"; + const cursor = isHorizontal ? "col-resize" : "row-resize"; + + const getFirst = + typeof firstArg === "function" + ? firstArg + : () => document.getElementById(firstArg); + const getSecond = + typeof secondArg === "function" + ? secondArg + : () => document.getElementById(secondArg); + + const resizer = document.getElementById(resizerId); + const minPct = Math.round( + (minSize / + Math.max( + 1, + resizer.parentElement.getBoundingClientRect()[dimension] - + resizer.getBoundingClientRect()[dimension] + )) * + 100 + ); + resizer.setAttribute("aria-valuemin", String(minPct)); + resizer.setAttribute("aria-valuemax", String(100 - minPct)); + resizer.setAttribute("aria-valuenow", "50"); + + resizer.addEventListener("mousedown", e => { + e.preventDefault(); + const firstPanel = getFirst(); + const secondPanel = getSecond(); + const startPos = e[axis]; + const containerSize = + resizer.parentElement.getBoundingClientRect()[dimension]; + const resizerSize = resizer.getBoundingClientRect()[dimension]; + const total = containerSize - resizerSize; + // After the first drag, panels have inline "N 1 0px" flex styles. Using + // getBoundingClientRect() as the baseline is wrong here because sub-pixel + // rendering makes the measured width slightly less than the grow value, + // causing the panel to barely move for the first pixel(s) of the drag. + // Parsing the grow value from the inline style gives the correct baseline. + const inlineFirst = parseFloat(firstPanel.style.flex); + const startFirst = isNaN(inlineFirst) + ? firstPanel.getBoundingClientRect()[dimension] + : inlineFirst; + + resizer.classList.add("dragging"); + document.body.style.cursor = cursor; + document.body.style.userSelect = "none"; + + const onMouseMove = ev => { + const delta = ev[axis] - startPos; + const newFirst = Math.max( + minSize, + Math.min(total - minSize, startFirst + delta) + ); + firstPanel.style.flex = `${newFirst} 1 0px`; + secondPanel.style.flex = `${total - newFirst} 1 0px`; + updateResizerAria(resizer, firstPanel, containerSize, resizerSize); + }; + + const onMouseUp = () => { + resizer.classList.remove("dragging"); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + // No flex re-assignment needed: onMouseMove already set grow ratios. + // Re-measuring here would introduce a 1px jump due to sub-pixel + // rounding (getBoundingClientRect returns integers while grow-ratio + // flex-basis values are fractional). + updateResizerAria( + resizer, + getFirst(), + containerSize, + resizer.getBoundingClientRect()[dimension] + ); + onDone?.(); + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }); + + resizer.addEventListener("keydown", e => { + const firstPanel = getFirst(); + const secondPanel = getSecond(); + const containerSize = + resizer.parentElement.getBoundingClientRect()[dimension]; + const resizerSize = resizer.getBoundingClientRect()[dimension]; + const total = containerSize - resizerSize; + const step = e.shiftKey ? 50 : 10; + + let delta = 0; + if (e.key === "ArrowUp" || e.key === "ArrowLeft") { + delta = -step; + } else if (e.key === "ArrowDown" || e.key === "ArrowRight") { + delta = step; + } else { + return; + } + e.preventDefault(); + + const inlineCurrent = parseFloat(firstPanel.style.flex); + const currentFirst = isNaN(inlineCurrent) + ? firstPanel.getBoundingClientRect()[dimension] + : inlineCurrent; + const newFirst = Math.max( + minSize, + Math.min(total - minSize, currentFirst + delta) + ); + firstPanel.style.flex = `${newFirst} 1 0px`; + secondPanel.style.flex = `${total - newFirst} 1 0px`; + updateResizerAria(resizer, firstPanel, containerSize, resizerSize); + onDone?.(); + }); +} + +// op-list-panel is wrapped in op-list-panel-wrapper after showRenderView(). +// The wrapper is the actual flex sibling of gfx-state-panel in op-top-row, +// so target it when present; fall back to op-list-panel otherwise. +makeResizer( + "op-gfx-state-resizer", + () => + document.querySelector(".op-list-panel-wrapper") ?? + document.getElementById("op-list-panel"), + "gfx-state-panel", + "horizontal", + 60 +); +makeResizer("op-resizer", "op-top-row", "op-detail-panel", "vertical", 40); +makeResizer( + "render-resizer", + "op-left-col", + "canvas-panel", + "horizontal", + 100, + renderCanvas +); + +function getFitScale() { + const canvasScroll = document.getElementById("canvas-scroll"); + return ( + (canvasScroll.clientWidth - 24) / + renderedPage.getViewport({ scale: 1 }).width + ); +} + +const MIN_ZOOM = 0.1; +const MAX_ZOOM = 10; +const ZOOM_STEP = 1.25; + +function 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. + const resumeAt = + pausedAtIdx ?? globalThis.StepperManager._active?.nextBreakPoint ?? null; + clearPausedState(); + renderScale = newScale; + if (resumeAt !== null) { + globalThis.StepperManager._active = new ViewerStepper(resumeAt); + } + return renderCanvas(); +} + +document + .getElementById("zoom-in-btn") + .addEventListener("click", () => + zoomRenderCanvas( + Math.min(MAX_ZOOM, (renderScale ?? getFitScale()) * ZOOM_STEP) + ) + ); +document + .getElementById("zoom-out-btn") + .addEventListener("click", () => + zoomRenderCanvas( + Math.max(MIN_ZOOM, (renderScale ?? getFitScale()) / ZOOM_STEP) + ) + ); + +document.getElementById("redraw-btn").addEventListener("click", async () => { + if (!renderedPage || !currentOpList) { + return; + } + clearPausedState(); + // Reset recorded bboxes so they get re-recorded for the modified op list. + renderedPage.recordedBBoxes = null; + if (breakpoints.size > 0) { + globalThis.StepperManager._active = new ViewerStepper(); + } + await renderCanvas(); +}); + +const stepBtn = document.getElementById("step-btn"); +const continueBtn = document.getElementById("continue-btn"); +const opDetailEl = document.getElementById("op-detail-panel"); + +stepBtn.addEventListener("click", () => { + globalThis.StepperManager._active?.stepNext(); +}); + +continueBtn.addEventListener("click", () => { + globalThis.StepperManager._active?.continueToBreakpoint(); +}); + +document.addEventListener("keydown", e => { + if ( + e.target.matches("input, textarea, [contenteditable]") || + e.altKey || + e.ctrlKey || + e.metaKey + ) { + return; + } + const stepper = globalThis.StepperManager._active; + if (!stepper) { + return; + } + if (e.key === "s") { + e.preventDefault(); + stepper.stepNext(); + } else if (e.key === "c") { + e.preventDefault(); + stepper.continueToBreakpoint(); + } +}); + +// Formats a glyph items array as: "text" kerning "more text" … +function formatGlyphItems(items) { + const parts = []; + let str = ""; + for (const item of items) { + if (typeof item === "number") { + if (str) { + parts.push(JSON.stringify(str)); + str = ""; + } + parts.push(String(Math.round(item * 100) / 100)); + } else if (item?.unicode) { + str += item.unicode; + } + } + if (str) { + parts.push(JSON.stringify(str)); + } + return parts.join(" "); +} + +/** + * Format an operator argument for display. + * @param {*} arg The argument value. + * @param {boolean} full true → expand fully (detail panel); + * false → truncate for compact list display. + */ +function formatArg(arg, full) { + if (arg === null || arg === undefined) { + return full ? "null" : ""; + } + if (typeof arg === "number") { + return Number.isInteger(arg) + ? String(arg) + : String(Math.round(arg * 10000) / 10000); + } + if (typeof arg === "string") { + return JSON.stringify(arg); + } + if (typeof arg === "boolean") { + return String(arg); + } + if (ArrayBuffer.isView(arg)) { + if (!full && arg.length > 8) { + return `<${arg.length} values>`; + } + const fmt = n => (Number.isInteger(n) ? n : Math.round(n * 1000) / 1000); + return `[${Array.from(arg).map(fmt).join(" ")}]`; + } + if (Array.isArray(arg)) { + if (arg.length === 0) { + return "[]"; + } + if (!full && arg.length > 4) { + return `[…${arg.length}]`; + } + return `[${arg.map(a => formatArg(a, full)).join(", ")}]`; + } + if (typeof arg === "object") { + if (!full) { + return "{…}"; + } + return `{${Object.entries(arg) + .map(([k, v]) => `${k}: ${formatArg(v, true)}`) + .join(", ")}}`; + } + return String(arg); +} + +const formatOpArg = arg => formatArg(arg, false); +const formatFullArg = arg => formatArg(arg, true); + +function showOpDetail(name, args, opIdx) { + const detailEl = opDetailEl; + detailEl.replaceChildren(); + + // Always build args into a .detail-args-col so it can be placed in a + // .detail-body alongside a path preview or image preview on the right. + const argsContainer = document.createElement("div"); + argsContainer.className = "detail-args-col"; + + const header = document.createElement("div"); + header.className = "detail-name"; + header.textContent = name; + argsContainer.append(header); + + if (!args || args.length === 0) { + const none = document.createElement("div"); + none.className = "detail-empty"; + none.textContent = "(no arguments)"; + argsContainer.append(none); + detailEl.append(argsContainer); + return; + } + + const imagePreviews = []; + for (let i = 0; i < args.length; i++) { + const row = document.createElement("div"); + row.className = "detail-row"; + const idx = document.createElement("span"); + idx.className = "detail-idx"; + idx.textContent = `[${i}]`; + const val = document.createElement("span"); + val.className = "detail-val"; + if (name === "showText" && i === 0 && Array.isArray(args[0])) { + val.textContent = formatGlyphItems(args[0]); + } else if ( + name === "constructPath" && + i === 0 && + typeof args[0] === "number" + ) { + val.textContent = OPS_TO_NAME[args[0]] ?? String(args[0]); + } else { + val.textContent = formatFullArg(args[i]); + } + row.append(idx); + if (typeof args[i] === "string" && /^#[0-9a-f]{6}$/i.test(args[i])) { + const argIdx = i; + const originalHex = originalColors.get(opIdx); + if (originalHex && args[i] !== originalHex) { + val.classList.add("changed-value"); + val.title = `Original: ${originalHex}`; + } + row.append( + makeColorSwatch(args[i], newHex => { + args[argIdx] = newHex; + val.textContent = JSON.stringify(newHex); + const changed = originalHex && newHex !== originalHex; + val.classList.toggle("changed-value", !!changed); + val.title = changed ? `Original: ${originalHex}` : ""; + // Also update the swatch and arg span in the selected op list line. + const listSwatch = document.querySelector( + "#op-list .op-line.selected .color-swatch" + ); + if (listSwatch) { + listSwatch.style.background = newHex; + } + const listArgSpan = document.querySelector( + "#op-list .op-line.selected .op-arg" + ); + if (listArgSpan) { + listArgSpan.textContent = JSON.stringify(newHex); + listArgSpan.classList.toggle("changed-value", !!changed); + listArgSpan.title = changed ? `Original: ${originalHex}` : ""; + } + }) + ); + } + row.append(val); + argsContainer.append(row); + if (typeof args[i] === "string" && args[i].startsWith("img_")) { + const preview = makeImageArgPreview(args[i]); + if (preview) { + imagePreviews.push(preview); + } + } + } + + // Assemble the final layout: constructPath gets a path preview on the right; + // image ops get an image column on the right; others just use argsContainer. + if (name === "constructPath") { + // args[1] is [Float32Array|null], args[2] is [minX,minY,maxX,maxY]|null + const data = Array.isArray(args?.[1]) ? args[1][0] : null; + const body = document.createElement("div"); + body.className = "detail-body"; + body.append(argsContainer, renderPathPreview(data, args?.[2] ?? null)); + detailEl.append(body); + } else if (imagePreviews.length > 0) { + const imgCol = document.createElement("div"); + imgCol.className = "detail-img-col"; + imgCol.append(...imagePreviews); + const body = document.createElement("div"); + body.className = "detail-body"; + body.append(argsContainer, imgCol); + detailEl.append(body); + } else { + detailEl.append(argsContainer); + } +} + +// Render an img_ argument value into a canvas preview using the decoded image +// stored in renderedPage.objs (or commonObjs for global images starting with +// g_). Handles ImageBitmap and raw pixel data with ImageKind values +// GRAYSCALE_1BPP, RGB_24BPP, and RGBA_32BPP. +function makeImageArgPreview(name) { + const objStore = name.startsWith("g_") + ? renderedPage?.commonObjs + : renderedPage?.objs; + if (!objStore?.has(name)) { + return null; + } + const imgObj = objStore.get(name); + if (!imgObj) { + return null; + } + const { width, height } = imgObj; + const canvas = document.createElement("canvas"); + canvas.className = "image-preview"; + canvas.width = width; + canvas.height = height; + canvas.style.aspectRatio = `${width} / ${height}`; + canvas.setAttribute("aria-label", `${name} ${width}×${height}`); + const ctx = canvas.getContext("2d"); + + // Fast path: if the browser already decoded it as an ImageBitmap, draw it. + if (imgObj.bitmap instanceof ImageBitmap) { + ctx.drawImage(imgObj.bitmap, 0, 0); + return canvas; + } + + // Slow path: convert raw pixel data to RGBA for putImageData. + const { data, kind } = imgObj; + let rgba; + if (kind === 3 /* RGBA_32BPP */) { + rgba = new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength); + } else if (kind === 2 /* RGB_24BPP */) { + const pixels = width * height; + rgba = new Uint8ClampedArray(pixels * 4); + for (let i = 0, j = 0; i < pixels; i++, j += 3) { + rgba[i * 4] = data[j]; + rgba[i * 4 + 1] = data[j + 1]; + rgba[i * 4 + 2] = data[j + 2]; + rgba[i * 4 + 3] = 255; + } + } else if (kind === 1 /* GRAYSCALE_1BPP */) { + const rowBytes = (width + 7) >> 3; + rgba = new Uint8ClampedArray(width * height * 4); + for (let row = 0; row < height; row++) { + const srcRow = row * rowBytes; + const dstRow = row * width * 4; + for (let col = 0; col < width; col++) { + const bit = (data[srcRow + (col >> 3)] >> (7 - (col & 7))) & 1; + const v = bit ? 255 : 0; + rgba[dstRow + col * 4] = v; + rgba[dstRow + col * 4 + 1] = v; + rgba[dstRow + col * 4 + 2] = v; + rgba[dstRow + col * 4 + 3] = 255; + } + } + } else { + return null; + } + ctx.putImageData(new ImageData(rgba, width, height), 0, 0); + return canvas; +} + +async function renderCanvas() { + if (!renderedPage) { + return null; + } + + // Cancel any in-progress render before starting a new one. + currentRenderTask?.cancel(); + currentRenderTask = null; + + const highlight = document.getElementById("highlight-canvas"); + const dpr = window.devicePixelRatio || 1; + const scale = renderScale ?? getFitScale(); + document.getElementById("zoom-level").textContent = + `${Math.round(scale * 100)}%`; + document.getElementById("zoom-out-btn").disabled = scale <= MIN_ZOOM; + document.getElementById("zoom-in-btn").disabled = scale >= MAX_ZOOM; + const viewport = renderedPage.getViewport({ scale: scale * dpr }); + const cssW = `${viewport.width / dpr}px`; + const cssH = `${viewport.height / dpr}px`; + + // Size the highlight canvas immediately so it stays in sync. + highlight.width = viewport.width; + highlight.height = viewport.height; + highlight.style.width = cssW; + highlight.style.height = cssH; + + // Render into a fresh canvas. When stepping, insert it into the DOM + // immediately so the user sees each instruction drawn live. For normal + // renders, swap only after completion so there's no blank flash. + const newCanvas = document.createElement("canvas"); + newCanvas.id = "render-canvas"; + newCanvas.width = viewport.width; + newCanvas.height = viewport.height; + newCanvas.style.width = cssW; + newCanvas.style.height = cssH; + newCanvas.addEventListener("click", () => scrollToGfxStateSection("Page")); + + const isStepping = globalThis.StepperManager._active !== null; + if (isStepping) { + const oldCanvas = document.getElementById("render-canvas"); + oldCanvas.width = oldCanvas.height = 0; + oldCanvas.replaceWith(newCanvas); + // Show any temporary canvases that survived from the previous render + // (e.g. after a zoom-while-stepping, the factory may already have entries). + pdfDoc?.canvasFactory.showAll(); + } else { + // Starting a fresh non-stepping render: remove leftover temp canvases. + pdfDoc?.canvasFactory.clear(); + } + + // Record bboxes only on the first render; they stay valid for subsequent + // re-renders because BBoxReader returns normalised [0, 1] fractions. + const firstRender = !renderedPage.recordedBBoxes; + const renderTask = renderedPage.render({ + canvasContext: wrapCanvasGetContext(newCanvas, "Page"), + viewport, + recordOperations: firstRender, + }); + currentRenderTask = renderTask; + + try { + await renderTask.promise; + } catch (err) { + if (err?.name === "RenderingCancelledException") { + return null; + } + throw err; + } finally { + if (currentRenderTask === renderTask) { + currentRenderTask = null; + } + } + + // Render completed fully — stepping session is over. + clearPausedState(); + pdfDoc?.canvasFactory.clear(); + document.getElementById("redraw-btn").disabled = false; + + if (!isStepping) { + // Swap the completed canvas in, replacing the previous one. Zero out the + // old canvas dimensions to release its GPU memory. + const oldCanvas = document.getElementById("render-canvas"); + oldCanvas.width = oldCanvas.height = 0; + oldCanvas.replaceWith(newCanvas); + } + + // 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; +} + +function drawHighlight(opIdx) { + const bboxes = renderedPage?.recordedBBoxes; + if (!bboxes || opIdx >= bboxes.length || bboxes.isEmpty(opIdx)) { + clearHighlight(); + return; + } + const canvas = document.getElementById("render-canvas"); + const highlight = document.getElementById("highlight-canvas"); + const cssW = parseFloat(canvas.style.width); + const cssH = parseFloat(canvas.style.height); + const x = bboxes.minX(opIdx) * cssW; + const y = bboxes.minY(opIdx) * cssH; + const w = (bboxes.maxX(opIdx) - bboxes.minX(opIdx)) * cssW; + const h = (bboxes.maxY(opIdx) - bboxes.minY(opIdx)) * cssH; + const dpr = window.devicePixelRatio || 1; + const ctx = highlight.getContext("2d"); + ctx.clearRect(0, 0, highlight.width, highlight.height); + ctx.save(); + ctx.scale(dpr, dpr); + ctx.fillStyle = "rgba(255, 165, 0, 0.3)"; + ctx.strokeStyle = "rgba(255, 140, 0, 0.9)"; + ctx.lineWidth = 1.5; + ctx.fillRect(x, y, w, h); + ctx.strokeRect(x, y, w, h); + ctx.restore(); +} + +function clearHighlight() { + const highlight = document.getElementById("highlight-canvas"); + highlight.getContext("2d").clearRect(0, 0, highlight.width, highlight.height); +} + +function renderPathPreview(data, minMax) { + const canvas = document.createElement("canvas"); + canvas.className = "path-preview"; + + const [minX, minY, maxX, maxY] = minMax ?? []; + const pathW = maxX - minX || 1; + const pathH = maxY - minY || 1; + if (!data || !minMax || !(pathW > 0) || !(pathH > 0)) { + canvas.width = canvas.height = 1; + return canvas; + } + + const PADDING = 10; // px + const dpr = window.devicePixelRatio || 1; + const drawW = Math.min(200, 200 * (pathW / pathH)); + const drawH = Math.min(200, 200 * (pathH / pathW)); + const scale = Math.min(drawW / pathW, drawH / pathH); + + canvas.width = Math.round((drawW + PADDING * 2) * dpr); + canvas.height = Math.round((drawH + PADDING * 2) * dpr); + canvas.style.width = `${drawW + PADDING * 2}px`; + canvas.style.height = `${drawH + PADDING * 2}px`; + + const ctx = canvas.getContext("2d"); + ctx.scale(dpr, dpr); + // PDF user space has Y pointing up; canvas has Y pointing down — flip Y. + ctx.translate(PADDING, PADDING + drawH); + ctx.scale(scale, -scale); + ctx.translate(-minX, -minY); + + ctx.lineWidth = 1 / scale; + ctx.strokeStyle = prefersDark.matches ? "#9cdcfe" : "#0070c1"; + ctx.stroke(data instanceof Path2D ? data : makePathFromDrawOPS(data)); + + return canvas; +} + +// The evaluator normalizes all color ops to setFillRGBColor / +// setStrokeRGBColor with args = ["#rrggbb"]. Return that hex string, or null. +function getOpColor(name, args) { + if ( + (name === "setFillRGBColor" || name === "setStrokeRGBColor") && + typeof args?.[0] === "string" && + /^#[0-9a-f]{6}$/i.test(args[0]) ) { - input.setAttribute("aria-invalid", "true"); - return; + return args[0]; } - input.setAttribute("aria-invalid", "false"); - await (result.page !== undefined - ? loadTree({ page: result.page }) - : loadTree({ ref: result.ref })); -}); + return null; +} -document.getElementById("goto-input").addEventListener("input", e => { - if (e.target.value.trim() === "") { - e.target.setAttribute("aria-invalid", "false"); +// Single hidden color input reused for all swatch pickers. +const colorPickerInput = document.createElement("input"); +colorPickerInput.type = "color"; +colorPickerInput.style.cssText = + "position:fixed;opacity:0;pointer-events:none;width:0;height:0;"; +document.body.append(colorPickerInput); + +// Creates a color swatch. If `onPick` is provided the swatch is clickable and +// opens the browser color picker; onPick(newHex) is called on each change. +function makeColorSwatch(hex, onPick) { + const swatch = document.createElement("span"); + swatch.className = "color-swatch"; + swatch.style.background = hex; + if (onPick) { + swatch.setAttribute("role", "button"); + swatch.setAttribute("tabindex", "0"); + swatch.setAttribute("aria-label", "Change color"); + swatch.title = "Click to change color"; + swatch.addEventListener("click", e => { + e.stopPropagation(); + colorPickerInput.value = hex; + const ac = new AbortController(); + colorPickerInput.addEventListener( + "input", + () => { + hex = colorPickerInput.value; + swatch.style.background = hex; + onPick(hex); + }, + { signal: ac.signal } + ); + colorPickerInput.addEventListener("change", () => ac.abort(), { + once: true, + }); + colorPickerInput.click(); + }); } -}); + return swatch; +} + +async function showRenderView(pageNum) { + const generation = debugViewGeneration; + const opListEl = document.getElementById("op-list"); + + const spinner = document.createElement("div"); + spinner.setAttribute("role", "status"); + spinner.textContent = "Loading…"; + opListEl.replaceChildren(spinner); + opDetailEl.replaceChildren(); + + renderScale = null; + markLoading(1); + try { + renderedPage = await pdfDoc.getPage(pageNum); + if (debugViewGeneration !== generation) { + return; + } + + // Render the page (records bboxes too). Reuse the operator list from the + // render task when available (dev/testing builds); fall back to a separate + // getOperatorList() call otherwise. + const renderTask = await renderCanvas(); + if (debugViewGeneration !== generation) { + return; + } + currentOpList = + renderTask?.getOperatorList?.() ?? (await renderedPage.getOperatorList()); + if (debugViewGeneration !== generation) { + return; + } + const opList = currentOpList; + + // Build operator list display. + opLines = []; + const opTexts = []; + let opHighlightedIdx = -1; + const opNumCol = document.createElement("div"); + opNumCol.className = "cs-line-nums-col"; + opNumCol.style.setProperty( + "--line-num-width", + `${String(opList.fnArray.length).length}ch` + ); + const opNumFrag = document.createDocumentFragment(); + const fragment = document.createDocumentFragment(); + 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 = document.createElement("div"); + line.className = "op-line"; + line.setAttribute("role", "option"); + line.setAttribute("aria-selected", "false"); + line.tabIndex = i === 0 ? 0 : -1; + opLines.push(line); + + const numItem = document.createElement("div"); + numItem.className = "cs-num-item"; + numItem.append(makeSpan("cs-line-num", String(i + 1))); + opNumFrag.append(numItem); + + // Breakpoint gutter — click to toggle a red-bullet breakpoint. + const gutter = document.createElement("span"); + gutter.className = "bp-gutter"; + gutter.setAttribute("role", "checkbox"); + gutter.setAttribute("tabindex", "0"); + gutter.setAttribute("aria-label", "Breakpoint"); + const isInitiallyActive = breakpoints.has(i); + gutter.setAttribute("aria-checked", String(isInitiallyActive)); + if (isInitiallyActive) { + gutter.classList.add("active"); + } + gutter.addEventListener("click", e => { + e.stopPropagation(); + if (breakpoints.has(i)) { + breakpoints.delete(i); + gutter.classList.remove("active"); + gutter.setAttribute("aria-checked", "false"); + } else { + breakpoints.add(i); + gutter.classList.add("active"); + gutter.setAttribute("aria-checked", "true"); + } + }); + gutter.addEventListener("keydown", e => { + if (e.key === " " || e.key === "Enter") { + e.preventDefault(); + gutter.click(); + } + }); + line.append(gutter); + + const nameEl = document.createElement("span"); + nameEl.className = "op-name"; + nameEl.textContent = name; + line.append(nameEl); + const rgb = getOpColor(name, args); + let colorArgSpan = null; + if (rgb) { + originalColors.set(i, rgb); + line.append( + makeColorSwatch(rgb, newHex => { + args[0] = newHex; + if (colorArgSpan) { + const changed = newHex !== rgb; + colorArgSpan.textContent = JSON.stringify(newHex); + colorArgSpan.classList.toggle("changed-value", changed); + colorArgSpan.title = changed ? `Original: ${rgb}` : ""; + } + }) + ); + } + if (name === "showText" && Array.isArray(args[0])) { + const argEl = document.createElement("span"); + argEl.className = "op-arg"; + argEl.textContent = formatGlyphItems(args[0]); + line.append(argEl); + } else { + for (let j = 0; j < args.length; j++) { + const s = + name === "constructPath" && j === 0 && typeof args[0] === "number" + ? (OPS_TO_NAME[args[0]] ?? String(args[0])) + : formatOpArg(args[j]); + if (s) { + const argEl = document.createElement("span"); + argEl.className = "op-arg"; + argEl.textContent = s; + line.append(argEl); + if (rgb && j === 0) { + colorArgSpan = argEl; + } + } + } + } + // Build plain-text representation for search. + let opText = name; + if (name === "showText" && Array.isArray(args[0])) { + opText += " " + formatGlyphItems(args[0]); + } else { + for (let j = 0; j < args.length; j++) { + const s = + name === "constructPath" && j === 0 && typeof args[0] === "number" + ? (OPS_TO_NAME[args[0]] ?? String(args[0])) + : formatOpArg(args[j]); + if (s) { + opText += " " + s; + } + } + } + opTexts.push(opText); + + line.addEventListener("mouseenter", () => drawHighlight(i)); + line.addEventListener("mouseleave", clearHighlight); + line.addEventListener("click", () => { + if (selectedOpLine) { + selectedOpLine.classList.remove("selected"); + selectedOpLine.setAttribute("aria-selected", "false"); + selectedOpLine.tabIndex = -1; + } + selectedOpLine = line; + line.classList.add("selected"); + line.setAttribute("aria-selected", "true"); + line.tabIndex = 0; + showOpDetail(name, args, i); + }); + fragment.append(line); + } + if (debugViewGeneration === generation) { + opNumCol.append(opNumFrag); + opListEl.replaceChildren(fragment); + + opListEl.addEventListener("keydown", e => { + const lines = opLines; + if (!lines.length) { + return; + } + const focused = document.activeElement; + const currentIdx = lines.indexOf(focused); + let targetIdx = -1; + if (e.key === "ArrowDown") { + targetIdx = + currentIdx < lines.length - 1 ? currentIdx + 1 : currentIdx; + } else if (e.key === "ArrowUp") { + targetIdx = currentIdx > 0 ? currentIdx - 1 : 0; + } else if (e.key === "Home") { + targetIdx = 0; + } else if (e.key === "End") { + targetIdx = lines.length - 1; + } else if (e.key === "Enter" || e.key === " ") { + if (currentIdx >= 0) { + lines[currentIdx].click(); + e.preventDefault(); + } + return; + } else { + return; + } + e.preventDefault(); + if (targetIdx >= 0) { + lines[targetIdx].tabIndex = 0; + if (currentIdx >= 0 && currentIdx !== targetIdx) { + lines[currentIdx].tabIndex = -1; + } + lines[targetIdx].focus(); + lines[targetIdx].scrollIntoView({ block: "nearest" }); + } + }); + + // Wrap #op-list-panel: toolbar above, then a row with the frozen + // line-number column alongside the scrollable panel. + const opListPanelEl = document.getElementById("op-list-panel"); + opListPanelEl.addEventListener("scroll", () => { + opNumCol.scrollTop = opListPanelEl.scrollTop; + }); + + // Replace #op-list-panel in the DOM *before* moving it into opListBody, + // otherwise replaceWith() would act on its new (detached) position. + const opListWrapper = document.createElement("div"); + opListWrapper.className = "op-list-panel-wrapper"; + opListPanelEl.replaceWith(opListWrapper); + + const opListBody = document.createElement("div"); + opListBody.className = "op-list-body"; + opListBody.append(opNumCol, opListPanelEl); + + opListWrapper.append( + makeSearchToolbar({ + total: opList.fnArray.length, + getText: i => opTexts[i], + jumpToItem(i) { + if (opHighlightedIdx >= 0) { + opLines[opHighlightedIdx]?.classList.remove("cs-match"); + opNumCol.children[opHighlightedIdx]?.classList.remove("cs-match"); + } + opHighlightedIdx = i; + if (i < 0) { + return; + } + opLines[i].classList.add("cs-match"); + opNumCol.children[i]?.classList.add("cs-match"); + opLines[i].scrollIntoView({ block: "nearest" }); + }, + }), + opListBody + ); + } + } catch (err) { + opListEl.replaceChildren(makeErrorEl(err.message)); + } finally { + markLoading(-1); + } +} // PDF Name objects arrive as { name: "..." } after structured clone. function isPDFName(val) { @@ -285,29 +2141,26 @@ function isFormXObjectStream(val) { return isStream(val) && val.contentStream === true; } -/** - * Render one key/value pair as a
. - * @param {string|null} key Dict key, array index, or null for root. - * @param {*} value - * @param {PDFDocumentProxy} doc - */ -function renderNode(key, value, doc) { +/** Create a bare div.node treeitem with an optional "key: " prefix. */ +function makeNodeEl(key) { const node = document.createElement("div"); node.className = "node"; node.setAttribute("role", "treeitem"); node.tabIndex = -1; - if (key !== null) { - const keyEl = document.createElement("span"); - keyEl.className = "key"; - keyEl.textContent = key; - node.append(keyEl); - const sep = document.createElement("span"); - sep.className = "separator"; - sep.textContent = ": "; - node.append(sep); + node.append(makeSpan("key", key), makeSpan("separator", ": ")); } + return node; +} +/** + * Render one key/value pair as a
. + * @param {string|null} key Dict key, array index, or null for root. + * @param {*} value + * @param {PDFDocumentProxy} doc + */ +function renderNode(key, value, doc) { + const node = makeNodeEl(key); node.append(renderValue(value, doc)); return node; } @@ -325,50 +2178,20 @@ function buildChildren(value, doc, container) { if (isImageStream(value)) { container.append(renderImageData(value.imageData)); } else if (isFormXObjectStream(value)) { - const contentNode = document.createElement("div"); - contentNode.className = "node"; - contentNode.setAttribute("role", "treeitem"); - contentNode.tabIndex = -1; - contentNode.append(makeSpan("key", "content")); - contentNode.append(makeSpan("separator", ": ")); - - const parsedEl = document.createElement("span"); - parsedEl.className = "content-stream-parsed"; - parsedEl.append( - renderExpandable( - `[Content Stream, ${value.instructions.length} instructions]`, - "stream-label", - c => buildInstructionLines(value, c) + const contentNode = makeNodeEl("content"); + const csLabel = `[Content Stream, ${value.instructions.length} instructions]`; + const csLabelEl = makeSpan("stream-label", csLabel); + contentNode.append( + makeExpandable(csLabelEl, csLabel, c => + buildContentStreamPanel(value, c, csLabelEl) ) ); - - const rawEl = document.createElement("span"); - rawEl.className = "content-stream-raw"; - const byteLabel = makeSpan( - "stream-label", - `<${value.bytes.length} raw bytes>` - ); - rawEl.append(byteLabel); - const bytesContentEl = document.createElement("div"); - bytesContentEl.className = "bytes-content"; - bytesContentEl.append(formatBytes(value.bytes)); - rawEl.append(bytesContentEl); - - contentNode.append(parsedEl, rawEl); container.append(contentNode); } else { - const byteNode = document.createElement("div"); - byteNode.className = "node"; - const keyEl = document.createElement("span"); - keyEl.className = "key"; - keyEl.textContent = "bytes"; - const sep = document.createElement("span"); - sep.className = "separator"; - sep.textContent = ": "; - const valEl = document.createElement("span"); - valEl.className = "stream-label"; - valEl.textContent = `<${value.bytes.length} raw bytes>`; - byteNode.append(keyEl, sep, valEl); + const byteNode = makeNodeEl("bytes"); + byteNode.append( + makeSpan("stream-label", `<${value.bytes.length} raw bytes>`) + ); container.append(byteNode); const bytesContentEl = document.createElement("div"); @@ -441,69 +2264,591 @@ function renderToken(token) { } /** - * Populate container with one .cs-instruction div per instruction. + * Return the plain-text representation of a token (mirrors renderToken). + * Used to build searchable strings for every instruction. + */ +function tokenToText(token) { + if (!token) { + return "null"; + } + switch (token.type) { + case "cmd": + return token.value; + case "name": + return "/" + token.value; + case "ref": + return `${token.num} ${token.gen} R`; + case "number": + return String(token.value); + case "string": + return JSON.stringify(token.value); + case "boolean": + return String(token.value); + case "null": + return "null"; + case "array": + return `[ ${token.value.map(tokenToText).join(" ")} ]`; + case "dict": { + const inner = Object.entries(token.value) + .map(([k, v]) => `/${k} ${tokenToText(v)}`) + .join(" "); + return `<< ${inner} >>`; + } + default: + return String(token.value ?? token.type); + } +} + +/** + * Populate container with one .content-stm-instruction div per instruction. * Shared by Page content streams and Form XObject streams. */ -function buildInstructionLines(val, container) { - const pre = document.createElement("div"); - pre.className = "content-stream"; - let depth = 0; - for (const instr of val.instructions) { - if (instr.cmd === "ET" || instr.cmd === "Q" || instr.cmd === "EMC") { - depth = Math.max(0, depth - 1); +const INSTRUCTION_BATCH_SIZE = 500; +// Maximum instructions kept in the DOM at once (two batches). +const MAX_RENDERED_INSTRUCTIONS = INSTRUCTION_BATCH_SIZE * 2; + +/** + * Build and return a sticky search/goto toolbar div. + * + * @param {object} opts + * @param {number} opts.total Total number of items. + * @param {function} opts.getText getText(i) → plain-text string for item i. + * @param {function} opts.jumpToItem jumpToItem(i) highlights item i and scrolls + * to it; jumpToItem(-1) clears the highlight. + * @returns {HTMLElement} The toolbar element (class "cs-goto-bar"). + */ +let _searchToolbarCounter = 0; + +function makeSearchToolbar({ total, getText, jumpToItem, actions = null }) { + const toolbarId = ++_searchToolbarCounter; + const gotoBar = document.createElement("div"); + gotoBar.className = "cs-goto-bar"; + + // Search group (left side) + const searchGroup = document.createElement("div"); + searchGroup.className = "cs-search-group"; + + const searchErrorId = `search-error-${toolbarId}`; + + const searchInput = document.createElement("input"); + searchInput.type = "search"; + searchInput.className = "cs-search-input"; + searchInput.placeholder = "Search for\u2026"; + searchInput.setAttribute("aria-label", "Search instructions"); + searchInput.setAttribute("aria-describedby", searchErrorId); + + const searchError = document.createElement("span"); + searchError.id = searchErrorId; + searchError.className = "sr-only"; + searchError.setAttribute("role", "alert"); + + const prevBtn = document.createElement("button"); + prevBtn.className = "cs-nav-btn"; + prevBtn.textContent = "↑"; + prevBtn.setAttribute("aria-label", "Previous match"); + prevBtn.disabled = true; + + const nextBtn = document.createElement("button"); + nextBtn.className = "cs-nav-btn"; + nextBtn.textContent = "↓"; + nextBtn.setAttribute("aria-label", "Next match"); + nextBtn.disabled = true; + + const matchInfo = document.createElement("span"); + matchInfo.className = "cs-match-info"; + + function makeCheckboxLabel(text) { + const label = document.createElement("label"); + label.className = "cs-check-label"; + const cb = document.createElement("input"); + cb.type = "checkbox"; + label.append(cb, ` ${text}`); + return { label, cb }; + } + + const { label: ignoreCaseLabel, cb: ignoreCaseCb } = + makeCheckboxLabel("Ignore case"); + const { label: regexLabel, cb: regexCb } = makeCheckboxLabel("Regex"); + + searchGroup.append( + searchInput, + searchError, + prevBtn, + nextBtn, + matchInfo, + ignoreCaseLabel, + regexLabel + ); + + // Go-to-line input (right side) + const gotoInput = document.createElement("input"); + gotoInput.type = "text"; + gotoInput.className = "cs-goto"; + gotoInput.placeholder = "Go to line\u2026"; + gotoInput.setAttribute("aria-label", "Go to line"); + + if (actions) { + gotoBar.append(actions); + } + gotoBar.append(searchGroup, gotoInput); + + let searchMatches = []; + let currentMatchIdx = -1; + + function updateMatchInfo() { + if (!searchInput.value) { + matchInfo.textContent = ""; + prevBtn.disabled = true; + nextBtn.disabled = true; + } else if (searchMatches.length === 0) { + matchInfo.textContent = "No results"; + prevBtn.disabled = true; + nextBtn.disabled = true; + } else { + matchInfo.textContent = `${currentMatchIdx + 1} / ${searchMatches.length}`; + prevBtn.disabled = false; + nextBtn.disabled = false; } - const line = document.createElement("div"); - line.className = "cs-instruction"; - if (depth > 0) { - line.style.paddingInlineStart = `${depth * 1.5}em`; + } + + function computeMatches() { + jumpToItem(-1); + searchMatches = []; + currentMatchIdx = -1; + + const query = searchInput.value; + if (!query) { + updateMatchInfo(); + return false; } - for (const arg of instr.args) { - line.append(renderToken(arg)); - line.append(document.createTextNode(" ")); + + let test; + if (regexCb.checked) { + try { + const re = new RegExp(query, ignoreCaseCb.checked ? "i" : ""); + test = str => re.test(str); + searchInput.removeAttribute("aria-invalid"); + searchError.textContent = ""; + } catch { + searchInput.setAttribute("aria-invalid", "true"); + searchError.textContent = "Invalid regular expression"; + updateMatchInfo(); + return false; + } + } else { + const needle = ignoreCaseCb.checked ? query.toLowerCase() : query; + test = str => + (ignoreCaseCb.checked ? str.toLowerCase() : str).includes(needle); } - if (instr.cmd !== null) { - const cmdEl = makeSpan("token-cmd", instr.cmd); - const opsName = val.cmdNames[instr.cmd]; - if (opsName) { - cmdEl.title = opsName; + searchInput.removeAttribute("aria-invalid"); + searchError.textContent = ""; + + for (let i = 0; i < total; i++) { + if (test(getText(i))) { + searchMatches.push(i); } - line.append(cmdEl); } - pre.append(line); - if (instr.cmd === "BT" || instr.cmd === "q" || instr.cmd === "BDC") { - depth++; + return searchMatches.length > 0; + } + + function navigateMatch(delta) { + if (!searchMatches.length) { + return; + } + currentMatchIdx = + (currentMatchIdx + delta + searchMatches.length) % searchMatches.length; + jumpToItem(searchMatches[currentMatchIdx]); + updateMatchInfo(); + } + + function runSearch() { + if (computeMatches() && searchMatches.length) { + currentMatchIdx = 0; + jumpToItem(searchMatches[0]); } + updateMatchInfo(); } - container.append(pre); + + searchInput.addEventListener("input", runSearch); + searchInput.addEventListener("keydown", e => { + if (e.key === "Enter") { + navigateMatch(e.shiftKey ? -1 : 1); + } + }); + prevBtn.addEventListener("click", () => navigateMatch(-1)); + nextBtn.addEventListener("click", () => navigateMatch(1)); + ignoreCaseCb.addEventListener("change", runSearch); + regexCb.addEventListener("change", runSearch); + + gotoInput.addEventListener("keydown", e => { + if (e.key !== "Enter") { + return; + } + const n = parseInt(gotoInput.value, 10); + if (Number.isNaN(n) || n < 1 || n > total) { + gotoInput.setAttribute("aria-invalid", "true"); + return; + } + gotoInput.removeAttribute("aria-invalid"); + jumpToItem(n - 1); + }); + + return gotoBar; } /** - * Render Page content stream as two pre-built views toggled by CSS: - * - .content-stream-parsed: expandable colorized instruction widget - * - .content-stream-raw: ref widget(s) mirroring the unparsed display - * The active view is controlled by the "parse-cs-active" class on #tree. + * Build a scrollable panel with a frozen line-number column and a + * search/goto toolbar, backed by an IntersectionObserver virtual scroll + * that keeps at most MAX_RENDERED_INSTRUCTIONS rows in the DOM at once. + * + * @param {object} opts + * @param {number} opts.total Total number of rows. + * @param {string} opts.preClass className(s) for the content
. + * @param {Function} opts.getText (i) => plain-text string for search. + * @param {Function} opts.makeItemEl (i, isHighlighted) => HTMLElement. + * @param {HTMLElement} opts.container Target element; panel is appended here. */ -function renderContentStream(val, doc) { - const frag = document.createDocumentFragment(); +function buildVirtualScrollPanel({ + total, + preClass, + getText, + makeItemEl, + container, + actions = null, +}) { + if (total === 0) { + return; + } - const parsedEl = document.createElement("span"); - parsedEl.className = "content-stream-parsed"; - parsedEl.append( - renderExpandable( - `[Content Stream, ${val.instructions.length} instructions]`, - "stream-label", - container => buildInstructionLines(val, container) - ) + const scrollEl = document.createElement("div"); + scrollEl.className = "content-stm-scroll"; + + // Left panel: line-number column. Lives outside the scroll container so it + // is unaffected by horizontal scroll. Its scrollTop is synced via JS. + const numCol = document.createElement("div"); + numCol.className = "cs-line-nums-col"; + numCol.style.setProperty("--line-num-width", `${String(total).length}ch`); + + // Right panel: the actual scroll container. + const innerEl = document.createElement("div"); + innerEl.className = "content-stm-inner"; + innerEl.addEventListener("scroll", () => { + numCol.scrollTop = innerEl.scrollTop; + }); + + // Right panel content: item rows. + const pre = document.createElement("div"); + pre.className = preClass; + innerEl.append(pre); + + // Body row: frozen num column + scrollable content side by side. + const body = document.createElement("div"); + body.className = "content-stm-body"; + body.append(numCol, innerEl); + + // Sentinels bracket the rendered window inside pre: + // topSentinel [startIndex .. endIndex) bottomSentinel + const topSentinel = document.createElement("div"); + topSentinel.className = "content-stm-load-sentinel"; + const bottomSentinel = document.createElement("div"); + bottomSentinel.className = "content-stm-load-sentinel"; + + let startIndex = 0; + let endIndex = Math.min(INSTRUCTION_BATCH_SIZE, total); + let highlightedIndex = -1; + + function makeNumEl(i) { + const item = document.createElement("div"); + item.className = "cs-num-item"; + if (i === highlightedIndex) { + item.classList.add("cs-match"); + } + item.append(makeSpan("cs-line-num", String(i + 1))); + return item; + } + + function renderRange(from, to) { + const frag = document.createDocumentFragment(); + for (let i = from; i < to; i++) { + frag.append(makeItemEl(i, i === highlightedIndex)); + } + return frag; + } + + function renderNumRange(from, to) { + const frag = document.createDocumentFragment(); + for (let i = from; i < to; i++) { + frag.append(makeNumEl(i)); + } + return frag; + } + + pre.append(topSentinel, renderRange(0, endIndex), bottomSentinel); + numCol.append(renderNumRange(0, endIndex)); + + function jumpToTarget(targetIndex) { + // Clear both the content window and the number column. + let el = topSentinel.nextElementSibling; + while (el && el !== bottomSentinel) { + const next = el.nextElementSibling; + el.remove(); + el = next; + } + numCol.replaceChildren(); + + // Re-render a window centred around the target. + const half = Math.floor(MAX_RENDERED_INSTRUCTIONS / 2); + startIndex = Math.max(0, targetIndex - half); + endIndex = Math.min(total, startIndex + MAX_RENDERED_INSTRUCTIONS); + startIndex = Math.max(0, endIndex - MAX_RENDERED_INSTRUCTIONS); + topSentinel.after(renderRange(startIndex, endIndex)); + numCol.append(renderNumRange(startIndex, endIndex)); + + // Scroll to centre the target in innerEl. + // pre.children: [0]=topSentinel, [1..n]=rows, [n+1]=bottomSentinel + const targetEl = pre.children[targetIndex - startIndex + 1]; + if (targetEl) { + const targetRect = targetEl.getBoundingClientRect(); + const innerRect = innerEl.getBoundingClientRect(); + const available = innerEl.clientHeight; + innerEl.scrollTop += + targetRect.top - + innerRect.top - + available / 2 + + targetEl.clientHeight / 2; + } + } + + function jumpToItem(i) { + pre.querySelector(".cs-match")?.classList.remove("cs-match"); + numCol.querySelector(".cs-match")?.classList.remove("cs-match"); + if (i < 0) { + highlightedIndex = -1; + return; + } + highlightedIndex = i; + jumpToTarget(i); + pre.children[i - startIndex + 1]?.classList.add("cs-match"); + numCol.children[i - startIndex]?.classList.add("cs-match"); + } + + scrollEl.append( + makeSearchToolbar({ total, getText, jumpToItem, actions }), + body + ); + + if (total <= INSTRUCTION_BATCH_SIZE) { + container.append(scrollEl); + return; + } + + const observer = new IntersectionObserver( + entries => { + for (const entry of entries) { + if (!entry.isIntersecting) { + continue; + } + + if (entry.target === bottomSentinel) { + // Append next batch at bottom. + const newEnd = Math.min(endIndex + INSTRUCTION_BATCH_SIZE, total); + if (newEnd === endIndex) { + continue; + } + bottomSentinel.before(renderRange(endIndex, newEnd)); + numCol.append(renderNumRange(endIndex, newEnd)); + endIndex = newEnd; + + // Trim oldest rows from top if window exceeds the max. + if (endIndex - startIndex > MAX_RENDERED_INSTRUCTIONS) { + const removeCount = + endIndex - startIndex - MAX_RENDERED_INSTRUCTIONS; + const heightBefore = pre.scrollHeight; + for (let i = 0; i < removeCount; i++) { + topSentinel.nextElementSibling?.remove(); + numCol.firstElementChild?.remove(); + } + startIndex += removeCount; + // Compensate so the visible content doesn't jump upward. + innerEl.scrollTop -= heightBefore - pre.scrollHeight; + } + } else { + // Prepend next batch at top. + if (startIndex === 0) { + continue; + } + const newStart = Math.max(0, startIndex - INSTRUCTION_BATCH_SIZE); + const scrollBefore = innerEl.scrollTop; + const heightBefore = pre.scrollHeight; + topSentinel.after(renderRange(newStart, startIndex)); + numCol.prepend(renderNumRange(newStart, startIndex)); + // Compensate so the visible content doesn't jump downward. + innerEl.scrollTop = scrollBefore + (pre.scrollHeight - heightBefore); + startIndex = newStart; + + // Trim oldest rows from bottom if window exceeds the max. + if (endIndex - startIndex > MAX_RENDERED_INSTRUCTIONS) { + const removeCount = + endIndex - startIndex - MAX_RENDERED_INSTRUCTIONS; + for (let i = 0; i < removeCount; i++) { + bottomSentinel.previousElementSibling?.remove(); + numCol.lastElementChild?.remove(); + } + endIndex -= removeCount; + } + } + } + }, + { root: innerEl, rootMargin: "200px" } ); - const rawEl = document.createElement("span"); - rawEl.className = "content-stream-raw"; - const rawVal = - val.rawContents.length === 1 ? val.rawContents[0] : val.rawContents; - rawEl.append(renderValue(rawVal, doc)); + observer.observe(topSentinel); + observer.observe(bottomSentinel); - frag.append(parsedEl, rawEl); - return frag; + container.append(scrollEl); +} + +function makeInstrItemEl(isHighlighted) { + const el = document.createElement("div"); + el.className = "content-stm-instruction"; + if (isHighlighted) { + el.classList.add("cs-match"); + } + return el; +} + +function buildInstructionLines(val, container, actions = null) { + const { instructions, cmdNames } = val; + const total = instructions.length; + + // Pre-compute indentation depth for every instruction so that any + // slice [from, to) can be rendered without replaying from the start. + const depths = new Int32Array(total); + let d = 0; + for (let i = 0; i < total; i++) { + const cmd = instructions[i].cmd; + if (cmd === "ET" || cmd === "Q" || cmd === "EMC") { + d = Math.max(0, d - 1); + } + depths[i] = d; + if (cmd === "BT" || cmd === "q" || cmd === "BDC") { + d++; + } + } + + // Pre-compute a plain-text string per instruction for searching. + const instrTexts = instructions.map(instr => { + const parts = instr.args.map(tokenToText); + if (instr.cmd !== null) { + parts.push(instr.cmd); + } + return parts.join(" "); + }); + + buildVirtualScrollPanel({ + total, + preClass: "content-stream", + getText: i => instrTexts[i], + actions, + makeItemEl(i, isHighlighted) { + const instr = instructions[i]; + const line = makeInstrItemEl(isHighlighted); + // Wrap the instruction content so that indentation shifts the tokens. + const content = document.createElement("span"); + if (depths[i] > 0) { + content.style.paddingInlineStart = `${depths[i] * 1.5}em`; + } + for (const arg of instr.args) { + content.append(renderToken(arg)); + content.append(document.createTextNode(" ")); + } + if (instr.cmd !== null) { + const cmdEl = makeSpan("token-cmd", instr.cmd); + const opsName = cmdNames[instr.cmd]; + if (opsName) { + cmdEl.title = opsName; + } + content.append(cmdEl); + } + line.append(content); + return line; + }, + container, + }); +} + +// Fills container with a raw-bytes virtual-scroll panel. +function buildRawBytesPanel(rawBytes, container, actions = null) { + const lines = rawBytes.split(/\r?\n|\r/); + if (lines.at(-1) === "") { + lines.pop(); + } + buildVirtualScrollPanel({ + total: lines.length, + preClass: "content-stream raw-bytes-stream", + getText: i => lines[i], + makeItemEl(i, isHighlighted) { + const el = makeInstrItemEl(isHighlighted); + el.append(formatBytes(lines[i])); + return el; + }, + container, + actions, + }); +} + +// Creates a "Parsed" toggle button. aria-pressed=true means the parsed view +// is currently active; clicking switches to the other view. +function makeParseToggleBtn(isParsed, onToggle) { + const btn = document.createElement("button"); + btn.className = "cs-nav-btn"; + btn.textContent = "Parsed"; + btn.setAttribute("aria-pressed", String(isParsed)); + btn.title = isParsed ? "Show raw bytes" : "Show parsed instructions"; + btn.addEventListener("click", onToggle); + return btn; +} + +// Fills container with the content stream panel (parsed or raw), with a +// toggle button in the toolbar that swaps the view in-place. +function buildContentStreamPanel(val, container, labelEl = null) { + let isParsed = true; + const rawBytes = val.rawBytes ?? val.bytes; + const rawLines = rawBytes ? rawBytes.split(/\r?\n|\r/) : []; + if (rawLines.at(-1) === "") { + rawLines.pop(); + } + const parsedLabel = `[Content Stream, ${val.instructions.length} instructions]`; + const rawLabel = `[Content Stream, ${rawLines.length} lines]`; + + function rebuild() { + container.replaceChildren(); + if (labelEl) { + labelEl.textContent = isParsed ? parsedLabel : rawLabel; + } + const btn = makeParseToggleBtn(isParsed, () => { + isParsed = !isParsed; + rebuild(); + }); + if (isParsed) { + buildInstructionLines(val, container, btn); + } else { + buildRawBytesPanel(rawBytes, container, btn); + } + } + + rebuild(); +} + +/** + * Render Page content stream as an expandable panel with a Parsed/Raw toggle. + */ +function renderContentStream(val) { + const label = `[Content Stream, ${val.instructions.length} instructions]`; + const labelEl = makeSpan("stream-label", label); + return makeExpandable(labelEl, label, container => + buildContentStreamPanel(val, container, labelEl) + ); } /** @@ -526,7 +2871,7 @@ function renderValue(value, doc) { return makeSpan("name-value", "/" + value.name); } - // Content stream (Page Contents) → two pre-built views toggled by CSS + // Content stream (Page Contents) → expandable with Parsed/Raw toggle if (isContentStream(value)) { return renderContentStream(value, doc); } @@ -588,135 +2933,86 @@ function renderRef(ref, doc) { cacheKey = `${ref.num}:${ref.gen}`; label = refLabel(ref); } - - const frag = document.createDocumentFragment(); - - const toggleEl = document.createElement("span"); - toggleEl.textContent = ARROW_COLLAPSED; - toggleEl.setAttribute("role", "button"); - toggleEl.setAttribute("tabindex", "0"); - toggleEl.setAttribute("aria-expanded", "false"); - toggleEl.setAttribute("aria-label", `Expand reference ${label}`); - - const refEl = document.createElement("span"); - refEl.className = "ref"; - refEl.textContent = label; - refEl.setAttribute("aria-hidden", "true"); - - const childrenEl = document.createElement("div"); - childrenEl.className = "hidden"; - childrenEl.setAttribute("role", "group"); - childrenEl.setAttribute("aria-label", `Contents of reference ${label}`); - - let open = false; - let loaded = false; - - const onToggle = async () => { - open = !open; - toggleEl.textContent = open ? ARROW_EXPANDED : ARROW_COLLAPSED; - toggleEl.setAttribute("aria-expanded", String(open)); - childrenEl.classList.toggle("hidden", !open); - - if (open && !loaded) { - loaded = true; + return makeExpandable( + makeSpan("ref", label), + `reference ${label}`, + childrenEl => { const spinner = document.createElement("div"); spinner.setAttribute("role", "status"); spinner.textContent = "Loading…"; childrenEl.append(spinner); - - try { - if (!refCache.has(cacheKey)) { - refCache.set(cacheKey, doc.getRawData({ ref })); - } - const result = await refCache.get(cacheKey); - childrenEl.replaceChildren(); - buildChildren(result, doc, childrenEl); - } catch (err) { - const errEl = document.createElement("div"); - errEl.setAttribute("role", "alert"); - errEl.textContent = "Error: " + err.message; - childrenEl.replaceChildren(errEl); + markLoading(1); + if (!refCache.has(cacheKey)) { + refCache.set(cacheKey, doc.getRawData({ ref })); } + refCache + .get(cacheKey) + .then(result => { + childrenEl.replaceChildren(); + buildChildren(result, doc, childrenEl); + }) + .catch(err => childrenEl.replaceChildren(makeErrorEl(err.message))) + .finally(() => markLoading(-1)); } - }; - - toggleEl.addEventListener("click", onToggle); - toggleEl.addEventListener("keydown", e => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onToggle(); - } - }); - refEl.addEventListener("click", onToggle); - - frag.append(toggleEl); - frag.append(refEl); - frag.append(childrenEl); - return frag; + ); } /** - * Build a synchronous expand/collapse widget. - * @param {string} label Text shown on the collapsed line. - * @param {string} labelClass CSS class for the label. - * @param {function} buildFn Called with (containerEl) on first open. + * Build a shared expand/collapse widget. + * labelEl is the element shown between the toggle arrow and the children. + * ariaLabel is used for the toggle and group aria-labels. + * onFirstOpen(childrenEl) is called once when first expanded (may be async). */ -function renderExpandable(label, labelClass, buildFn) { - const frag = document.createDocumentFragment(); - +function makeExpandable(labelEl, ariaLabel, onFirstOpen) { const toggleEl = document.createElement("span"); toggleEl.textContent = ARROW_COLLAPSED; toggleEl.setAttribute("role", "button"); toggleEl.setAttribute("tabindex", "0"); toggleEl.setAttribute("aria-expanded", "false"); - toggleEl.setAttribute("aria-label", `Expand ${label}`); - - const labelEl = document.createElement("span"); - labelEl.className = labelClass; - labelEl.textContent = label; + toggleEl.setAttribute("aria-label", `Expand ${ariaLabel}`); labelEl.setAttribute("aria-hidden", "true"); const childrenEl = document.createElement("div"); childrenEl.className = "hidden"; childrenEl.setAttribute("role", "group"); - childrenEl.setAttribute("aria-label", `Contents of ${label}`); - - let open = false; - let built = false; + childrenEl.setAttribute("aria-label", `Contents of ${ariaLabel}`); - const onToggle = () => { + let open = false, + done = false; + const toggle = () => { open = !open; toggleEl.textContent = open ? ARROW_EXPANDED : ARROW_COLLAPSED; toggleEl.setAttribute("aria-expanded", String(open)); childrenEl.classList.toggle("hidden", !open); - if (open && !built) { - built = true; - buildFn(childrenEl); + if (open && !done) { + done = true; + onFirstOpen(childrenEl); } }; - - toggleEl.addEventListener("click", onToggle); + toggleEl.addEventListener("click", toggle); toggleEl.addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - onToggle(); + toggle(); } }); - labelEl.addEventListener("click", onToggle); + labelEl.addEventListener("click", toggle); - frag.append(toggleEl); - frag.append(labelEl); - frag.append(childrenEl); + const frag = document.createDocumentFragment(); + frag.append(toggleEl, labelEl, childrenEl); return frag; } /** - * Build a DocumentFragment for the byte string. - * Printable ASCII (0x20–0x7e) runs become plain text nodes. - * Consecutive non-printable bytes are grouped into a single - * with each byte as uppercase XX separated by - * a narrow space. + * Build a synchronous expand/collapse widget. + * @param {string} label Text shown on the collapsed line. + * @param {string} labelClass CSS class for the label. + * @param {Function} buildFn Called with (containerEl) on first open. */ +function renderExpandable(label, labelClass, buildFn) { + return makeExpandable(makeSpan(labelClass, label), label, c => buildFn(c)); +} + /** * Render image data (RGBA Uint8ClampedArray) into a node. */ @@ -738,6 +3034,9 @@ function renderImageData({ width, height, data }) { canvas.className = "image-preview"; canvas.width = width; canvas.height = height; + const dpr = window.devicePixelRatio || 1; + canvas.style.width = `${width / dpr}px`; + canvas.style.aspectRatio = `${width} / ${height}`; canvas.setAttribute("aria-label", `Image preview ${width}×${height}`); const ctx = canvas.getContext("2d"); const imgData = new ImageData(new Uint8ClampedArray(data), width, height);