<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>⚡ RasgaPDF — Blazing-Fast 2D Spatial PDF Parser in Rust (Python, Node.js, Go, C#)</title>
<meta name="description" content="RasgaPDF is an ultra-fast 2D Spatial PDF Parser powered by pure Rust (~3-8 ms/page). Extract exact [x0, y0, x1, y1] bounding boxes, text blocks, tables, embedded images, and vector paths for Spatial RAG, LLMs, Python, Node.js, Rust, Go, and C#.">
<meta name="keywords" content="rasgapdf, pdf parser, spatial pdf parser, rust pdf parser, python pdf parser, nodejs pdf parser, pdf bounding boxes, extract pdf coordinates, x0 y0 x1 y1 pdf, pdf table extraction, spatial rag, llm pdf parser, pdf to json, extrair coordenadas pdf, ler pdf rapido python">
<meta name="author" content="Messias Martins">
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1">
<link rel="canonical" href="https://martinsmessias.github.io/rasgapdf/">
<link rel="alternate" type="text/plain" href="llms.txt" title="LLM Context Summary">
<link rel="alternate" type="text/plain" href="llms-full.txt" title="LLM Full Technical Reference">
<!-- Icons & Theme -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
<meta name="theme-color" content="#0d1117">
<meta property="og:image" content="https://martinsmessias.github.io/rasgapdf/og-preview.png">
<meta name="twitter:image" content="https://martinsmessias.github.io/rasgapdf/og-preview.png">
<!-- Open Graph / Social / AI Grounding -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="RasgaPDF">
<meta property="og:title" content="⚡ RasgaPDF — Blazing-Fast 2D Spatial PDF Parser in Pure Rust">
<meta property="og:description" content="Extract exact 2D physical coordinates [x0, y0, x1, y1], structured text blocks, tables, images, and vector paths in ~3-8 ms/page. Native bindings for Python, Node.js, Rust, Go & C#.">
<meta property="og:url" content="https://martinsmessias.github.io/rasgapdf/">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="⚡ RasgaPDF — 2D Spatial PDF Parser in Pure Rust">
<meta name="twitter:description" content="Extract exact [x0, y0, x1, y1] bounding boxes, tables, images & vector paths at ~3-8 ms/page. Available on PyPI, npm, and Crates.io.">
<!-- Schema.org Structured Data (JSON-LD) for Google & LLMs -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "SoftwareSourceCode",
"@id": "https://github.com/MartinsMessias/rasgapdf#code",
"name": "RasgaPDF",
"alternateName": ["rasgapdf", "RasgaPDF Spatial PDF Parser"],
"description": "Blazing-fast 2D Spatial PDF Parser written in pure Rust with native C-ABI bindings for Python, Node.js, Go, and C# (.NET). Extracts exact [x0, y0, x1, y1] bounding boxes, typography metadata, paragraphs, tables, embedded images, and vector drawings in ~3-8 ms per page.",
"version": "0.3.2",
"license": "https://www.gnu.org/licenses/agpl-3.0",
"codeRepository": "https://github.com/MartinsMessias/rasgapdf",
"programmingLanguage": ["Rust", "Python", "JavaScript", "TypeScript", "Go", "C#"],
"runtimePlatform": ["Linux", "Windows", "macOS", "WebAssembly / Browser"],
"author": {
"@type": "Person",
"name": "Messias Martins",
"url": "https://github.com/MartinsMessias"
},
"keywords": "pdf, spatial pdf parser, bounding-box, layout-analysis, table-extraction, rust, python, nodejs, rag, llm, document-parsing, pdf-to-json",
"sameAs": [
"https://github.com/MartinsMessias/rasgapdf",
"https://pypi.org/project/rasgapdf/",
"https://crates.io/crates/rasgapdf",
"https://www.npmjs.com/package/rasgapdf",
"https://huggingface.co/martinsmessias/rasgapdf",
"https://huggingface.co/spaces/martinsmessias/rasgapdf"
]
},
{
"@type": "SoftwareApplication",
"name": "RasgaPDF 2D Spatial PDF Parser & Visualizer",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Linux, Windows, macOS, Web",
"softwareVersion": "0.3.2",
"url": "https://martinsmessias.github.io/rasgapdf/",
"downloadUrl": "https://pypi.org/project/rasgapdf/",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
}
},
{
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What is RasgaPDF and how fast is it?",
"acceptedAnswer": {
"@type": "Answer",
"text": "RasgaPDF is an open-source 2D spatial PDF parser written in pure Rust (powered by pure Rust and Rayon SIMD parallelism). It extracts exact [x0, y0, x1, y1] bounding boxes in ~3 to 8 ms per page—roughly 4x faster than PyMuPDF, 12x faster than pypdf, and over 50x faster than pdfplumber."
}
},
{
"@type": "Question",
"name": "How do I install RasgaPDF in Python, Node.js, or Rust?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Install via PyPI with pip install rasgapdf, via npm with npm install rasgapdf, or via Cargo with cargo add rasgapdf. Go and C# (.NET 8) bindings are also included."
}
},
{
"@type": "Question",
"name": "Why use RasgaPDF for RAG and LLM pipelines?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Unlike traditional PDF parsers that flatten multi-column layouts and break tables, RasgaPDF preserves exact 2D physical geometry [x0, y0, x1, y1] for every span, line, block, table cell, image, and vector drawing, enabling accurate Spatial RAG, visual citation grounding, and layout-aware document AI."
}
}
]
}
]
}
</script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js" integrity="sha384-/1qUCSGwTur9vjf/z9lmu/eCUYbpOTgSjmpbMQZ1/CtX2v/WcAIKqRv+U1DUCG6e" crossorigin="anonymous"></script>
<style>
/* Monospace numerical coordinates */
.coord-font {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
color: #38bdf8;
}
/* Custom scrollbars */
::-webkit-scrollbar { width: 7px; height: 7px; }
::-webkit-scrollbar-track { background: #090d16; }
::-webkit-scrollbar-thumb { background: #26334d; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #3b4d71; }
</style>
</head>
<body class="bg-slate-950 text-slate-100 h-full flex flex-col font-sans overflow-x-hidden">
<noscript>
<section style="padding:2rem;background:#0f172a;color:#f8fafc;font-family:sans-serif;">
<h1>RasgaPDF — The Blazing-Fast 2D Spatial PDF Parser in Pure Rust</h1>
<p>Extract exact physical coordinates <code>[x0, y0, x1, y1]</code>, structured text blocks, matrix tables, embedded images, and vector paths at ~3–8 ms per page.</p>
<ul>
<li><strong>Python (PyPI):</strong> <code>pip install rasgapdf</code> — <a href="https://pypi.org/project/rasgapdf/">pypi.org/project/rasgapdf</a></li>
<li><strong>Node.js (npm):</strong> <code>npm install rasgapdf</code> — <a href="https://www.npmjs.com/package/rasgapdf">npmjs.com/package/rasgapdf</a></li>
<li><strong>Rust (Crates.io):</strong> <code>cargo add rasgapdf</code> — <a href="https://crates.io/crates/rasgapdf">crates.io/crates/rasgapdf</a></li>
<li><strong>GitHub Repository:</strong> <a href="https://github.com/MartinsMessias/rasgapdf">github.com/MartinsMessias/rasgapdf</a></li>
<li><strong>LLM Documentation:</strong> <a href="llms.txt">llms.txt</a> | <a href="llms-full.txt">llms-full.txt</a></li>
</ul>
</section>
</noscript>
<!-- Header (Sticky & Compact) -->
<header class="border-b border-slate-800 bg-slate-900/90 backdrop-blur sticky top-0 z-50 px-4 sm:px-6 py-2.5 shrink-0">
<div class="w-full flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<span class="text-xl sm:text-2xl font-black bg-gradient-to-r from-cyan-400 to-blue-500 bg-clip-text text-transparent">⚡ RasgaPDF</span>
<span class="text-xs px-2.5 py-0.5 rounded-full bg-cyan-950 text-cyan-400 border border-cyan-800 font-mono font-bold">v0.3.2</span>
<span id="headerComputeBadge" class="hidden sm:inline-block text-xs px-2.5 py-0.5 rounded-full bg-emerald-950 text-emerald-400 border border-emerald-800 font-mono">Client-Side Compute</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<!-- Duplicate Space Button -->
<a href="https://huggingface.co/spaces/martinsmessias/rasgapdf?duplicate=true" target="_blank" class="flex items-center gap-1.5 bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-slate-950 font-bold px-3 py-1.5 rounded-lg text-xs shadow transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
<span class="hidden xs:inline">Duplicate to Your HF Account</span>
<span class="xs:hidden">Duplicate</span>
</a>
<!-- Repository & Package Links -->
<a href="https://github.com/MartinsMessias/rasgapdf" target="_blank" class="bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 px-2.5 py-1.5 rounded-lg text-xs font-mono transition flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.53 1.032 1.53 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"></path></svg>
GitHub
</a>
<a href="https://pypi.org/project/rasgapdf/" target="_blank" class="bg-slate-800 hover:bg-slate-700 text-blue-400 border border-slate-700 px-2.5 py-1.5 rounded-lg text-xs font-mono transition">PyPI</a>
<a href="https://crates.io/crates/rasgapdf" target="_blank" class="bg-slate-800 hover:bg-slate-700 text-orange-400 border border-slate-700 px-2.5 py-1.5 rounded-lg text-xs font-mono transition">Crates</a>
<a href="https://www.npmjs.com/package/rasgapdf" target="_blank" class="bg-slate-800 hover:bg-slate-700 text-red-400 border border-slate-700 px-2.5 py-1.5 rounded-lg text-xs font-mono transition">npm</a>
<a href="https://huggingface.co/martinsmessias/rasgapdf" target="_blank" class="bg-slate-800 hover:bg-slate-700 text-cyan-400 border border-slate-700 px-2.5 py-1.5 rounded-lg text-xs font-mono transition">HF Hub</a>
</div>
</div>
</header>
<!-- Main Viewport-Filling Workspace -->
<main class="flex-1 w-full p-3 sm:p-5 flex flex-col lg:flex-row gap-4 min-h-0 overflow-y-auto lg:overflow-hidden">
<!-- Left Controls Sidebar (Compact on mobile, fixed width on desktop) -->
<aside class="w-full lg:w-80 xl:w-96 flex flex-col gap-3 shrink-0 overflow-y-visible lg:overflow-y-auto pr-0 lg:pr-1">
<!-- File Upload Card -->
<div class="bg-slate-900 border border-slate-800 rounded-xl p-4 shadow-lg">
<label class="block text-xs font-semibold text-slate-200 mb-2">📄 Select or Upload PDF</label>
<div id="dropzone" role="button" tabindex="0" aria-label="Upload PDF document" class="border-2 border-dashed border-slate-700 hover:border-cyan-500 rounded-lg p-4 sm:p-5 text-center cursor-pointer transition bg-slate-950/50">
<svg class="mx-auto h-7 w-7 text-cyan-400 mb-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path></svg>
<p class="text-xs text-slate-300 font-medium">Click to upload or drag & drop</p>
<p class="text-[10px] text-slate-500 mt-0.5">Processed 100% locally in your browser</p>
<input type="file" id="pdfFileInput" accept="application/pdf" class="hidden">
</div>
<button id="btnLoadSample" class="mt-2.5 w-full py-1.5 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg text-xs font-medium text-cyan-300 transition flex items-center justify-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
Load Built-in Sample PDF
</button>
</div>
<!-- Navigation & Controls -->
<div class="bg-slate-900 border border-slate-800 rounded-xl p-4 shadow-lg flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold text-slate-200">📖 Page Navigation</span>
<span id="pageIndicator" class="text-xs font-mono text-cyan-400">Page 1 of 1</span>
</div>
<div class="flex items-center gap-2">
<button id="btnPrevPage" class="flex-1 py-1.5 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg text-xs font-semibold disabled:opacity-50 transition">Previous</button>
<input type="range" id="pageSlider" min="1" max="1" value="1" class="flex-1 accent-cyan-400 cursor-pointer">
<button id="btnNextPage" class="flex-1 py-1.5 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg text-xs font-semibold disabled:opacity-50 transition">Next</button>
</div>
<div class="border-t border-slate-800/80 pt-2.5 flex flex-col gap-2">
<label class="flex items-center gap-2 text-xs text-slate-300 cursor-pointer">
<input type="checkbox" id="chkShowBoxes" checked class="rounded accent-cyan-400">
<span>Show 2D Bounding Boxes (Translucent Cyan)</span>
</label>
<label class="flex items-center gap-2 text-xs text-slate-300 cursor-pointer">
<input type="checkbox" id="chkShowImages" checked class="rounded accent-orange-400">
<span>Show Embedded Images (Translucent Orange)</span>
</label>
<label class="flex items-center gap-2 text-xs text-slate-300 cursor-pointer">
<input type="checkbox" id="chkShowVectors" checked class="rounded accent-fuchsia-400">
<span>Show Vector Paths (Translucent Magenta)</span>
</label>
</div>
</div>
<!-- Performance Metrics Card -->
<div class="bg-slate-900 border border-slate-800 rounded-xl p-4 shadow-lg">
<span class="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block mb-2.5">⚡ Extraction Metrics</span>
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="bg-slate-950 p-2 rounded-lg border border-slate-800/80">
<span class="text-slate-500 block text-[10px]">Parse Latency</span>
<span id="metricLatency" class="font-mono text-cyan-400 font-bold text-xs">0.00 ms</span>
</div>
<div class="bg-slate-950 p-2 rounded-lg border border-slate-800/80">
<span class="text-slate-500 block text-[10px]">Total Spans</span>
<span id="metricSpans" class="font-mono text-cyan-400 font-bold text-xs">0</span>
</div>
<div class="bg-slate-950 p-2 rounded-lg border border-slate-800/80">
<span class="text-slate-500 block text-[10px]">Embedded Images</span>
<span id="metricImages" class="font-mono text-orange-400 font-bold text-xs">0</span>
</div>
<div class="bg-slate-950 p-2 rounded-lg border border-slate-800/80">
<span class="text-slate-500 block text-[10px]">Vector Paths</span>
<span id="metricVectors" class="font-mono text-fuchsia-400 font-bold text-xs">0</span>
</div>
<div class="bg-slate-950 p-2 rounded-lg border border-slate-800/80">
<span class="text-slate-500 block text-[10px]">Canvas Dimensions</span>
<span id="metricDims" class="font-mono text-slate-300 text-[11px]">-</span>
</div>
<div class="bg-slate-950 p-2 rounded-lg border border-slate-800/80">
<span class="text-slate-500 block text-[10px]">Compute Mode</span>
<span id="metricComputeMode" class="font-mono text-amber-400 font-bold text-[11px]">Client Browser</span>
</div>
</div>
</div>
</aside>
<!-- Right Workspace Area (Full Width & Height) -->
<section class="flex-1 h-full min-h-[550px] lg:min-h-0 flex flex-col gap-3 overflow-hidden">
<!-- Navigation Tabs -->
<div class="flex border-b border-slate-800 gap-1 overflow-x-auto pb-1 text-xs font-semibold shrink-0">
<button data-tab="tab-visual" class="tab-btn px-4 py-2 border-b-2 border-cyan-400 text-cyan-300 font-bold flex items-center gap-1.5 transition">
👁️ 1. 2D Canvas Overlay
</button>
<button data-tab="tab-coords" class="tab-btn px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-200 flex items-center gap-1.5 transition">
⚡ 2. Raw Coordinates (X/Y Table)
</button>
<button data-tab="tab-text" class="tab-btn px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-200 flex items-center gap-1.5 transition">
📝 3. Pure Plain Text
</button>
<button data-tab="tab-json" class="tab-btn px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-200 flex items-center gap-1.5 transition">
📄 4. Raw JSON Export
</button>
<button data-tab="tab-images" class="tab-btn px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-200 flex items-center gap-1.5 transition">
🖼️ 5. Embedded Images
</button>
</div>
<!-- Tab Content 1: 2D Visual Overlay (Primary Tab, Full Height & Width) -->
<div id="tab-visual" class="tab-panel flex flex-col bg-slate-900 border border-slate-800 rounded-xl overflow-hidden shadow-lg flex-1 h-full min-h-0">
<div class="p-2.5 sm:p-3 bg-slate-950 border-b border-slate-800 flex flex-wrap justify-between items-center gap-2 text-xs shrink-0">
<div class="flex items-center gap-2 min-w-0">
<span class="font-semibold text-slate-200 whitespace-nowrap">Rendered Canvas Overlay</span>
</div>
<!-- Responsive Zoom Controls -->
<div class="flex items-center gap-1 shrink-0">
<span class="text-slate-400 text-[11px] mr-1 hidden sm:inline">Zoom:</span>
<button id="btnFitPage" class="px-2 py-0.5 rounded bg-cyan-950 text-cyan-400 border border-cyan-800 text-[11px] font-mono hover:bg-cyan-900 transition font-bold" title="Fit entire page vertically and horizontally">🔍 Fit Page</button>
<button id="btnFitWidth" class="px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700 text-[11px] font-mono hover:bg-slate-700 transition" title="Fit page width to screen">↔️ Fit Width</button>
<button id="btnZoom100" class="px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700 text-[11px] font-mono hover:bg-slate-700 transition">100%</button>
<button id="btnZoom150" class="px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700 text-[11px] font-mono hover:bg-slate-700 transition">150%</button>
</div>
</div>
<!-- Canvas Scroll Area: Starts from Top (y=0), Full Screen Scrollable -->
<div id="canvasScrollArea" class="flex-1 w-full h-full min-h-0 overflow-auto p-4 sm:p-6 bg-slate-950/80">
<div id="canvasWrapper" class="shadow-2xl border border-slate-700/80 rounded bg-white transition-all mx-auto w-max">
<canvas id="pdfCanvas" class="block cursor-crosshair"></canvas>
</div>
</div>
</div>
<!-- Tab Content 2: Raw Coordinates Table -->
<div id="tab-coords" class="tab-panel hidden flex flex-col bg-slate-900 border border-slate-800 rounded-xl overflow-hidden shadow-lg flex-1 h-full min-h-0">
<div class="p-3 bg-slate-950 border-b border-slate-800 flex justify-between items-center text-xs shrink-0">
<span class="font-semibold text-slate-300">Exact Physical Coordinates Matrix</span>
<span id="tableCount" class="text-slate-500 font-mono">0 spans displayed</span>
</div>
<div class="flex-1 overflow-auto w-full">
<table class="w-full text-left text-xs border-collapse">
<thead class="bg-slate-950 sticky top-0 border-b border-slate-800 text-cyan-400 font-mono font-bold">
<tr>
<th class="p-3 w-16 text-center">Page</th>
<th class="p-3 w-20">X0 (pt)</th>
<th class="p-3 w-20">Y0 (pt)</th>
<th class="p-3 w-20">X1 (pt)</th>
<th class="p-3 w-20">Y1 (pt)</th>
<th class="p-3 w-16">Size</th>
<th class="p-3 w-28">Font</th>
<th class="p-3 min-w-[280px]">Extracted Text</th>
</tr>
</thead>
<tbody id="coordsTableBody" class="divide-y divide-slate-800/60 font-sans">
<tr>
<td colspan="8" class="p-8 text-center text-slate-500">No PDF loaded. Please upload a file or click "Load Built-in Sample PDF".</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Tab Content 3: Pure Plain Text -->
<div id="tab-text" class="tab-panel hidden flex flex-col bg-slate-900 border border-slate-800 rounded-xl overflow-hidden shadow-lg flex-1 h-full min-h-0">
<div class="p-3 bg-slate-950 border-b border-slate-800 flex justify-between items-center text-xs shrink-0">
<span class="font-semibold text-slate-300">Pure Text Stream & Raw Coordinates Dump</span>
<button id="btnCopyText" class="px-3 py-1 bg-cyan-950 hover:bg-cyan-900 text-cyan-300 border border-cyan-800 rounded text-xs font-semibold flex items-center gap-1 transition">
📋 Copy to Clipboard
</button>
</div>
<textarea id="pureTextBox" readonly class="flex-1 w-full h-full bg-slate-950 p-4 text-xs font-mono text-slate-200 outline-none resize-none leading-relaxed" placeholder="Text stream will appear here..."></textarea>
</div>
<!-- Tab Content 4: JSON Export -->
<div id="tab-json" class="tab-panel hidden flex flex-col bg-slate-900 border border-slate-800 rounded-xl overflow-hidden shadow-lg flex-1 h-full min-h-0">
<div class="p-3 bg-slate-950 border-b border-slate-800 flex flex-wrap justify-between items-center gap-2 text-xs shrink-0">
<div class="flex items-center gap-2">
<span class="font-semibold text-slate-300">JSON Inspection:</span>
<div class="inline-flex rounded-lg bg-slate-900 p-0.5 border border-slate-800">
<button id="btnJsonStructured" class="px-2.5 py-1 rounded text-xs font-medium bg-cyan-950 text-cyan-300 border border-cyan-800 transition">Structured Layout</button>
<button id="btnJsonRaw" class="px-2.5 py-1 rounded text-xs font-medium text-slate-400 hover:text-slate-200 transition">Raw X/Y Spans</button>
</div>
</div>
<div class="flex items-center gap-2">
<button id="btnCopyJson" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 rounded text-xs font-semibold flex items-center gap-1 transition">
📋 Copy JSON
</button>
<button id="btnDownloadJson" class="px-3 py-1 bg-cyan-950 hover:bg-cyan-900 text-cyan-300 border border-cyan-800 rounded text-xs font-semibold flex items-center gap-1 transition">
💾 Download JSON
</button>
</div>
</div>
<pre id="jsonViewer" class="flex-1 w-full h-full bg-slate-950 p-4 text-xs font-mono text-cyan-300 overflow-auto"></pre>
</div>
<!-- Tab Content 5: Embedded Images -->
<div id="tab-images" class="tab-panel hidden flex flex-col bg-slate-900 border border-slate-800 rounded-xl overflow-hidden shadow-lg flex-1 h-full min-h-0">
<div class="p-3 bg-slate-950 border-b border-slate-800 flex flex-wrap justify-between items-center gap-2 text-xs shrink-0">
<span class="font-semibold text-slate-300">Embedded Images (decoded client-side from the content stream)</span>
<span id="imagesCount" class="text-slate-500 font-mono">0 images on page 1</span>
</div>
<div id="imagesGrid" class="flex-1 overflow-auto w-full p-3 grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 content-start">
<div class="col-span-full p-8 text-center text-slate-500 text-xs">No PDF loaded. Please upload a file or click "Load Built-in Sample PDF".</div>
</div>
</div>
</section>
</main>
<!-- Floating hover tooltip: follows the cursor over the overlay canvas -->
<div id="hoverInfo" class="hidden fixed z-40 pointer-events-none font-mono text-[11px] leading-snug text-cyan-200 bg-slate-950/95 px-2.5 py-1.5 rounded border border-cyan-700 shadow-xl truncate max-w-[420px]"></div>
<!-- File load error toast: without it a rejected PDF fails silently -->
<div id="loadError" class="hidden fixed bottom-6 left-1/2 -translate-x-1/2 z-50 w-[92%] sm:w-auto sm:max-w-lg bg-rose-950/95 border border-rose-700 text-rose-100 rounded-xl px-4 py-3 shadow-2xl">
<div class="flex items-start gap-3">
<span class="text-base leading-none mt-0.5">⚠️</span>
<div class="min-w-0 flex-1">
<p id="loadErrorTitle" class="text-sm font-bold text-rose-200">Could not open this PDF</p>
<p id="loadErrorMessage" class="text-xs text-rose-200/80 mt-1 break-words"></p>
</div>
<button id="loadErrorClose" class="shrink-0 text-rose-300/70 hover:text-white text-sm leading-none px-1" title="Dismiss">✕</button>
</div>
</div>
<!-- Embedded image lightbox -->
<div id="imageLightbox" class="fixed inset-0 z-50 bg-slate-950/90 items-center justify-center p-6" style="display: none;">
<div class="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl flex flex-col max-w-full max-h-full overflow-hidden">
<div class="flex items-start justify-between gap-6 px-4 py-2.5 bg-slate-950 border-b border-slate-800">
<div class="flex flex-col min-w-0">
<span id="lightboxTitle" class="text-sm font-bold text-orange-400 font-mono truncate">-</span>
<span id="lightboxMeta" class="text-[11px] text-slate-400 font-mono truncate">-</span>
</div>
<button id="lightboxClose" class="shrink-0 text-slate-400 hover:text-slate-100 text-base leading-none px-2 py-1 rounded hover:bg-slate-800 transition" title="Close (Esc)">✕</button>
</div>
<div class="overflow-auto bg-slate-950 flex items-center justify-center p-4">
<canvas id="lightboxCanvas" class="shadow-lg"></canvas>
</div>
<div class="px-4 py-2 bg-slate-950 border-t border-slate-800 text-[11px] text-slate-400 font-mono flex items-center justify-between gap-4">
<span id="lightboxSize">-</span>
<span class="text-slate-600">Esc to close · scroll to pan</span>
</div>
</div>
</div>
<!-- Script logic with PDF.js client-side parser -->
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
let currentPdfDoc = null;
let currentPageNum = 1;
let extractedSpans = [];
let extractedImages = [];
let extractedDrawings = [];
let currentRenderScale = 1.0;
let activeZoomMode = 'fit-page'; // 'fit-page' | 'fit-width' | 'manual'
let currentViewport = null;
let cleanPdfBitmap = null;
let hoveredSpan = null;
let hoveredImage = null;
let hoveredDrawing = null;
// Document-wide extraction cache. Each entry mirrors the Rust JSON schema
// (coordinates in PDF points, top-down) so the visualizer can rebuild any
// page from it and export a `pages[]` document exactly like the native engine.
let pageCache = [];
let docModel = null;
let docFileName = 'document.pdf';
let docParseMs = 0;
let docParseStart = 0;
let parseGeneration = 0;
// Elements
const fileInput = document.getElementById('pdfFileInput');
const dropzone = document.getElementById('dropzone');
const btnLoadSample = document.getElementById('btnLoadSample');
const pageSlider = document.getElementById('pageSlider');
const pageIndicator = document.getElementById('pageIndicator');
const btnPrevPage = document.getElementById('btnPrevPage');
const btnNextPage = document.getElementById('btnNextPage');
const chkShowBoxes = document.getElementById('chkShowBoxes');
const chkShowImages = document.getElementById('chkShowImages');
const chkShowVectors = document.getElementById('chkShowVectors');
const coordsTableBody = document.getElementById('coordsTableBody');
const tableCount = document.getElementById('tableCount');
const pureTextBox = document.getElementById('pureTextBox');
const btnCopyText = document.getElementById('btnCopyText');
const btnDownloadJson = document.getElementById('btnDownloadJson');
const btnCopyJson = document.getElementById('btnCopyJson');
const jsonViewer = document.getElementById('jsonViewer');
const imagesGrid = document.getElementById('imagesGrid');
const imagesCount = document.getElementById('imagesCount');
const imageLightbox = document.getElementById('imageLightbox');
const lightboxCanvas = document.getElementById('lightboxCanvas');
const lightboxTitle = document.getElementById('lightboxTitle');
const lightboxMeta = document.getElementById('lightboxMeta');
const lightboxSize = document.getElementById('lightboxSize');
const lightboxClose = document.getElementById('lightboxClose');
const metricLatency = document.getElementById('metricLatency');
const metricSpans = document.getElementById('metricSpans');
const metricImages = document.getElementById('metricImages');
const metricVectors = document.getElementById('metricVectors');
const metricComputeMode = document.getElementById('metricComputeMode');
const headerComputeBadge = document.getElementById('headerComputeBadge');
let currentRustDoc = null;
const loadError = document.getElementById('loadError');
const loadErrorTitle = document.getElementById('loadErrorTitle');
const loadErrorMessage = document.getElementById('loadErrorMessage');
const loadErrorClose = document.getElementById('loadErrorClose');
const metricDims = document.getElementById('metricDims');
const hoverInfo = document.getElementById('hoverInfo');
const canvasScrollArea = document.getElementById('canvasScrollArea');
const btnFitPage = document.getElementById('btnFitPage');
const btnFitWidth = document.getElementById('btnFitWidth');
const btnZoom100 = document.getElementById('btnZoom100');
const btnZoom150 = document.getElementById('btnZoom150');
const pdfCanvas = document.getElementById('pdfCanvas');
const pdfCtx = pdfCanvas.getContext('2d');
// Tabs logic
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => {
b.classList.remove('border-cyan-400', 'text-cyan-300', 'font-bold');
b.classList.add('border-transparent', 'text-slate-400');
});
btn.classList.add('border-cyan-400', 'text-cyan-300', 'font-bold');
btn.classList.remove('border-transparent', 'text-slate-400');
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden'));
const targetPanel = document.getElementById(btn.dataset.tab);
targetPanel.classList.remove('hidden');
// If switching to visual tab, guarantee instant repaint
if (btn.dataset.tab === 'tab-visual') {
if (cleanPdfBitmap) {
drawCanvas(null);
} else if (currentPdfDoc) {
renderCurrentPage();
}
}
});
});
// Zoom Buttons Styling
function updateZoomButtons() {
[btnFitPage, btnFitWidth, btnZoom100, btnZoom150].forEach(b => {
b.className = 'px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700 text-[11px] font-mono hover:bg-slate-700 transition';
});
if (activeZoomMode === 'fit-page') {
btnFitPage.className = 'px-2 py-0.5 rounded bg-cyan-950 text-cyan-400 border border-cyan-800 text-[11px] font-mono hover:bg-cyan-900 transition font-bold';
} else if (activeZoomMode === 'fit-width') {
btnFitWidth.className = 'px-2 py-0.5 rounded bg-cyan-950 text-cyan-400 border border-cyan-800 text-[11px] font-mono hover:bg-cyan-900 transition font-bold';
} else if (currentRenderScale === 1.0) {
btnZoom100.className = 'px-2 py-0.5 rounded bg-cyan-950 text-cyan-400 border border-cyan-800 text-[11px] font-mono hover:bg-cyan-900 transition font-bold';
} else if (currentRenderScale === 1.5) {
btnZoom150.className = 'px-2 py-0.5 rounded bg-cyan-950 text-cyan-400 border border-cyan-800 text-[11px] font-mono hover:bg-cyan-900 transition font-bold';
}
}
async function calculateAutoZoom(mode = activeZoomMode) {
if (!currentPdfDoc) return 1.0;
const page = await currentPdfDoc.getPage(currentPageNum);
const vp1 = page.getViewport({ scale: 1.0 });
// Available container space
const availW = Math.max(canvasScrollArea.clientWidth - 52, 280);
const availH = Math.max(canvasScrollArea.clientHeight - 52, 320);
if (mode === 'fit-page') {
// Fits both width and height cleanly without clipping
const scaleW = availW / vp1.width;
const scaleH = availH / vp1.height;
return Math.min(scaleW, scaleH);
} else if (mode === 'fit-width') {
// Fits width of available area
return availW / vp1.width;
}
return currentRenderScale;
}
btnFitPage.addEventListener('click', async () => {
activeZoomMode = 'fit-page';
currentRenderScale = await calculateAutoZoom('fit-page');
updateZoomButtons();
renderCurrentPage();
});
btnFitWidth.addEventListener('click', async () => {
activeZoomMode = 'fit-width';
currentRenderScale = await calculateAutoZoom('fit-width');
updateZoomButtons();
renderCurrentPage();
});
btnZoom100.addEventListener('click', () => {
activeZoomMode = 'manual';
currentRenderScale = 1.0;
updateZoomButtons();
renderCurrentPage();
});
btnZoom150.addEventListener('click', () => {
activeZoomMode = 'manual';
currentRenderScale = 1.5;
updateZoomButtons();
renderCurrentPage();
});
// Window Resize Observer for Responsive Fit
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(async () => {
if (currentPdfDoc && (activeZoomMode === 'fit-page' || activeZoomMode === 'fit-width')) {
currentRenderScale = await calculateAutoZoom(activeZoomMode);
renderCurrentPage();
}
}, 200);
});
// File selection
dropzone.addEventListener('click', () => fileInput.click());
dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('border-cyan-400'); });
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('border-cyan-400'));
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dropzone.classList.remove('border-cyan-400');
if (e.dataTransfer.files.length > 0) loadPdfFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) loadPdfFile(e.target.files[0]);
});
btnLoadSample.addEventListener('click', async () => {
try {
const res = await fetch('sample.pdf');
const blob = await res.blob();
loadPdfFile(blob, 'sample.pdf');
} catch (e) {
alert('Could not load sample.pdf: ' + e);
}
});
function showLoadError(err, fileName) {
const name = (err && err.name) || '';
const msg = (err && err.message) || String(err || 'Unknown error');
let title = 'Could not open this PDF';
let text = msg;
if (name === 'PasswordException' || /password/i.test(msg)) {
title = 'Password-protected PDF';
text = 'This file is encrypted with a password. Remove the password (or export an unprotected copy) and try again.';
} else if (/encryption|EBX_HANDLER|ADEPT|encrypted/i.test(msg)) {
title = 'This PDF is DRM-protected';
text = "It uses Adobe ADEPT / EBX_HANDLER encryption (typical of e-books). The content is locked by the store's key, so no PDF library can read it without removing the DRM. Try an unprotected copy.";
} else if (name === 'InvalidPDFException' || /invalid pdf/i.test(msg)) {
title = 'This file is not a valid PDF';
text = 'The file looks corrupted or is not a PDF at all. Try re-exporting it.';
}
if (fileName) text = fileName + ' — ' + text;
loadErrorTitle.textContent = title;
loadErrorMessage.textContent = text;
loadError.classList.remove('hidden');
console.error('loadPdfFile failed:', err);
}
loadErrorClose.addEventListener('click', () => loadError.classList.add('hidden'));
// Load WebAssembly Pure Rust Engine (rasgapdf.wasm)
let wasmEngine = null;
async function initWasmEngine() {
try {
const wasmRes = await fetch('rasgapdf.wasm');
if (wasmRes.ok) {
const wasmBytes = await wasmRes.arrayBuffer();
const { instance } = await WebAssembly.instantiate(wasmBytes);
wasmEngine = instance.exports;
console.log('⚡ Pure Rust WebAssembly Engine initialized successfully!');
}
} catch (err) {
console.warn('WASM engine unavailable, using browser fallback:', err);
}
}
const wasmEnginePromise = initWasmEngine();
function parsePdfWithWasm(pdfUint8Array) {
if (!wasmEngine) return null;
const t0 = performance.now();
const ptr = wasmEngine.wasm_alloc(pdfUint8Array.length);
const mem = new Uint8Array(wasmEngine.memory.buffer);
mem.set(pdfUint8Array, ptr);
// Pass parser options with extract_images and extract_drawings enabled
const optsJson = JSON.stringify({
detect_tables: true,
extract_images: true,
extract_drawings: true,
filter_background_fills: true
});
const optsBytes = new TextEncoder().encode(optsJson + "\0");
const optsPtr = wasmEngine.wasm_alloc(optsBytes.length);
const memOpts = new Uint8Array(wasmEngine.memory.buffer);
memOpts.set(optsBytes, optsPtr);
// Parse structured document (blocks, tables, images, and drawings)
const resPtr = wasmEngine.rasgapdf_parse_bytes_options
? wasmEngine.rasgapdf_parse_bytes_options(ptr, pdfUint8Array.length, optsPtr)
: wasmEngine.rasgapdf_parse_bytes(ptr, pdfUint8Array.length);
const memOut = new Uint8Array(wasmEngine.memory.buffer);
let end = resPtr;
while (memOut[end] !== 0) end++;
const jsonStr = new TextDecoder('utf-8').decode(memOut.subarray(resPtr, end));
const structured = JSON.parse(jsonStr);
// Parse raw spans for highlighting
const rawPtr = wasmEngine.rasgapdf_parse_raw_bytes(ptr, pdfUint8Array.length);
const memRaw = new Uint8Array(wasmEngine.memory.buffer);
let endRaw = rawPtr;
while (memRaw[endRaw] !== 0) endRaw++;
const rawJsonStr = new TextDecoder('utf-8').decode(memRaw.subarray(rawPtr, endRaw));
const raw = JSON.parse(rawJsonStr);
const elapsed = (performance.now() - t0).toFixed(2);
wasmEngine.rasgapdf_free_string(resPtr);
wasmEngine.rasgapdf_free_string(rawPtr);
wasmEngine.wasm_free(optsPtr, optsBytes.length);
wasmEngine.wasm_free(ptr, pdfUint8Array.length);
return {
structured: structured,
raw: raw,
parse_time_ms: elapsed,
is_wasm: true
};
}
async function loadPdfFile(file, name = null) {
const fileName = name || file.name || 'document.pdf';
try {
loadError.classList.add('hidden');
const rawBytes = new Uint8Array(await file.arrayBuffer());
// Always pass a fresh slice/copy so the buffer is never detached
const wasmBytes = rawBytes.slice();
const pdfJsBytes = rawBytes.slice();
// 1. Prioritize Pure Rust WebAssembly in the browser!
currentRustDoc = null;
if (wasmEngine) {
try {
currentRustDoc = parsePdfWithWasm(wasmBytes);
console.log('⚡ Parsed with Pure Rust WebAssembly Engine in', currentRustDoc.parse_time_ms, 'ms');
} catch (wErr) {
console.warn('WASM parse error:', wErr);
}
}
const loadingTask = pdfjsLib.getDocument({ data: pdfJsBytes });
currentPdfDoc = await loadingTask.promise;
if (!currentRustDoc) {
try {
const formData = new FormData();
formData.append('file', file, fileName);
const apiRes = await fetch('/api/parse', { method: 'POST', body: formData });
if (apiRes.ok) {
currentRustDoc = await apiRes.json();
console.log('⚡ Parsed with Native Rust Server in', currentRustDoc.parse_time_ms, 'ms');
}
} catch (e) {
console.log('Native server not reachable');
}
}
pageSlider.max = currentPdfDoc.numPages;
pageSlider.value = 1;
currentPageNum = 1;
docFileName = fileName;
// Reset the document model for the new file.
parseGeneration += 1;
pageCache = new Array(currentPdfDoc.numPages);
docModel = null;
docParseMs = 0;
// On mobile devices, default to fit-width; on desktop, default to fit-page
if (window.innerWidth < 768) {
activeZoomMode = 'fit-width';
} else {
activeZoomMode = 'fit-page';
}
if (!currentRustDoc) {
throw new Error('Pure Rust WebAssembly engine not loaded.');
}
currentRenderScale = await calculateAutoZoom(activeZoomMode);
updateZoomButtons();
await renderCurrentPage();
} catch (e) {
showLoadError(e, fileName);
}
}
let renderInFlight = false;
let renderQueued = false;
async function renderCurrentPage() {
if (!currentPdfDoc) return;
// Serialize renders: pdf.js refuses two render() calls on the same canvas,
// and zoom / resize / page-change events can fire while a page is still
// painting. Without this the later call dies silently and leaves the
// extracted arrays empty.
if (renderInFlight) {
renderQueued = true;
return;
}
renderInFlight = true;
try {
do {
renderQueued = false;
await renderPageOnce();
} while (renderQueued && currentPdfDoc);
} finally {
renderInFlight = false;
}
}
async function renderPageOnce() {
if (!currentPdfDoc) return;
const t0 = performance.now();
pageIndicator.textContent = `Page ${currentPageNum} of ${currentPdfDoc.numPages}`;
pageSlider.value = currentPageNum;
btnPrevPage.disabled = (currentPageNum <= 1);
btnNextPage.disabled = (currentPageNum >= currentPdfDoc.numPages);
const page = await currentPdfDoc.getPage(currentPageNum);
const viewport = page.getViewport({ scale: currentRenderScale });
currentViewport = viewport;
pdfCanvas.width = viewport.width;
pdfCanvas.height = viewport.height;
metricDims.textContent = `${Math.round(viewport.width / currentRenderScale)}x${Math.round(viewport.height / currentRenderScale)} pt`;
// 1. Render base PDF page directly onto canvas
await page.render({ canvasContext: pdfCtx, viewport }).promise;
// 2. Cache clean rasterized bitmap of PDF for zero-latency redrawing
cleanPdfBitmap = await createImageBitmap(pdfCanvas);
// If Native Rust Engine processed this document, use its 100% native Rust data!
if (currentRustDoc && currentRustDoc.structured && currentRustDoc.structured.pages) {
const pageData = currentRustDoc.structured.pages[currentPageNum - 1] || {};
// 1. Spans from Rust
const pageSpans = (currentRustDoc.raw && currentRustDoc.raw.spans
? currentRustDoc.raw.spans.filter(s => s.page === currentPageNum)
: []);
extractedSpans = pageSpans.map(s => {
const vx0 = s.x0 * currentRenderScale;
const vy0 = s.y0 * currentRenderScale;
const vx1 = s.x1 * currentRenderScale;
const vy1 = s.y1 * currentRenderScale;
return {
page: s.page,
x0: s.x0,
y0: s.y0,
x1: s.x1,
y1: s.y1,
font_size: s.font_size,
font_name: s.font_name || 'Helvetica',
text: s.text,
vx0,
vy0,
vw: Math.max(vx1 - vx0, 1.0),
vh: Math.max(vy1 - vy0, 1.0)
};
});
// 2. Images from Rust
extractedImages = (pageData.images || []).map(im => {
const b = im.bbox || { x0: 0, y0: 0, x1: 0, y1: 0, width: 0, height: 0 };
const vx = b.x0 * currentRenderScale;
const vy = b.y0 * currentRenderScale;
const vw = b.width * currentRenderScale;
const vh = b.height * currentRenderScale;
let thumb = null;
const uriKey = im.xref ? `xref_${im.xref}` : `${currentPageNum}_${im.image_id}`;
const uri = currentRustDoc.image_data_uris
? (currentRustDoc.image_data_uris[uriKey] || currentRustDoc.image_data_uris[im.image_id] || currentRustDoc.image_data_uris[String(im.image_id)])
: null;
if (uri) {
thumb = new Image();
thumb.src = uri;
} else if (b.width > 0 && b.height > 0) {
thumb = cropFromPage({ vx, vy, vw, vh });
}
return {
objId: im.name || '#' + im.image_id,
px: { width: im.pixel_width, height: im.pixel_height },
pt: { x0: b.x0, y0: b.y0, x1: b.x1, y1: b.y1 },
paint: { vx, vy, vw, vh },
count: 1,
thumb: thumb,
cropped: !uri,
mime: im.mime_type,
sha256: im.sha256
};
});
// 3. Vector drawings from Rust
extractedDrawings = (pageData.drawings || []).map(d => {
const b = d.bbox || { x0: 0, y0: 0, x1: 0, y1: 0, width: 0, height: 0 };
const vx = b.x0 * currentRenderScale;
const vy = b.y0 * currentRenderScale;
const vw = Math.max(b.width * currentRenderScale, 1.0);
const vh = Math.max(b.height * currentRenderScale, 1.0);
return {
type: d.drawing_type,
fill: rgb01ToHex(d.fill_color),
stroke: rgb01ToHex(d.stroke_color),
paint: { vx, vy, vw, vh },
pt: { x0: b.x0, y0: b.y0, x1: b.x1, y1: b.y1 }
};
});
// 4. Update metrics with real Rust values
const rustLatency = (currentRustDoc.parse_time_ms !== undefined)
? Number(currentRustDoc.parse_time_ms).toFixed(2)
: (performance.now() - t0).toFixed(2);
metricLatency.textContent = `${rustLatency} ms`;
metricSpans.textContent = extractedSpans.length;
metricImages.textContent = extractedImages.length;
metricVectors.textContent = extractedDrawings.length;
const isWasm = currentRustDoc && currentRustDoc.is_wasm;
const modeTitle = isWasm ? 'Pure Rust WebAssembly (WASM)' : 'Native Rust Engine (FFI)';
if (metricComputeMode) {
metricComputeMode.textContent = modeTitle;
metricComputeMode.className = 'font-mono text-emerald-400 font-bold text-[11px]';
}
if (headerComputeBadge) {
headerComputeBadge.textContent = isWasm ? '⚡ Rust WebAssembly Engine' : '⚡ Native Rust Engine';
headerComputeBadge.className = 'hidden sm:inline-block text-xs px-2.5 py-0.5 rounded-full bg-emerald-950 text-emerald-300 border border-emerald-700 font-mono font-bold';
}
updateTable();
updateTextDump();
updateImageGallery();
drawCanvas(null);
updateJsonViewer();
return;
}
// If not from Rust, clear elements
extractedSpans = [];
extractedImages = [];
extractedDrawings = [];
updateTable();
updateTextDump();
updateImageGallery();
drawCanvas(null);
updateJsonViewer();
}
function cropFromPage(paint) {
if (!cleanPdfBitmap) return null;
try {
const x = Math.max(0, Math.round(paint.vx));
const y = Math.max(0, Math.round(paint.vy));
const w = Math.max(1, Math.round(paint.vw));
const h = Math.max(1, Math.round(paint.vh));
const c = document.createElement('canvas');
c.width = w;
c.height = h;
c.getContext('2d').drawImage(cleanPdfBitmap, x, y, w, h, 0, 0, w, h);
return c;
} catch (e) {
return null;
}
}
function rgb01ToHex(rgb) {
if (!rgb || rgb.length < 3) return null;
const to = (v) => Math.max(0, Math.min(255, Math.round(v * 255))).toString(16).padStart(2, '0');
return '#' + to(rgb[0]) + to(rgb[1]) + to(rgb[2]);
}
function updateImageGallery() {
imagesGrid.innerHTML = '';
imagesCount.textContent = `${extractedImages.length} image(s) on page ${currentPageNum}`;
if (extractedImages.length === 0) {
imagesGrid.innerHTML = '<div class="col-span-full p-8 text-center text-slate-500 text-xs">No embedded images found on this page.</div>';
return;
}
extractedImages.forEach((im, idx) => {
const card = document.createElement('div');
card.className = 'bg-slate-950 border border-slate-800 rounded-lg overflow-hidden flex flex-col cursor-pointer group hover:border-orange-500/70 transition focus:outline-none focus:ring-2 focus:ring-orange-500/60';
card.title = 'Click to view full size';
card.setAttribute('role', 'button');
card.setAttribute('tabindex', '0');
card.setAttribute('aria-label',
'Enlarge image ' + (idx + 1) +
(im.px ? ', ' + im.px.width + ' by ' + im.px.height + ' pixels' : ''));
card.addEventListener('click', () => openLightbox(im, idx));
card.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openLightbox(im, idx);
}
});
const frame = document.createElement('div');
frame.className = 'relative bg-slate-900/60 flex items-center justify-center p-2 h-44';
if (im.thumb) {
im.thumb.className = 'max-h-full max-w-full object-contain shadow';
frame.appendChild(im.thumb);
} else {
frame.innerHTML = '<span class="text-slate-500 text-xs">preview unavailable</span>';
}
const hint = document.createElement('span');
hint.className = 'absolute bottom-1.5 right-2 text-[10px] font-mono text-slate-300 bg-slate-950/85 border border-slate-700 rounded px-1.5 py-0.5 opacity-0 group-hover:opacity-100 transition';
hint.textContent = '🔍 enlarge';
frame.appendChild(hint);
const label = im.objId !== null && im.objId !== undefined
? 'XObject ' + im.objId
: 'inline image';
const meta = document.createElement('div');
meta.className = 'p-2.5 text-[11px] font-mono text-slate-300 flex flex-col gap-1 border-t border-slate-800';
meta.innerHTML =
'<span class="text-orange-400 font-bold">#' + (idx + 1) + ' ' + escapeHtml(String(label)) + '</span>' +
'<span>pixels: <span class="coord-font">' + (im.px ? im.px.width + ' x ' + im.px.height : 'unknown') + '</span></span>' +
'<span>bbox pt: <span class="coord-font">[' + im.pt.x0 + ', ' + im.pt.y0 + ', ' + im.pt.x1 + ', ' + im.pt.y1 + ']</span></span>' +
'<span>painted: <span class="coord-font">' + im.count + 'x</span>' +
(im.cropped ? ' <span class="text-amber-400">(crop fallback)</span>' : '') + '</span>' +
'<span class="text-slate-500">🔍 click to enlarge</span>';
card.appendChild(frame);
card.appendChild(meta);
imagesGrid.appendChild(card);
});
}
function drawCanvas(highlightSpan = null) {
if (!cleanPdfBitmap) return;
// Draw clean base PDF
pdfCtx.drawImage(cleanPdfBitmap, 0, 0);
// Embedded image placement (orange); the hovered image gets a stronger tint
if (chkShowImages.checked) {
extractedImages.forEach(im => {
const isHovered = (im === hoveredImage);
pdfCtx.fillStyle = isHovered ? 'rgba(249, 115, 22, 0.40)' : 'rgba(249, 115, 22, 0.22)';
pdfCtx.strokeStyle = isHovered ? 'rgba(251, 146, 60, 1.0)' : 'rgba(249, 115, 22, 0.95)';
pdfCtx.lineWidth = isHovered
? Math.max(2.5, 3.2 * (currentRenderScale / 1.5))
: Math.max(1.5, 2.0 * (currentRenderScale / 1.5));
pdfCtx.fillRect(im.paint.vx, im.paint.vy, im.paint.vw, im.paint.vh);
pdfCtx.strokeRect(im.paint.vx, im.paint.vy, im.paint.vw, im.paint.vh);
});
}
// Vector paths (magenta); the hovered path gets a stronger stroke
if (chkShowVectors.checked) {
extractedDrawings.forEach(d => {
const isHovered = (d === hoveredDrawing);
const px = d.paint;
if (px.vw > 1.5 && px.vh > 1.5) {
pdfCtx.fillStyle = isHovered ? 'rgba(217, 70, 239, 0.24)' : 'rgba(217, 70, 239, 0.10)';
pdfCtx.fillRect(px.vx, px.vy, px.vw, px.vh);
}
pdfCtx.strokeStyle = isHovered ? 'rgba(240, 171, 252, 1.0)' : 'rgba(217, 70, 239, 0.85)';
pdfCtx.lineWidth = isHovered
? Math.max(2.0, 2.6 * (currentRenderScale / 1.5))
: Math.max(1.0, 1.2 * (currentRenderScale / 1.5));
pdfCtx.strokeRect(px.vx, px.vy, px.vw, px.vh);
});
}
if (!chkShowBoxes.checked) return;
extractedSpans.forEach(s => {
const isHovered = (s === highlightSpan);
if (isHovered) {
pdfCtx.fillStyle = 'rgba(6, 182, 212, 0.40)';
pdfCtx.strokeStyle = 'rgba(34, 211, 238, 1.0)';
pdfCtx.lineWidth = Math.max(1.5, 2.0 * (currentRenderScale / 1.5));
} else {
pdfCtx.fillStyle = 'rgba(6, 182, 212, 0.18)';
pdfCtx.strokeStyle = 'rgba(6, 182, 212, 0.85)';
pdfCtx.lineWidth = Math.max(1.0, 1.3 * (currentRenderScale / 1.5));
}
pdfCtx.fillRect(s.vx0, s.vy0, s.vw, s.vh);
pdfCtx.strokeRect(s.vx0, s.vy0, s.vw, s.vh);
});
}
// Interactive Hover on Canvas (text spans + embedded images).
// The tooltip is position:fixed and follows the cursor.
const TOOLTIP_MARGIN = 8;
const TOOLTIP_OFFSET = 14;
function positionTooltip(clientX, clientY) {
const box = hoverInfo.getBoundingClientRect();
let x = clientX + TOOLTIP_OFFSET;
let y = clientY + TOOLTIP_OFFSET;
if (x + box.width + TOOLTIP_MARGIN > window.innerWidth) {
x = clientX - TOOLTIP_OFFSET - box.width;
}
if (y + box.height + TOOLTIP_MARGIN > window.innerHeight) {
y = clientY - TOOLTIP_OFFSET - box.height;
}
hoverInfo.style.left = Math.max(TOOLTIP_MARGIN, Math.round(x)) + 'px';
hoverInfo.style.top = Math.max(TOOLTIP_MARGIN, Math.round(y)) + 'px';
}
function setTooltipKind(kind) {
hoverInfo.classList.remove(
'text-cyan-200', 'border-cyan-700',
'text-orange-200', 'border-orange-700',
'text-fuchsia-200', 'border-fuchsia-700'
);
if (kind === 'image') {
hoverInfo.classList.add('text-orange-200', 'border-orange-700');
} else if (kind === 'vector') {
hoverInfo.classList.add('text-fuchsia-200', 'border-fuchsia-700');
} else {
hoverInfo.classList.add('text-cyan-200', 'border-cyan-700');
}
}
function drawingTooltipText(d) {
const col = d.type === 'stroke' ? d.stroke : (d.fill || d.stroke);
return 'path · ' + d.type +
(col ? ' · ' + col : '') +
' · bbox [' + d.pt.x0 + ', ' + d.pt.y0 + ', ' + d.pt.x1 + ', ' + d.pt.y1 + ']';
}
function imageTooltipText(im) {
const label = (im.objId !== null && im.objId !== undefined) ? 'XObject ' + im.objId : 'inline image';
const px = im.px ? (im.px.width + '×' + im.px.height + ' px') : 'unknown size';
return label + ' · ' + px +
' · bbox [' + im.pt.x0 + ', ' + im.pt.y0 + ', ' + im.pt.x1 + ', ' + im.pt.y1 + ']' +
' · painted ' + im.count + 'x';
}
pdfCanvas.addEventListener('mousemove', (e) => {
if (!currentViewport) return;
const rect = pdfCanvas.getBoundingClientRect();
const scaleX = pdfCanvas.width / rect.width;
const scaleY = pdfCanvas.height / rect.height;
const mouseX = (e.clientX - rect.left) * scaleX;
const mouseY = (e.clientY - rect.top) * scaleY;
// Text spans are the smaller targets, so they win when they overlap an image.
const span = extractedSpans.find(s =>
mouseX >= s.vx0 && mouseX <= s.vx0 + s.vw &&
mouseY >= s.vy0 && mouseY <= s.vy0 + s.vh
) || null;
const img = (span || !chkShowImages.checked) ? null : (extractedImages.find(im =>
mouseX >= im.paint.vx && mouseX <= im.paint.vx + im.paint.vw &&
mouseY >= im.paint.vy && mouseY <= im.paint.vy + im.paint.vh
) || null);
// Smallest matching path wins: the table's outer rect would otherwise
// swallow its own grid lines, which are far more useful to inspect.
const drw = (span || img || !chkShowVectors.checked) ? null : (() => {
let best = null;
let bestArea = Infinity;
for (const d of extractedDrawings) {
const p = d.paint;
if (mouseX < p.vx || mouseX > p.vx + p.vw || mouseY < p.vy || mouseY > p.vy + p.vh) continue;
const area = p.vw * p.vh;
if (area < bestArea) {
bestArea = area;
best = d;
}
}
return best;
})();
if (span !== hoveredSpan || img !== hoveredImage || drw !== hoveredDrawing) {
hoveredSpan = span;
hoveredImage = img;
hoveredDrawing = drw;
drawCanvas(hoveredSpan);
if (hoveredSpan) {
setTooltipKind('text');
hoverInfo.textContent = `[${hoveredSpan.x0.toFixed(1)}, ${hoveredSpan.y0.toFixed(1)}, ${hoveredSpan.x1.toFixed(1)}, ${hoveredSpan.y1.toFixed(1)}] "${hoveredSpan.text}"`;
hoverInfo.classList.remove('hidden');
} else if (hoveredImage) {
setTooltipKind('image');
hoverInfo.textContent = imageTooltipText(hoveredImage);
hoverInfo.classList.remove('hidden');
} else if (hoveredDrawing) {
setTooltipKind('vector');
hoverInfo.textContent = drawingTooltipText(hoveredDrawing);
hoverInfo.classList.remove('hidden');
} else {
hoverInfo.classList.add('hidden');
}
}
if (hoveredSpan || hoveredImage || hoveredDrawing) positionTooltip(e.clientX, e.clientY);
});
pdfCanvas.addEventListener('mouseleave', () => {
if (hoveredSpan || hoveredImage || hoveredDrawing) {
hoveredSpan = null;
hoveredImage = null;
hoveredDrawing = null;
drawCanvas(null);
hoverInfo.classList.add('hidden');
}
});
function updateTable() {
coordsTableBody.innerHTML = '';
tableCount.textContent = `${extractedSpans.length} spans on page ${currentPageNum}`;
if (extractedSpans.length === 0) {
coordsTableBody.innerHTML = '<tr><td colspan="8" class="p-8 text-center text-slate-500">No text elements found on this page.</td></tr>';
return;
}
extractedSpans.forEach(s => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-800/40 transition border-b border-slate-800/40';
tr.innerHTML = `
<td class="p-3 text-center text-slate-400 font-mono">${s.page}</td>
<td class="p-3 coord-font">${s.x0.toFixed(2)}</td>
<td class="p-3 coord-font">${s.y0.toFixed(2)}</td>
<td class="p-3 coord-font">${s.x1.toFixed(2)}</td>
<td class="p-3 coord-font">${s.y1.toFixed(2)}</td>
<td class="p-3 coord-font">${s.font_size}</td>
<td class="p-3 text-slate-400 font-mono text-[11px]">${s.font_name}</td>
<td class="p-3 text-slate-200">${escapeHtml(s.text)}</td>
`;
coordsTableBody.appendChild(tr);
});
}
function updateTextDump() {
let flow = `=== [CLEAN TEXT FLOW] ===\n\n`;
extractedSpans.forEach(s => flow += s.text + '\n');
flow += `\n=======================================================\n`;
flow += `=== [RAW PHYSICAL COORDINATES DUMP] ===\n\n`;
extractedSpans.forEach(s => {
flow += `[Page ${s.page}] (X0: ${s.x0.toFixed(2).padStart(7)}, Y0: ${s.y0.toFixed(2).padStart(7)}, X1: ${s.x1.toFixed(2).padStart(7)}, Y1: ${s.y1.toFixed(2).padStart(7)}) [${s.font_size}pt] "${s.text}"\n`;
});
pureTextBox.value = flow;
}
let currentJsonViewMode = 'structured'; // 'structured' | 'raw'
const btnJsonStructured = document.getElementById('btnJsonStructured');
const btnJsonRaw = document.getElementById('btnJsonRaw');
if (btnJsonStructured && btnJsonRaw) {
btnJsonStructured.addEventListener('click', () => {
currentJsonViewMode = 'structured';
btnJsonStructured.className = 'px-2.5 py-1 rounded text-xs font-medium bg-cyan-950 text-cyan-300 border border-cyan-800 transition';
btnJsonRaw.className = 'px-2.5 py-1 rounded text-xs font-medium text-slate-400 hover:text-slate-200 transition';
updateJsonViewer();
});
btnJsonRaw.addEventListener('click', () => {
currentJsonViewMode = 'raw';
btnJsonRaw.className = 'px-2.5 py-1 rounded text-xs font-medium bg-cyan-950 text-cyan-300 border border-cyan-800 transition';
btnJsonStructured.className = 'px-2.5 py-1 rounded text-xs font-medium text-slate-400 hover:text-slate-200 transition';
updateJsonViewer();
});
}
function updateJsonViewer() {
if (!currentRustDoc) {
jsonViewer.textContent = '{}';
return;
}
if (currentJsonViewMode === 'raw' && currentRustDoc.raw) {
jsonViewer.textContent = JSON.stringify(currentRustDoc.raw, null, 2);
} else {
jsonViewer.textContent = JSON.stringify(currentRustDoc.structured || {}, null, 2);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ---------------------------------------------------------------------------
// Embedded image lightbox (click a gallery thumbnail to inspect it 1:1)
// ---------------------------------------------------------------------------
function openLightbox(im, index) {
if (!im || !im.thumb) return;
const imgW = im.thumb.naturalWidth || im.thumb.width || (im.px ? im.px.width : 300);
const imgH = im.thumb.naturalHeight || im.thumb.height || (im.px ? im.px.height : 200);
lightboxCanvas.width = imgW;
lightboxCanvas.height = imgH;
const lctx = lightboxCanvas.getContext('2d');
lctx.clearRect(0, 0, lightboxCanvas.width, lightboxCanvas.height);
lctx.drawImage(im.thumb, 0, 0, imgW, imgH);
const label = (im.objId !== null && im.objId !== undefined) ? 'XObject ' + im.objId : 'inline image';
lightboxTitle.textContent = '#' + (index + 1) + ' ' + label;
lightboxMeta.textContent =
'bbox pt [' + im.pt.x0 + ', ' + im.pt.y0 + ', ' + im.pt.x1 + ', ' + im.pt.y1 + ']' +
' · painted ' + im.count + 'x' +
(im.cropped ? ' · crop fallback' : '');
lightboxSize.textContent = im.px
? (im.px.width + ' x ' + im.px.height + ' px')
: (imgW + ' x ' + imgH + ' px');
// Enlarge small images so the embedded pixels are actually visible.
const maxW = Math.max(window.innerWidth * 0.85, 320);
const maxH = Math.max(window.innerHeight * 0.72, 240);
const k = Math.min(maxW / imgW, maxH / imgH);
const scale = k >= 1 ? Math.min(k, 8) : 1;
lightboxCanvas.style.width = Math.round(imgW * scale) + 'px';
lightboxCanvas.style.height = Math.round(imgH * scale) + 'px';
lightboxCanvas.style.imageRendering = scale >= 2 ? 'pixelated' : 'auto';
imageLightbox.style.display = 'flex';
}
function closeLightbox() {
imageLightbox.style.display = 'none';
}
lightboxClose.addEventListener('click', closeLightbox);
imageLightbox.addEventListener('click', (e) => {
if (e.target === imageLightbox) closeLightbox();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && imageLightbox.style.display !== 'none') closeLightbox();
});
// Navigation Controls
btnPrevPage.addEventListener('click', () => { if (currentPageNum > 1) { currentPageNum--; renderCurrentPage(); } });
btnNextPage.addEventListener('click', () => { if (currentPdfDoc && currentPageNum < currentPdfDoc.numPages) { currentPageNum++; renderCurrentPage(); } });
pageSlider.addEventListener('input', (e) => { currentPageNum = parseInt(e.target.value); renderCurrentPage(); });
chkShowBoxes.addEventListener('change', () => {
drawCanvas(hoveredSpan);
});
chkShowImages.addEventListener('change', () => {
drawCanvas(hoveredSpan);
});
chkShowVectors.addEventListener('change', () => {
drawCanvas(hoveredSpan);
});
btnCopyText.addEventListener('click', () => {
navigator.clipboard.writeText(pureTextBox.value);
btnCopyText.textContent = '✅ Copied!';
setTimeout(() => btnCopyText.textContent = '📋 Copy to Clipboard', 2000);
});
btnDownloadJson.addEventListener('click', async () => {
const original = btnDownloadJson.textContent;
btnDownloadJson.textContent = '⏳ Preparing…';
btnDownloadJson.disabled = true;
// 100% Rust WASM doc already parsed
btnDownloadJson.textContent = original;
btnDownloadJson.disabled = false;
const blob = new Blob([jsonViewer.textContent], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'rasgapdf_document.json';
a.click();
URL.revokeObjectURL(url);
});
if (btnCopyJson) {
btnCopyJson.addEventListener('click', async () => {
btnCopyJson.textContent = '⏳ Preparing…';
btnCopyJson.disabled = true;
// 100% Rust WASM doc already parsed
btnCopyJson.disabled = false;
const text = jsonViewer.textContent || '';
try {
await navigator.clipboard.writeText(text);
} catch (e) {
// Clipboard API needs a secure context (https/localhost); fall back to a
// hidden textarea so the button still works on plain http.
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (err) { /* ignore */ }
document.body.removeChild(ta);
}
btnCopyJson.textContent = '✅ Copied!';
setTimeout(() => btnCopyJson.textContent = '📋 Copy JSON', 2000);
});
}
// Auto-load sample PDF on startup after WASM is ready
window.addEventListener('DOMContentLoaded', async () => {
if (typeof wasmEnginePromise !== 'undefined') {
await wasmEnginePromise;
}
btnLoadSample.click();
});
</script>
</body>
</html>