added debugger

This commit is contained in:
Kilian Schüttler 2025-03-27 02:48:09 +01:00
parent 5227c0acea
commit 223c5f76e0
16 changed files with 1105 additions and 324 deletions

View File

@ -35,7 +35,6 @@
reader.onload = (e) => {
const data = e.target?.result;
if (!data) return;
console.log(file, data);
createFileViewState(file.name, data)
};
reader.onerror = () => {

View File

@ -3,9 +3,8 @@
import { UploadOutline } from 'flowbite-svelte-icons';
import * as monaco from 'monaco-editor';
import type ContentModel from '../models/ContentModel.svelte';
import { getPDFOperator, opLookup } from '../models/OperatorList';
import { OperatorList } from '../models/OperatorList.js';
import { opLookup } from '../models/OperatorList';
import type FileViewState from '../models/FileViewState.svelte';
const opIdRegex = /opId-(\d+)/;
monaco.languages.register({ id: 'pdf-content-stream' });
@ -63,20 +62,31 @@
});
let {
fState,
contentsUpdatedEvent,
height,
save,
contents
onBreakpointsChange = (breakpoints: Set<number>) => {},
currentOgIdx = -1
}: {
fState: FileViewState;
contentsUpdatedEvent: () => void;
height: number;
save: (updated_data: string) => void;
contents: ContentModel;
onBreakpointsChange?: (breakpoints: Set<number>) => void;
currentOgIdx?: number;
} = $props();
let editorContainer: HTMLElement;
let editor: monaco.editor.IStandaloneCodeEditor;
let isEdited = $state(false);
let lastContents: ContentModel | undefined = $state(undefined);
let contents: ContentModel | undefined;
let isEdited = false;
let loaded = false;
let breakpoints: Set<number> = $state(new Set());
let breakpointDecorations: monaco.editor.IEditorDecorationsCollection | null = null;
let currentOperationDecoration: monaco.editor.IEditorDecorationsCollection | null = null;
let lineToOperations: Map<number, number[]> = new Map();
let ogIdxToEditorIdx: Map<number, number> = new Map();
onMount(() => {
editor = monaco.editor.create(editorContainer, {
@ -87,18 +97,71 @@
scrollBeyondLastLine: false,
fontSize: 14,
automaticLayout: true,
lineNumbers: 'on',
lineDecorationsWidth: 8, // Further reducing margin after line numbers
lineNumbersMinChars: 3, // Controls minimum width of line number area
glyphMargin: true, // Enable the gutter for breakpoints
});
// Add change listener to detect edits
editor.onDidChangeModelContent(() => {
isEdited = true;
isEdited = loaded;
updateContents();
});
loadContents(contents);
const gutterElement = editorContainer.querySelector('.margin');
if (gutterElement) {
gutterElement.classList.add('breakpoint-hover-area');
}
let lastHoveredLine: number | null = null;
let hoverDecorationIds: string[] = [];
function clearHoverIndicators() {
if (hoverDecorationIds.length > 0) {
editor.removeDecorations(hoverDecorationIds);
hoverDecorationIds = [];
lastHoveredLine = null;
}
}
editorContainer.addEventListener('mouseleave', clearHoverIndicators);
editor.onMouseMove((e) => {
const currentLineNumber = e.target.position?.lineNumber || null;
if ((e.target.type === monaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS ||
e.target.type === monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN) &&
currentLineNumber) {
if (currentLineNumber !== lastHoveredLine || hoverDecorationIds.length === 0) {
clearHoverIndicators();
if (!breakpoints.has(currentLineNumber)) {
hoverDecorationIds = editor.deltaDecorations([], [{
range: new monaco.Range(currentLineNumber, 1, currentLineNumber, 1),
options: {
isWholeLine: false,
glyphMarginClassName: 'breakpoint-glyph-hover'
}
}]);
}
lastHoveredLine = currentLineNumber;
}
} else {
clearHoverIndicators();
}
});
editor.onMouseDown((e) => {
if (!e.target.position) return;
if (e.target.type === monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN ||
e.target.type === monaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS) {
toggleBreakpointAtLine(e.target.position.lineNumber);
return;
}
const decorations = editor.getModel()?.getDecorationsInRange({
startLineNumber: e.target.position.lineNumber,
startColumn: e.target.position.column,
@ -110,34 +173,111 @@
if (!match) {
return;
}
console.log(contents.opList.fnArray[+match[1]]);
console.log(contents.opList.argsArray[+match[1]]);
})
});
return () => {
editorContainer.removeEventListener('mouseleave', clearHoverIndicators);
editor.dispose();
};
});
$effect(() => {
loadContents(contents);
loadContents();
});
function addDecorations(contents: ContentModel) {
$effect(() => {
if (currentOgIdx >= 0) {
highlightCurrentOperation(currentOgIdx);
} else {
clearCurrentOperationHighlight();
}
});
function toggleBreakpointAtLine(lineNumber: number) {
if (!contents?.opList) return;
const operations = lineToOperations.get(lineNumber) || [];
if (operations.length === 0) {
return;
}
const hasBreakpoint = operations.some(ogIdx => breakpoints.has(ogIdx));
if (hasBreakpoint) {
operations.forEach(ogIdx => {
breakpoints.delete(ogIdx);
});
} else {
operations.forEach(ogIdx => {
breakpoints.add(ogIdx);
});
}
updateBreakpointDecorations();
breakpoints = new Set([...breakpoints]);
onBreakpointsChange(breakpoints);
}
function updateBreakpointDecorations() {
const model = editor.getModel();
if (!model || !contents?.opList) return;
const decorationsArray = [];
const linesWithBreakpoints = new Set<number>();
for (let i = 0; i < contents.opList.fnArray.length; i++) {
const operator = contents.opList.getOperatorAt(i);
if (breakpoints.has(operator.ogIdx) && operator.range) {
const startPos = model.getPositionAt(operator.range.start);
linesWithBreakpoints.add(startPos.lineNumber);
decorationsArray.push({
range: new monaco.Range(startPos.lineNumber, 1, startPos.lineNumber, 1),
options: {
isWholeLine: true,
className: 'breakpoint-line',
glyphMarginClassName: 'breakpoint-glyph',
glyphMarginHoverMessage: { value: `Breakpoint on operation: ${operator.getPDFOperator?.().name || 'Unknown'}` }
}
});
}
}
if (breakpointDecorations) {
breakpointDecorations.clear();
}
breakpointDecorations = editor.createDecorationsCollection(decorationsArray);
}
function removeAllDecorations() {
const ids = editor.getModel()?.getAllDecorations().map(d => d.id) ?? [];
editor.removeDecorations(ids);
}
function updateDecorations(contents: ContentModel) {
removeAllDecorations();
if (contents.opList && contents.opList.fnArray.length > 0) {
const decorations = [];
lineToOperations.clear();
ogIdxToEditorIdx.clear();
for (let i = 0; i < contents.opList.fnArray.length; i++) {
const fnId = contents.opList.fnArray[i];
const args = contents.opList.argsArray[i];
const range = contents.opList.rangeArray[i];
const operator = getPDFOperator(fnId);
const operator = contents.opList.getOperatorAt(i);
const model = editor.getModel();
if (model && operator && range) {
const startPos = model.getPositionAt(range[0]);
const endPos = model.getPositionAt(range[1]);
if (model && operator && operator.range) {
const startPos = model.getPositionAt(operator.range.start);
const endPos = model.getPositionAt(operator.range.end);
const monacoRange = new monaco.Range(
startPos.lineNumber,
startPos.column,
@ -145,45 +285,132 @@
endPos.column
);
let className = `${operator.class} highlightOp opId-${i}`;
// Add class-based decoration for operator highlighting
ogIdxToEditorIdx.set(operator.ogIdx, i);
let className = `${operator.getPDFOperator?.().class || 'operator'} highlightOp opId-${i}`;
decorations.push({
range: monacoRange,
options: {
className: className,
hoverMessage: { value: OperatorList.formatOperatorToMarkdown(fnId, args) }
hoverMessage: { value: operator.toMarkdown?.() || `Operator Index: ${i}, Original Index: ${operator.ogIdx}` }
}
});
const lineNumber = startPos.lineNumber;
if (!lineToOperations.has(lineNumber)) {
lineToOperations.set(lineNumber, []);
}
lineToOperations.get(lineNumber)?.push(operator.ogIdx);
}
}
// Apply all decorations at once
editor.createDecorationsCollection(decorations);
updateBreakpointDecorations();
if (currentOgIdx >= 0) {
highlightCurrentOperation(currentOgIdx);
}
}
}
function loadContents(contents: ContentModel | undefined) {
if (!contents || !editor) return;
async function loadContents() {
if (!fState.currentPageNumber || !editor || fState.currentPageNumber === contents?.pageNum) return;
loaded = false;
contents = await fState.document.getContents(fState.currentPageNumber);
if (lastContents === contents) {
return;
}
lastContents = contents;
editor.setValue(contents.toDisplay());
lineToOperations.clear();
ogIdxToEditorIdx.clear();
clearCurrentOperationHighlight();
breakpointDecorations?.clear();
updateDecorations(contents);
isEdited = false;
addDecorations(contents);
loaded = true;
breakpoints = new Set();
onBreakpointsChange(breakpoints);
}
</script>
async function updateContents() {
if (!fState.currentPageNumber || !loaded || !isEdited) return;
await fState.document.updateContents(fState.currentPageNumber, editor.getValue())
const contents = await fState.document.getContents(fState.currentPageNumber);
updateDecorations(contents);
isEdited = false;
contentsUpdatedEvent();
}
function highlightCurrentOperation(ogIdx: number) {
if (!contents?.opList || !editor.getModel()) return;
clearCurrentOperationHighlight();
const editorIdx = findEditorIdxForOgIdx(ogIdx);
if (editorIdx === undefined) return;
try {
const operator = contents.opList.getOperatorAt(editorIdx);
if (!operator || !operator.range) return;
const model = editor.getModel();
if (!model) return;
const startPos = model.getPositionAt(operator.range.start);
const endPos = model.getPositionAt(operator.range.end);
currentOperationDecoration = editor.createDecorationsCollection([{
range: new monaco.Range(
startPos.lineNumber,
startPos.column,
endPos.lineNumber,
endPos.column
),
options: {
className: 'current-operation-highlight',
isWholeLine: false,
inlineClassName: 'current-operation-inline',
hoverMessage: { value: 'Current operation (execution paused)' }
}
}]);
editor.revealPositionInCenter(startPos);
} catch (error) {
console.error('Error highlighting current operation:', error);
}
}
function findEditorIdxForOgIdx(ogIdx: number): number | undefined {
return ogIdxToEditorIdx.get(ogIdx);
}
function clearCurrentOperationHighlight() {
if (currentOperationDecoration) {
currentOperationDecoration.clear();
currentOperationDecoration = null;
}
}
</script>
<div class="relative">
<div class="breakpoints-list">
<h3>Breakpoints</h3>
<ul>
{#each Array.from(breakpoints).sort((a, b) => a - b) as opIdx}
<li>Operation {opIdx}</li>
{/each}
</ul>
</div>
<div bind:this={editorContainer} style:height={height + "px"}></div>
{#if isEdited}
<button onclick={() => save(editor.getValue())} class="save-button">
<button onclick={() => updateContents()} class="save-button">
<UploadOutline size="xl" />
</button>
{/if}
</div>
<style lang="postcss">
@ -191,7 +418,7 @@
@apply p-2 bg-forge-sec text-forge-text border-t border-forge-bound cursor-pointer;
position: absolute;
right: 2em;
bottom: 0px;
bottom: 0;
width: 48px;
height: 48px;
}
@ -203,5 +430,71 @@
:global(.highlightOp) {
@apply border border-forge-sec rounded;
}
:global(.current-operation-highlight) {
@apply border-2 border-orange-400 rounded bg-opacity-20 bg-orange-400;
animation: pulse 2s infinite;
}
:global(.current-operation-inline) {
@apply font-bold text-orange-300;
}
@keyframes pulse {
0% {
background-color: rgba(251, 146, 60, 0.1);
}
50% {
background-color: rgba(251, 146, 60, 0.3);
}
100% {
background-color: rgba(251, 146, 60, 0.1);
}
}
:global(.breakpoint-glyph) {
margin-left: 3px;
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 6px solid #c65252;
cursor: pointer;
}
:global(.breakpoint-glyph-hover) {
margin-left: 3px;
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 6px solid rgba(198, 82, 82, 0.4);
cursor: pointer;
}
:global(.breakpoint-line) {
background-color: rgba(198, 82, 82, 0.2); /* Light red background */
}
:global(.breakpoint-hover-area) {
cursor: pointer;
}
.breakpoints-list {
@apply p-3 bg-forge-prim text-forge-text border border-forge-bound rounded;
position: absolute;
right: 2em;
top: 2em;
max-height: 300px;
overflow-y: auto;
z-index: 100;
}
.breakpoints-list h3 {
@apply font-bold mb-2;
}
.breakpoints-list ul {
@apply list-disc pl-4;
}
</style>

View File

@ -1,27 +1,169 @@
<script lang="ts">
import { RefreshOutline } from "flowbite-svelte-icons";
import ZoomableContainer from './ZoomableContainer.svelte';
import ZoomControls from '../components/ZoomControls.svelte';
import type { ViewState } from '../models/ViewState';
import {
RefreshOutline
} from 'flowbite-svelte-icons';
type ZoomableProps = {
minZoom?: number;
maxZoom?: number;
zoomStep?: number;
imgUrl?: string;
height?: number;
};
let {
minZoom = 0.1,
maxZoom = 30,
zoomStep = 0.1,
imgUrl,
height
}: ZoomableProps = $props();
let container = $state<HTMLDivElement>();
let viewState: ViewState = $state({
x: 0,
y: 0,
scale: 1
});
type DragState = {
startX: number;
startY: number;
};
let dragState: DragState | undefined = $state(undefined);
function handleZoom(event: WheelEvent) {
if (!container || !event.ctrlKey) return;
event.preventDefault();
const delta = -Math.sign(event.deltaY);
const newScale = Math.min(
Math.max(viewState.scale + delta * zoomStep * viewState.scale, minZoom),
maxZoom
);
if (newScale !== viewState.scale) {
const x = event.clientX;
const y = event.clientY;
const scaleChange = newScale / viewState.scale;
container.scrollLeft = x * scaleChange - x + container.scrollLeft;
container.scrollTop = y * scaleChange - y + container.scrollTop;
viewState.scale = newScale;
}
}
// Drag handlers
function handleDragStart(event: MouseEvent) {
if (!container || event.button) return;
event.preventDefault();
dragState = {
startX: event.pageX - viewState.x,
startY: event.pageY - viewState.y
};
}
function handleDragMove(event: MouseEvent) {
if (!container || !dragState) return;
event.preventDefault();
const x = event.pageX - container.offsetLeft;
const y = event.pageY - container.offsetTop;
viewState.x = (x - dragState.startX);
viewState.y = (y - dragState.startY);
}
function handleDragEnd(event: MouseEvent) {
event.preventDefault();
dragState = undefined;
}
function zoomIn() {
const newScale = Math.min(viewState.scale + (zoomStep * viewState.scale), maxZoom);
if (newScale !== viewState.scale) {
viewState.scale = newScale;
}
}
function zoomOut() {
const newScale = Math.max(viewState.scale - (zoomStep * viewState.scale), minZoom);
if (newScale !== viewState.scale) {
viewState.scale = newScale;
}
}
function resetZoom() {
viewState.scale = 1;
if (container) {
viewState.x = 0;
viewState.y = 0;
}
}
// Event listener management
$effect(() => {
if (container) {
container.addEventListener('wheel', handleZoom, { passive: false });
return () => container?.removeEventListener('wheel', handleZoom);
}
});
let { img, height }: { img: string | undefined; height: number } =
$props();
</script>
<div class="page-container" style:height={height + "px"}>
{#if !img}
<div class="loading-container">
<RefreshOutline class="animate-spin" size="xl" />
</div>
{:else}
<ZoomableContainer imgUrl={img} {height}>
</ZoomableContainer>
{/if}
<div class="relative w-full h-full">
<ZoomControls
scale={viewState.scale}
onZoomIn={() => zoomIn()}
onZoomOut={() => zoomOut()}
{resetZoom}
showFit={false}
/>
<div
class="container"
bind:this={container}
onmousedown={handleDragStart}
onmousemove={handleDragMove}
onmouseup={handleDragEnd}
onmouseleave={handleDragEnd}
role="presentation"
style:height={height + "px"}
style:max-height={height + "px"}
>
{#if !imgUrl}
<RefreshOutline class="animate-spin" size="xl"></RefreshOutline>
{:else}
<img
class="zoomimage"
alt="xobject"
src={imgUrl}
style:transform="translate({viewState.x}px, {viewState.y}px) scale({viewState.scale})"
style:max-height={height + "px"}
/>
{/if}
</div>
</div>
<style lang="postcss">
.page-container {
@apply w-full bg-forge-dark relative m-auto;
.container {
@apply w-full h-full bg-forge-dark;
overflow: hidden;
cursor: grab;
display: flex;
justify-content: center;
align-items: center;
}
.loading-container {
@apply flex items-center justify-center h-full;
.container:active {
cursor: grabbing;
}
.zoomimage {
position: relative;
}
</style>

View File

@ -1,43 +1,51 @@
<script lang="ts">
import ZoomControls from '../components/ZoomControls.svelte';
import type FileViewState from '../models/FileViewState.svelte';
import { PageRenderState } from '../models/PageRenderState.svelte';
type ZoomableProps = {
minZoom?: number;
maxZoom?: number;
zoomStep?: number;
imgUrl?: string;
canvas?: HTMLCanvasElement;
height?: string | number;
fState: FileViewState;
height?: number;
render: () => void;
};
let {
minZoom = 0.1,
maxZoom = 10,
maxZoom = 20,
zoomStep = 0.1,
imgUrl,
canvas = $bindable(),
height
fState,
height,
render = $bindable()
}: ZoomableProps = $props();
let currentImgUrl = $state(imgUrl);
let image: HTMLImageElement | undefined = $state(undefined);
let container = $state<HTMLDivElement>();
let canvas = $state<HTMLCanvasElement>();
type ViewState = {
x: number;
y: number;
scale: number;
}
let renderState: PageRenderState | undefined = $derived(canvas && fState ? new PageRenderState(fState, canvas) : undefined)
let viewState: ViewState = $state({
x: 0,
y: 0,
scale: 1
let scale = $derived(renderState ? renderState.scale : 1);
$effect(() => {
if (renderState) {
render = () => {renderState.renderFrame();};
}
})
$effect(() => {
if (renderState && fState.currentPageNumber) {
renderState.updatePage(fState.currentPageNumber).then(() => fitCanvasToPage())
}
});
// DOM refs
let viewState = $state({
x: 0,
y: 0
});
// Drag state
type DragState = {
startX: number;
startY: number;
@ -45,25 +53,24 @@
let dragState: DragState | undefined = $state(undefined);
// Zoom handlers
function handleZoom(event: WheelEvent) {
if (!container || !event.ctrlKey) return;
event.preventDefault();
const delta = -Math.sign(event.deltaY);
const newScale = Math.min(
Math.max(viewState.scale + delta * zoomStep * viewState.scale, minZoom),
Math.max(scale + delta * zoomStep * scale, minZoom),
maxZoom
);
if (newScale !== viewState.scale) {
if (newScale !== scale) {
const x = event.clientX;
const y = event.clientY;
const scaleChange = newScale / viewState.scale;
const scaleChange = newScale / scale;
container.scrollLeft = x * scaleChange - x + container.scrollLeft;
container.scrollTop = y * scaleChange - y + container.scrollTop;
viewState.scale = newScale;
renderState?.updateScale(newScale);
}
}
@ -95,25 +102,40 @@
}
function fitCanvasToPage() {
if (!canvas || !container) return;
const w = canvas.width;
const h = canvas.height;
if (!renderState || !container) return;
const w = renderState.width ?? 682;
const h = renderState.height ?? 798;
const targetWidth = container.offsetWidth;
const targetHeight = container.offsetHeight;
const wScale = targetWidth / w;
const hScale = targetHeight / h;
viewState.scale = Math.min(maxZoom, Math.max(minZoom, Math.min(wScale, hScale)));
const newScale = Math.min(maxZoom, Math.max(minZoom, Math.min(wScale, hScale)));
viewState.x = 0;
viewState.y = 0;
renderState?.updateScale(newScale);
}
function zoomIn() {
const newScale = Math.min(scale + (zoomStep * scale), maxZoom);
if (newScale !== scale) {
renderState?.updateScale(newScale);
}
}
function zoomOut() {
const newScale = Math.max(scale - (zoomStep * scale), minZoom);
if (newScale !== scale) {
renderState?.updateScale(newScale);
}
}
function resetZoom() {
viewState.scale = 1;
if (container) {
viewState.x = 0;
viewState.y = 0;
}
renderState?.updateScale(1);
}
// Event listener management
@ -124,20 +146,12 @@
}
});
$effect(() => {
if (canvas) {
currentImgUrl = canvas.toDataURL();
} else if (imgUrl) {
currentImgUrl = imgUrl;
}
});
</script>
<div class="relative w-full h-full">
<ZoomControls
scale={viewState.scale}
onZoomIn={() => (viewState.scale = Math.min(viewState.scale + (zoomStep * viewState.scale), maxZoom))}
onZoomOut={() => (viewState.scale = Math.max(viewState.scale - (zoomStep * viewState.scale), minZoom))}
scale={scale}
onZoomIn={() => zoomIn()}
onZoomOut={() => zoomOut()}
{resetZoom}
fit={fitCanvasToPage}
showFit={!image}
@ -153,23 +167,11 @@
style:height={height + "px"}
style:max-height={height + "px"}
>
{#if imgUrl}
<img
class="zoomimage"
bind:this={image}
alt="rendered-page"
src={currentImgUrl}
style:transform="translate({viewState.x}px, {viewState.y}px) scale({viewState.scale})"
style:max-height={height + "px"}
/>
{:else}
<canvas
bind:this={canvas}
class="zoomimage"
style:transform="translate({viewState.x}px, {viewState.y}px) scale({viewState.scale})"
>
</canvas>
{/if}
<canvas
bind:this={canvas}
style:transform="translate({viewState.x}px, {viewState.y}px)"
>
</canvas>
</div>
</div>
@ -187,8 +189,4 @@
cursor: grabbing;
}
.zoomimage {
position: relative;
}
</style>

View File

@ -1,66 +1,177 @@
<script lang="ts">
import { Pane, Splitpanes } from 'svelte-splitpanes';
import { BugOutline } from "flowbite-svelte-icons";
import type FileViewState from '../models/FileViewState.svelte';
import ContentModel from '../models/ContentModel.svelte';
import type DocumentWorker from '../models/Document.svelte';
import ContentEditor from './ContentEditor.svelte';
import ZoomableContainer from './ZoomableContainer.svelte';
let display: HTMLCanvasElement;
import PageRenderView from './PageRenderView.svelte';
import { DebugStepper } from '../models/Stepper.svelte.js';
let { fState, height }: { fState: FileViewState, height: number } = $props();
let renderer: DocumentWorker = $derived(fState.document);
let pageNum = $derived(fState.currentPageNumber);
let contents: ContentModel | undefined = $state(undefined);
let render: () => void = $state(() => {});
let stepper: DebugStepper | undefined = $state();
let breakpoints: Set<number> = $state(new Set<number>());
function contentsUpdatedEvent() {
render();
}
$effect(() => {
refresh();
});
function startDebug() {
if (!pageNum) return;
stepper = new DebugStepper(pageNum, breakpoints);
fState.document.stepperManager.register(stepper);
render();
}
function handleBreakpointsChange(newBreakpoints: Set<number>) {
breakpoints = newBreakpoints;
stepper?.updateBreakPoints(breakpoints);
}
function refresh() {
if (pageNum) {
renderer.getContents(pageNum).then(parts => {
contents = parts;
renderer.renderPage(pageNum, display);
}
);
} else {
contents = undefined;
function continueExecution() {
if (stepper) {
stepper.continue();
}
}
async function update(newData: string) {
if (pageNum) {
await fState.document.updateContents(pageNum, newData);
refresh();
function stepExecution() {
if (stepper) {
stepper.step();
}
}
function stopDebugging() {
if (stepper) {
fState.document.stepperManager.unregister(stepper);
stepper = undefined;
render();
}
}
</script>
{#if pageNum}
<Splitpanes theme="forge-movable">
<Pane minSize={1}>
<div class="overflow-hidden">
{#if contents}
<ContentEditor save={(newData) => update(newData)} contents={contents} height={height - 1}></ContentEditor>
<div class="controls">
<h1 class="text-forge-text font-semibold">Contents</h1>
<button class="debugButton m-1" on:click={startDebug}>
<BugOutline size="md"></BugOutline>
</button>
{#if breakpoints.size > 0}
<span class="breakpoint-count">{breakpoints.size}</span>
{/if}
</div>
<div class="overflow-hidden relative h-full">
<ContentEditor
{fState}
height={stepper ? (height - 101) : (height - 1)}
{contentsUpdatedEvent}
onBreakpointsChange={handleBreakpointsChange}
currentOgIdx={stepper?.currentIdx}
></ContentEditor>
{#if stepper}
<div class="debug-terminal">
<div class="debug-terminal-header">
<h2 class="text-forge-text font-semibold">Debug Terminal</h2>
<div class="debug-info">
{#if stepper.onBreak}
<span class="break-indicator">Paused at operation {stepper.currentIdx}</span>
{:else}
<span class="running-indicator">Running...</span>
{/if}
</div>
</div>
<div class="debug-controls">
<button class="debug-button continue" on:click={continueExecution}>
Continue
</button>
<button class="debug-button step" on:click={stepExecution}>
Step
</button>
<button class="debug-button stop" on:click={stopDebugging}>
Stop Debugging
</button>
</div>
</div>
{/if}
</div>
</div>
</Pane>
<Pane minSize={1}>
<ZoomableContainer bind:canvas={display} {height}></ZoomableContainer>
<PageRenderView bind:render {fState} {height}></PageRenderView>
</Pane>
</Splitpanes>
{:else }
<h1>Please select a Page!</h1>
{/if}
<style lang="postcss">
.scroller {
@apply w-full h-full bg-forge-dark;
overflow: auto;
display: flex;
}
.container {
display: contents;
}
.controls {
@apply bg-forge-dark border-forge-prim border-b flex flex-row;
position: relative;
align-items: center;
z-index: 1;
width: 100%;
height: 30px;
}
.debugButton {
@apply bg-forge-prim rounded-sm hover:bg-forge-acc text-green-500 p-1;
position: absolute;
right: 0;
cursor: pointer;
}
.breakpoint-count {
@apply bg-red-600 text-white text-xs font-bold px-1.5 py-0.5 rounded-full;
position: absolute;
right: 2.5rem;
top: 6px;
}
.debug-terminal {
@apply bg-forge-dark border-forge-prim border-t;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 100px;
z-index: 10;
}
.debug-terminal-header {
@apply bg-forge-prim flex flex-row justify-between items-center px-3 py-1;
height: 30px;
}
.debug-info {
@apply flex flex-row items-center;
}
.break-indicator {
@apply text-yellow-300 text-sm;
}
.running-indicator {
@apply text-green-400 text-sm;
}
.debug-controls {
@apply flex flex-row p-2 space-x-2;
}
.debug-button {
@apply px-4 py-2 rounded-md font-medium text-white;
cursor: pointer;
}
.debug-button.continue {
@apply bg-green-600 hover:bg-green-700;
}
.debug-button.step {
@apply bg-blue-600 hover:bg-blue-700;
}
.debug-button.stop {
@apply bg-red-600 hover:bg-red-700;
}
</style>

View File

@ -18,7 +18,7 @@
{#if streamData}
{#if streamData.type === "Image"}
<ImageViewer img={streamData.imageData} {height}
<ImageViewer imgUrl={streamData.imageData} {height}
></ImageViewer>
{:else if streamData.textData}
<StreamEditor

View File

@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from "svelte";
import { UploadOutline } from "flowbite-svelte-icons";
import * as monaco from "monaco-editor";
// Define a custom theme for PDF content stream highlighting
@ -46,7 +45,7 @@
onMount(() => {
editor = monaco.editor.create(editorContainer, {
value: "",
language: 'pdf-content-stream',
language: 'plaintext',
theme: "forge-dark",
minimap: { enabled: false },
scrollBeyondLastLine: false,

View File

@ -4,8 +4,7 @@
let {upload} = $props();
let result_message = $state("");
</script>
<h1 class="text-xl m-5">Welcome to PDF Forge!</h1>
<div class="w-full h-[20%]"></div>
<div class="row" on:click={upload}>
<img src="/pdf-forge-logo-bg.png" class="logo forge" alt="PDF Forge Logo"/>
</div>

View File

@ -18,7 +18,7 @@
onZoomIn: () => void;
onZoomOut: () => void;
resetZoom: () => void;
fit: () => void;
fit?: () => void;
showFit: boolean;
} = $props();

View File

@ -1,10 +1,12 @@
import type { OperatorList } from './OperatorList';
export default class ContentModel {
pageNum: number;
content: string;
opList: OperatorList;
constructor(content: string, opList: OperatorList) {
constructor(pageNum: number, content: string, opList: OperatorList) {
this.pageNum = pageNum;
this.content = content;
this.opList = opList;
}
@ -17,7 +19,7 @@ export default class ContentModel {
for (let i = 0; i < this.opList.rangeArray.length; i++){
const oldRange: number[] = this.opList.rangeArray[i];
if (!oldRange) {
newRanges.push([0, 0]);
newRanges.push(null);
continue;
}
newRanges.push([result.lookup[oldRange[0]], result.lookup[oldRange[1]]]);

View File

@ -5,130 +5,150 @@ import type { TreeViewModel } from './TreeViewModel';
import type XRefTable from './XRefTable';
import ContentModel from './ContentModel.svelte';
import { OperatorList } from './OperatorList';
import { StepperManager } from './Stepper.svelte';
export default class DocumentWorker {
doc: PDFDocumentProxy;
stepperManager: StepperManager;
renderingTask: { promise: Promise<void>; cancel: () => void } | null;
doc: PDFDocumentProxy;
constructor(doc: PDFDocumentProxy, stepperManager: StepperManager) {
this.doc = doc;
this.renderingTask = null;
this.stepperManager = stepperManager;
}
constructor(doc: PDFDocumentProxy) {
this.doc = doc;
}
static async load(data: string | ArrayBuffer) {
console.log("Initializing pdf worker")
GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
const loadingTask = getDocument({ data });
const doc = await loadingTask.promise;
return new DocumentWorker(doc);
}
static async load(data: string | ArrayBuffer) {
GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
const stepperManager = new StepperManager();
// @ts-expect-error interface to untyped js
globalThis.StepperManager = stepperManager;
const loadingTask = getDocument({ data, pdfBug: true });
const doc = await loadingTask.promise;
return new DocumentWorker(doc, stepperManager);
}
public async save() {
return this.doc.saveDocument();
}
public async save() {
return this.doc.saveDocument();
}
public async getTitle(): Promise<string | undefined> {
let title = this.doc._pdfInfo.Title;
console.log(this.doc._pdfInfo);
if (title && title !== "") {
return title;
}
const metadata = await this.doc.getMetadata()
public async getTitle(): Promise<string | undefined> {
let title = this.doc._pdfInfo.Title;
if (title && title !== '') {
return title;
}
const metadata = await this.doc.getMetadata();
title = metadata?.info?.Title;
return title === "" ? undefined : title;
}
title = metadata?.info?.Title;
return title === '' ? undefined : title;
}
public getNumberOfPages(): number {
return this.doc.numPages;
}
public getNumberOfPages(): number {
return this.doc.numPages;
}
public async getPrimByPath(path: string): Promise<PrimitiveModel> {
return await this.doc.getPrimitiveByPath(path);
}
public async getPrimByPath(path: string): Promise<PrimitiveModel> {
return await this.doc.getPrimitiveByPath(path);
}
public async getPrimTreeByPath(request: TreeViewRequest[]): Promise<TreeViewModel[]> {
return await this.doc.getPrimitiveTree(request);
}
public async getPrimTreeByPath(request: TreeViewRequest[]): Promise<TreeViewModel[]> {
return await this.doc.getPrimitiveTree(request);
}
public async getXrefEntries(): Promise<XRefTable> {
return await this.doc.getXRefEntries();
}
public async getXrefEntries(): Promise<XRefTable> {
return await this.doc.getXRefEntries();
}
public async getStreamAsString(path: string): Promise<string> {
return await this.doc.getStreamAsString(path);
}
public async getStreamAsString(path: string): Promise<string> {
return await this.doc.getStreamAsString(path);
}
public async getContents(pageNumber: number): Promise<ContentModel> {
try {
const page = await this.doc.getPage(pageNumber);
const contentsPromise = page.getContents();
const operatorList = await page.getOperatorList(pageNumber)
const opList = OperatorList.createUnfoldedOpList(operatorList.fnArray, operatorList.argsArray, operatorList.rangeArray);
return new ContentModel(await contentsPromise, opList);
} catch (error: unknown) {
console.error(error);
return new ContentModel("", new OperatorList([], [], []));
}
}
public async getContents(pageNumber: number): Promise<ContentModel> {
try {
const page = await this.doc.getPage(pageNumber);
const contentsPromise = page.getContents();
const operatorList = await page.getOperatorList(pageNumber);
const opList = OperatorList.createUnfoldedOpList(
operatorList.fnArray,
operatorList.argsArray,
operatorList.rangeArray
);
return new ContentModel(pageNumber, await contentsPromise, opList);
} catch (error: unknown) {
console.error(error);
return new ContentModel(pageNumber, '', new OperatorList([], [], [], []));
}
}
public async updateContents(pageNumber: number, newContents: string) {
const page = await this.doc.getPage(pageNumber);
await page.updateContents(newContents);
}
public async updateContents(pageNumber: number, newContents: string) {
if (this.renderingTask) {
await this.renderingTask.promise;
}
const page = await this.doc.getPage(pageNumber);
await page.updateContents(newContents);
}
public dispose() {
this.doc.destroy();
}
public dispose() {
this.doc.destroy();
}
public async getImageAsUrl(path: string): Promise<string> {
const imageData = await this.doc.getImageDataByPath(path);
public async getImageAsUrl(path: string): Promise<string> {
const imageData = await this.doc.getImageDataByPath(path);
const canvas = document.createElement('canvas');
canvas.width = imageData.width;
canvas.height = imageData.height;
const canvas = document.createElement('canvas');
canvas.width = imageData.width;
canvas.height = imageData.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Could not get canvas context');
}
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.putImageData(new ImageData(imageData.data, imageData.width, imageData.height), 0, 0);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Could not get canvas context');
}
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.putImageData(new ImageData(imageData.data, imageData.width, imageData.height), 0, 0);
return canvas.toDataURL('image/png');
}
return canvas.toDataURL('image/png');
}
public async getOperatorList(num: number) {
const page = await this.doc.getPage(num);
return await page.getOperatorList();
}
public async getOperatorList(num: number) {
const page = await this.doc.getPage(num);
return await page.getOperatorList();
}
public async renderPage(num: number, canvas: HTMLCanvasElement) {
const page = await this.doc.getPage(num);
const scale = 1.5;
const viewport = page.getViewport({ scale: scale, });
canvas.height = viewport.height;
canvas.width = viewport.width;
canvas.style.width = Math.floor(canvas.width) + "px";
canvas.style.height = Math.floor(canvas.height) + "px";
const context = canvas.getContext("2d");
if (!context) return "";
const transform = [1, 0, 0, 1, 0, 0];
// viewport = page.getViewport({ scale: 0.7, offsetX: canvas.width / 4, offsetY: canvas.height / 4});
public async renderPage(
num: number,
canvas: HTMLCanvasElement,
scale: number
) {
if (this.renderingTask) {
this.renderingTask.cancel();
this.renderingTask = null;
}
const page = await this.doc.getPage(num);
const renderContext = {
canvasContext: context,
transform: transform,
viewport: viewport,
};
const renderTask = page.render(renderContext);
const viewport = page.getViewport({ scale: scale });
canvas.height = viewport.height;
canvas.width = viewport.width;
canvas.style.width = Math.floor(canvas.width) + 'px';
canvas.style.height = Math.floor(canvas.height) + 'px';
const context = canvas.getContext('2d');
if (!context) return '';
const transform = [1, 0, 0, 1, 0, 0];
context.fillStyle = '#ffffff';
context.fillRect(viewport.offsetX, viewport.offsetY, viewport.width, viewport.height);
await renderTask.promise
}
const renderContext = {
canvasContext: context,
transform: transform,
viewport: viewport
};
this.renderingTask = page.render(renderContext);
context.fillStyle = '#ffffff';
context.fillRect(viewport.offsetX, viewport.offsetY, viewport.width, viewport.height);
await this.renderingTask?.promise;
this.renderingTask = null;
}
public async getPage(pageNum: number) {
return await this.doc.getPage(pageNum);
}
}

View File

@ -7,63 +7,33 @@ export interface PDFOperator {
class: string;
}
export class OperatorList {
fnArray: number[];
argsArray: [];
rangeArray: number[][];
constructor(fnArray: [], argsArray: [], rangeArray: []) {
this.fnArray = fnArray;
this.argsArray = argsArray;
this.rangeArray = rangeArray;
export interface Range {
start: number;
end: number;
}
export class Operator {
fnId: keyof typeof opLookup;
args: [] | null;
range: Range | null;
ogIdx: number;
constructor(fnId: number, args: [] | null, range: Range | null, ogIdx: number) {
// @ts-expect-error can not happen
this.fnId = fnId;
this.args = args;
this.range = range;
this.ogIdx = ogIdx;
}
public setRangeArray(rangeArray: number[][]) {
this.rangeArray = rangeArray;
getPDFOperator(): PDFOperator {
return opLookup[this.fnId];
}
public static createUnfoldedOpList(fnArray: [], argsArray: [], rangeArray: []) {
const unfoldedFnArray = [];
const unfoldedArgsArray = [];
const unfoldedRangeArray = [];
for (let i = 0; i < fnArray.length; i++) {
unfoldedFnArray.push(fnArray[i]);
unfoldedArgsArray.push(argsArray[i]);
unfoldedRangeArray.push(rangeArray[i]);
if (fnArray[i] == 91) {
const [subFnArray, subArgsArray, subRangeArray] = OperatorList.unfoldConstructPath(
argsArray[i]
);
unfoldedFnArray.push(...subFnArray);
unfoldedArgsArray.push(...subArgsArray);
unfoldedRangeArray.push(...subRangeArray);
}
}
return new OperatorList(unfoldedFnArray, unfoldedArgsArray, unfoldedRangeArray);
}
private static unfoldConstructPath(args: [[], [], [], []]) {
const [subFnArray, subArgs, , subRanges] = args;
const subArgsArray = [];
const subRangesArray = [];
let argsOffset = 0;
for (let i = 0; i < subFnArray.length; i++) {
const op = getPDFOperator(subFnArray[i]);
const rangeOffset = i * 2;
const range = subRanges.slice(rangeOffset, rangeOffset + 2);
subRangesArray.push(range);
const newSubArgs = [];
for (let j = 0; j < (op.numArgs ?? 0); j++) {
newSubArgs.push(subArgs[argsOffset++]);
}
subArgsArray.push(newSubArgs);
}
return [subFnArray, subArgsArray, subRangesArray];
}
public static formatOperatorToMarkdown(operatorId: number, args: any): string {
const operator = opLookup[operatorId] as PDFOperator;
toMarkdown() {
const operator = this.getPDFOperator();
if (!operator) {
return `Unknown operator: ${operatorId}`;
return `Unknown operator: ${this.fnId}`;
}
const keyword = operator.keyword ? `\`${operator.keyword}\`` : 'N/A';
@ -71,12 +41,16 @@ export class OperatorList {
operator.numArgs
? `Arguments: ${operator.numArgs}${operator.variableArgs ? ' (variable)' : ''}`
: 'No arguments';
if (operator.keyword === "Tj" || operator.keyword === "TJ" && Array.isArray(args)) {
if ((operator.keyword === "Tj" || operator.keyword === "TJ") && Array.isArray(this.args)) {
let content = '';
content += "`"
for (const arg of args[0]) {
if (arg.unicode) {
content += arg.unicode;
const textArgs = this.args.at(0)
if (textArgs && Array.isArray(textArgs)) {
// @ts-expect-error I don't know how to properly type this
for (const arg of textArgs) {
if (arg.unicode) {
content += arg.unicode;
}
}
}
content += "`"
@ -91,7 +65,110 @@ export class OperatorList {
}
}
export interface OperatorListModel {
fnArray: number[];
argsArray: [];
rangeArray: number[][];
}
export class OperatorList {
fnArray: number[];
argsArray: [];
rangeArray: number[][];
ogIdx: number[];
constructor(fnArray: [], argsArray: [], rangeArray: [], ogIdx: number[]) {
this.fnArray = fnArray;
this.argsArray = argsArray;
this.rangeArray = rangeArray;
this.ogIdx = ogIdx;
}
public length() {
return this.fnArray.length;
}
public ogLength() {
const l = this.ogIdx.at(-1);
if (l === undefined) {
return 0;
}
return l + 1;
}
public setRangeArray(rangeArray: number[][]) {
this.rangeArray = rangeArray;
}
public static fromModel(opList: OperatorListModel): OperatorList {
// @ts-expect-error impossibly difficult to type
return this.createUnfoldedOpList(opList.fnArray, opList.argsArray, opList.rangeArray);
}
public static createUnfoldedOpList(fnArray: [], argsArray: [], rangeArray: []) {
const unfoldedFnArray = [];
const unfoldedArgsArray = [];
const unfoldedRangeArray = [];
const ogIdxArray = [];
for (let i = 0; i < fnArray.length; i++) {
if (fnArray[i] == 91) {
const [subFnArray, subArgsArray, subRangeArray] = OperatorList.unfoldConstructPath(
argsArray[i]
);
unfoldedFnArray.push(...subFnArray);
unfoldedArgsArray.push(...subArgsArray);
unfoldedRangeArray.push(...subRangeArray);
for (let j = 0; j < subFnArray.length; j++) {
ogIdxArray.push(i);
}
} else {
unfoldedFnArray.push(fnArray[i]);
unfoldedArgsArray.push(argsArray[i]);
unfoldedRangeArray.push(rangeArray[i]);
ogIdxArray.push(i);
}
}
// @ts-expect-error I don't know how to properly type this
return new OperatorList(unfoldedFnArray, unfoldedArgsArray, unfoldedRangeArray, ogIdxArray);
}
private static unfoldConstructPath(args: [[], [], [], []]) {
const [subFnArray, subArgs, , subRanges] = args;
const subArgsArray = [];
const subRangesArray = [];
let argsOffset = 0;
for (let i = 0; i < subFnArray.length; i++) {
const op = getPDFOperator(subFnArray[i]);
const rangeOffset = i * 2;
const range = subRanges.slice(rangeOffset, rangeOffset + 2);
subRangesArray.push(range);
// @ts-expect-error I don't know how to properly type this
const newSubArgs = [];
for (let j = 0; j < (op.numArgs ?? 0); j++) {
newSubArgs.push(subArgs[argsOffset++]);
}
// @ts-expect-error I don't know how to properly type this
subArgsArray.push(newSubArgs);
}
return [subFnArray, subArgsArray, subRangesArray];
}
public getOperatorAt(i: number) {
const fnId = this.fnArray.at(i);
const args = this.argsArray.at(i) ?? null;
const range = this.rangeArray.at(i);
const ogIdx = this.ogIdx.at(i);
if (fnId && ogIdx !== undefined) {
return new Operator(fnId, args, Array.isArray(range) ? {start: range[0], end: range[1]} : null, ogIdx);
} else {
throw new Error(`Operator List Index not found at ${i}`);
}
}
}
export function getPDFOperator(id: number | string): PDFOperator {
// @ts-expect-error I don't know how to properly type this
return opLookup[id];
}

View File

@ -0,0 +1,45 @@
import type DocumentWorker from './Document.svelte';
import type FileViewState from './FileViewState.svelte';
export class PageRenderState {
canvas: HTMLCanvasElement;
doc: DocumentWorker;
fState: FileViewState;
pageNum: number | undefined;
width: number | undefined;
height: number | undefined;
scale: number = $state(1);
constructor(fState: FileViewState, canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.doc = fState.document;
this.fState = fState;
this.scale = 1;
}
async updatePage(pageNum: number) {
if (pageNum === this.pageNum) {
return;
}
const page = await this.doc.getPage(pageNum);
const viewPort = page.getViewport({ scale: 1, });
this.pageNum = pageNum;
this.width = viewPort.width;
this.height = viewPort.height;
}
updateScale(scale: number) {
this.scale = scale;
return this.renderFrame();
}
public async renderFrame() {
if (this.fState.currentPageNumber) {
await this.doc.renderPage(this.fState.currentPageNumber, this.canvas, this.scale);
}
}
}

View File

@ -0,0 +1,90 @@
import type { OperatorListModel } from './OperatorList';
export class DebugStepper {
breakPoints: number[];
nextBreakPoint: number | null = null;
currentIdx: number = $state(0);
pageNum: number = 0;
onBreak: boolean = $state(false);
opList: OperatorListModel | null = null;
callback: (() => void) | null = null;
constructor(pageNum: number, breakPoints: Set<number>) {
this.pageNum = pageNum;
this.breakPoints = Array.from(breakPoints).sort(function (a, b) {
return b - a;
});
}
updateBreakPoints(breakPoints: Set<number>) {
this.breakPoints = Array.from(breakPoints).filter(a => a > this.currentIdx).sort(function (a, b) {
return b - a;
});
}
public async breakIt(idx: number, callback: () => void) {
this.onBreak = true;
this.currentIdx = idx;
this.callback = callback;
}
public continue() {
this.nextBreakPoint = this.popNextBreakPoint();
if (this.callback) {
this.callback();
}
this.onBreak = false;
this.callback = null;
this._resume();
}
public step() {
this.nextBreakPoint = this.currentIdx + 1;
this._resume();
}
private _resume() {
if (this.callback) {
this.callback();
}
this.onBreak = false;
this.callback = null;
}
public getNextBreakPoint() {
return this.nextBreakPoint ?? this.breakPoints.pop();
}
private popNextBreakPoint() {
return this.breakPoints.pop() ?? null;
}
init(operatorList: OperatorListModel) {
this.opList = operatorList;
}
updateOperatorList(operatorList: OperatorListModel) {
this.opList = operatorList;
}
}
export class StepperManager {
stepper: DebugStepper | null = null;
enabled: boolean = false;
register(stepper: DebugStepper) {
this.stepper = stepper;
this.enabled = true;
}
unregister(stepper: DebugStepper) {
this.stepper = null;
this.enabled = false;
}
// must be called like this for api reasons
create(pageNum: number) {
return this.stepper;
}
}

6
src/models/ViewState.ts Normal file
View File

@ -0,0 +1,6 @@
export type ViewState = {
x: number;
y: number;
scale: number;
}

File diff suppressed because one or more lines are too long