<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[The Engineered Toolkits]]></title><description><![CDATA[Engineering toolkit distilling the structure of technical work into three focused layers: document processing, table manipulation, and schema editing. Free engi]]></description><link>https://ginexys.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/69d89739fa7251682e618cf1/fd4f63c8-44ee-4386-9247-065b9d7ff011.png</url><title>The Engineered Toolkits</title><link>https://ginexys.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 11 Sep 2026 17:34:19 GMT</lastBuildDate><atom:link href="https://ginexys.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[The Empty Quadrant: Mapping the Design Space of Frontend PDF Extraction]]></title><description><![CDATA[A user asked me a sharp question yesterday:

Looking at your extraction pipeline, pdfjs + geometryWorker + lattice + visualGridMapper, what makes this any different from any other extraction approach ]]></description><link>https://ginexys.hashnode.dev/the-empty-quadrant-mapping-the-design-space-of-frontend-pdf-extraction</link><guid isPermaLink="true">https://ginexys.hashnode.dev/the-empty-quadrant-mapping-the-design-space-of-frontend-pdf-extraction</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[pdf]]></category><category><![CDATA[architecture]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[PDF Conversion]]></category><dc:creator><![CDATA[The Engineered Notes]]></dc:creator><pubDate>Fri, 15 May 2026 14:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69d89739fa7251682e618cf1/a92a5477-49d5-49be-b78c-6bb725fc4d78.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A user asked me a sharp question yesterday:</p>
<blockquote>
<p>Looking at your extraction pipeline, pdfjs + geometryWorker + lattice + visualGridMapper, what makes this any different from any other extraction approach for frontend only, no backend or compiled engine?</p>
</blockquote>
<p>It's the right question to ask any author of a tool. So I sat down and surveyed the space honestly. What I found was more interesting than my gut answer.</p>
<p>The pipeline isn't different because of clever algorithms. The lattice reconstruction is the same lattice reconstruction every server-side tool uses. The KD-tree proximity is a textbook nearest-neighbor query. Y-band paragraph clustering is in a 1996 paper. <strong>The math is borrowed.</strong></p>
<p>What's different is the <em>quadrant of the design space</em> the pipeline occupies, and the architectural commitments it took to land there.</p>
<p>This post maps that design space. It catalogs what's already in each cell, identifies the empty one, and explains why it stayed empty long enough for a niche to form.</p>
<hr />
<h2>1. The 2×2 grid</h2>
<p>Two axes describe almost every PDF extraction project I've encountered:</p>
<ul>
<li><p><strong>Approach axis</strong>: deterministic vs. ML-based.</p>
</li>
<li><p><strong>Output axis</strong>: visual fidelity vs. semantic structure.</p>
</li>
</ul>
<p>Plot them and you get four cells.</p>
<pre><code class="language-plaintext">                 DETERMINISTIC                 ML-BASED
                ───────────────                ────────
  SEMANTIC      pdfplumber, Tabula,            Adobe Extract API,
  STRUCTURE     Camelot, PyMuPDF               Textract, Azure DI,
  (backend)                                     transformers.js + layout
                                                models (frontend)

  VISUAL        pdf2htmlEX                     —
  FIDELITY      (frontend WASM)
  (frontend)

  TEXT-ONLY     pdfreader, pdf-extract,        tesseract.js
  STREAM        the naive getTextContent()     (OCR over rendered canvas)
                recipe
</code></pre>
<p>Three observations fall out of this map immediately.</p>
<p><strong>Backend dominates the deterministic-structural cell.</strong> Everything serious about extracting structure from PDFs without ML lives on a server. pdfplumber, Tabula, Camelot, PyMuPDF — all Python, all backend, all decades of accumulated implementation knowledge.</p>
<p><strong>Frontend is well-represented but compromised.</strong> Each frontend project gives up something significant. <code>pdf2htmlEX</code> reproduces visual appearance perfectly but ships zero semantic structure. <code>tesseract.js</code> works on scanned PDFs but throws away the native text layer that digital PDFs hand you for free. The transformers.js + layout-model approach handles weird documents but ships multi-megabyte model weights and opaque failure modes. The naive <code>getTextContent()</code> recipe and its Y-clustering descendants give you a flat blob and don't read the operator list at all.</p>
<p><strong>There's a frontend cell that's empty.</strong> Deterministic. Structural. No ML weights. No raster step. No backend.</p>
<p>That empty cell is where this pipeline sits.</p>
<hr />
<h2>2. The naive 95 percent</h2>
<p>Before we look at what fills the four occupied cells, it's worth establishing the baseline. Roughly 95 percent of frontend PDF extraction code in the wild does this:</p>
<pre><code class="language-js">const pdf = await pdfjsLib.getDocument(bytes).promise;
let text = '';
for (let i = 1; i &lt;= pdf.numPages; i++) {
  const page = await pdf.getPage(i);
  const content = await page.getTextContent();
  text += content.items.map(it =&gt; it.str).join(' ') + '\n';
}
</code></pre>
<p>This works on a memo. It collapses on a two-column research paper. It liquefies on a table. It can't tell a heading from a paragraph. It has no concept of reading order on a complex page.</p>
<p>Everything beyond this baseline is a project trying harder. There are four serious such projects. None of them sits in the deterministic-structural-frontend cell.</p>
<hr />
<h2>3. The four occupied frontend cells</h2>
<h3>Cell A: <code>pdf2htmlEX</code> — visual fidelity, no semantics</h3>
<p><code>pdf2htmlEX</code> is a WASM port of an old C++ project. It walks the PDF and emits absolutely-positioned <code>&lt;div&gt;</code>s that visually reproduce the source.</p>
<pre><code class="language-html">&lt;div style="position:absolute; top:124px; left:88px; font-size:11pt"&gt;A table cell&lt;/div&gt;
&lt;div style="position:absolute; top:124px; left:240px; font-size:11pt"&gt;Another&lt;/div&gt;
&lt;div style="position:absolute; top:124px; left:392px; font-size:11pt"&gt;Cell&lt;/div&gt;
</code></pre>
<p>If you want to render the PDF in a browser and let the user select text, this is unbeatable. If you want any semantic structure (a <code>&lt;table&gt;</code>, an <code>&lt;h1&gt;</code>, a paragraph block), you're back to scraping divs by their bounding boxes — the same problem the user started with.</p>
<h3>Cell B: <code>tesseract.js</code> — OCR</h3>
<p>Render each page to canvas. Run OCR on the canvas. Get text + bounding boxes back.</p>
<p>This is the right answer for <strong>scanned</strong> PDFs that have no native text layer. It's the wrong answer for digital PDFs that already have perfect text. You're feeding selectable text through an image-to-text model and getting a degraded copy of what was already there. Plus a 2MB WASM payload, plus seconds-per-page latency.</p>
<h3>Cell C: <code>transformers.js</code> + layout models — ML-based structural</h3>
<p>Load a layout-aware model (DocLayout-YOLO, LayoutLM, or similar) into the browser via ONNX or transformers.js. Render each page to canvas. Run inference. Get back labeled regions: <code>TABLE</code>, <code>TEXT</code>, <code>FIGURE</code>.</p>
<p>This is where the modern industry is heading. It works on weird, varied document types. It generalizes. But:</p>
<ul>
<li><p>The model weights are megabytes (DocLayout-YOLO Nano alone is ~6MB ONNX).</p>
</li>
<li><p>First inference takes seconds.</p>
</li>
<li><p>Failure modes are opaque — when the model misclassifies, you have no levers.</p>
</li>
<li><p>You're shipping an ML inference engine to do something that, for digital PDFs, can be done with pure geometry.</p>
</li>
</ul>
<h3>Cell D: <code>pdfreader</code>, <code>pdf-extract</code>, and friends — text-only Y-clustering</h3>
<p>These libraries take <code>getTextContent()</code> items, cluster them by Y position, sort by X, and produce slightly more structured output than the flat-blob recipe.</p>
<p>The fundamental limit: they only consume the text content. They never call <code>getOperatorList()</code>. They cannot see vector lines. They cannot detect a table border, distinguish an underline from a horizontal rule, or recognize a chart axis. Their world is text and only text.</p>
<p>For prose-heavy documents, that's fine. For anything with tables, they degrade to row-smashing.</p>
<hr />
<h2>4. The empty cell, and what fills it</h2>
<p>The deterministic-structural-frontend cell asks for a tool that:</p>
<ol>
<li><p>Runs entirely in the browser. No server.</p>
</li>
<li><p>Ships no ML model weights. Determinism via geometry.</p>
</li>
<li><p>Reads the operator list, not just the text content. Vector-aware.</p>
</li>
<li><p>Outputs semantic structure: tables with topology, headings, paragraphs, lists, reading order.</p>
</li>
</ol>
<p>To fill it, this pipeline does the following:</p>
<h3>4.1 CTM-baked vector segments</h3>
<pre><code class="language-js">// ctmAdapter.js (simplified)
for (let i = 0; i &lt; fnArray.length; i++) {
  if (fnArray[i] === OPS.save)    ctmStack.push(ctm.slice());
  if (fnArray[i] === OPS.restore) ctm = ctmStack.pop();
  if (fnArray[i] === OPS.transform) ctm = mulMatrix(ctm, argsArray[i]);
  if (fnArray[i] === OPS.constructPath) {
    // Walk subpaths, transform each point through CTM × viewport.transform,
    // emit normalized H/V segment records.
  }
}
</code></pre>
<p>This is the move that puts the pipeline in a different category from cells B and D. We don't just consume text. We consume the operator list and reconstruct the page's vector skeleton in viewport coordinates. We can <em>see</em> the table borders before any text math runs.</p>
<h3>4.2 Region-typed classification <em>before</em> extraction</h3>
<p>Most pipelines run sequential passes: find tables, find paragraphs, find lists. Each pass works against the full text pool. Then you deduplicate at the end and hope the passes didn't disagree.</p>
<p>This pipeline does the opposite. Classify regions first, then route scoped text into each region's specialist extractor. The mechanism is a single <code>assignedTextIndices</code> set:</p>
<pre><code class="language-js">for (const lattice of lattices) {
  const tableTextIndices = [];
  for (const tm of textMeta) {
    if (assignedTextIndices.has(tm.idx)) continue; // skip consumed
    if (insideBBox(tm.vx, tm.vy, lattice.bbox, tablePad)) {
      tableTextIndices.push(tm.idx);
      assignedTextIndices.add(tm.idx); // mark as consumed
    }
  }
  regions.push({ type: TABLE, lattice, textItemIndices: tableTextIndices });
}
// later: paragraph/heading/list passes only see un-consumed text
</code></pre>
<p>The invariant is: <strong>a text item belongs to exactly one region.</strong> No leakage by construction. The bug class of "table text accidentally in a paragraph" is preempted, not patched.</p>
<h3>4.3 Underline-vs-border discrimination</h3>
<p>A naive lattice reconstructor sees every horizontal line and tries to use it as a table border. This produces phantom 1×1 tables under every underlined heading.</p>
<p>We classify each H-segment against the text baselines using KD-tree-style proximity:</p>
<pre><code class="language-js">for (const h of hSegs) {
  const hY = (h.y1 + h.y2) / 2;
  for (const tm of textMeta) {
    const yDist = hY - tm.vy;
    if (yDist &gt;= -1 &amp;&amp; yDist &lt;= 5 &amp;&amp;
        tm.vx &lt;= hXMax + 2 &amp;&amp; (tm.vx + tm.vWidth) &gt;= hXMin - 2 &amp;&amp;
        hLen &lt; tm.vWidth * 2.5) {
      underlineSegIds.add(h.id);
      break;
    }
  }
}
</code></pre>
<p>If a horizontal line sits 0–5px below a text baseline with overlapping X-span, it's an underline. Tag it. Remove from the table-detection pool. ~99% of phantom tables disappear.</p>
<p>I have not seen another browser-side PDF extractor that does this. Tabula has equivalents on the backend. On the frontend, every other tool I've audited just hands all H-lines to the lattice and lives with phantom tables.</p>
<h3>4.4 Topological cell-merge inference</h3>
<p>Naive table extractors detect cell merges by visual whitespace heuristics ("if these two cells have no visible boundary between their text, they're merged"). This is unreliable. Tables with thin internal borders look unmerged but are; tables with wide cell padding look merged but aren't.</p>
<p>This pipeline asks the geometry directly:</p>
<pre><code class="language-js">function vLinePresent(vLines, x, yA, yB, eps) {
  return vLines.some(l =&gt;
    Math.abs(l.x - x) &lt;= eps &amp;&amp;
    l.yMin &lt;= yA + eps &amp;&amp;
    l.yMax &gt;= yB - eps
  );
}
</code></pre>
<p>Is there an actual merged vertical-line record at this X position spanning [yA, yB]? If yes, the cell boundary exists; the cells are separate. If no, extend the colspan. Topological, not visual.</p>
<h3>4.5 Nearest-cell Euclidean snap</h3>
<p>Strict point-in-box assignment drops text whose origin is 0.1px outside a cell, which is common because PDF rendering coordinates have jitter. We use Euclidean distance to the nearest cell center with a 15px snap threshold:</p>
<pre><code class="language-js">let bestR = -1, bestC = -1, minDist = Infinity;
for (let ri = 0; ri &lt; numRows; ri++) {
  for (let ci = 0; ci &lt; numCols; ci++) {
    const dx = Math.max(cols[ci] - sx, 0, sx - cols[ci+1]);
    const dy = Math.max(rows[ri] - sy, 0, sy - rows[ri+1]);
    const dist = Math.sqrt(dx*dx + dy*dy);
    if (dist &lt; minDist) { minDist = dist; bestR = ri; bestC = ci; }
  }
}
if (minDist &lt; 15) cells[bestR][bestC].push(...);
</code></pre>
<p>Magnetic, not literal. Coordinate jitter doesn't drop data.</p>
<h3>4.6 Worker-isolated full pipeline</h3>
<p>Most browser PDF extractors run on the main thread. The geometry pipeline here loads PDF.js as a <em>nested worker</em> inside the geometry worker. CTM baking, lattice reconstruction, classification, assembly — all off the main thread. The UI stays responsive on a 200-page document.</p>
<h3>4.7 Per-page streaming</h3>
<p>Naive extractors accumulate the whole document into one structured-clone payload at the end. That dies on large PDFs with stack-overflow errors in <code>postMessage</code>. We emit per-page <code>'page'</code> messages from the worker, the main thread accumulates incrementally, and the UI can show progressive results.</p>
<pre><code class="language-js">self.postMessage({
  type: 'page',
  page: p,
  html: result.html,
  text: result.text.trim(),
  tables: result.tableCount,
});
</code></pre>
<p>Not algorithmic novelty. Engineering discipline that lets the architecture survive 76-page technical manuals.</p>
<h3>4.8 VisualGridMapper as a downstream operator</h3>
<p>The output isn't a dead <code>&lt;table&gt;</code> string. It's a live HTML table that we can immediately remap into a Cartesian array using <code>VisualGridMapper</code>:</p>
<pre><code class="language-js">const mapper = new VisualGridMapper(table);
// mapper.grid[row][col] now holds origin/spanned cell metadata.
// Transposes, merges, splits all become matrix operations.
</code></pre>
<p>This is the bridge into the table-formatter half of the platform. Other extractors stop at "here's a <code>&lt;table&gt;</code>." We hand the user something they can keep manipulating mathematically.</p>
<hr />
<h2>5. What's borrowed and what's new</h2>
<p>Worth being honest about which pieces of this are original engineering versus academic standard:</p>
<p><strong>Borrowed:</strong></p>
<ul>
<li><p>The lattice algorithm itself — intersection clustering, row/column projection. Same as Tabula, Camelot, pdfplumber.</p>
</li>
<li><p>Y-band paragraph clustering — pdfminer-style, in academic literature since the 90s.</p>
</li>
<li><p>XY-cut column detection — known since the 80s.</p>
</li>
<li><p>KD-tree spatial indexing — textbook.</p>
</li>
<li><p>DOMPurify, jQuery, Monaco — off-the-shelf.</p>
</li>
</ul>
<p><strong>Original to this pipeline (or unusual in the niche):</strong></p>
<ul>
<li><p>The full assembly running in a Web Worker on top of PDF.js as a nested worker.</p>
</li>
<li><p>The non-overlapping-region invariant via <code>assignedTextIndices</code>.</p>
</li>
<li><p>The underline-discrimination heuristic with the specific 0–5px / 2.5×-width thresholds.</p>
</li>
<li><p>The coordinate-space discipline: storing both <code>vWidth/vFont</code> (viewport) and <code>width/fontSize</code> (PDF points) on every text-meta record, with explicit comments about which to use where.</p>
</li>
<li><p>The per-page streaming pattern that survives 100+ page documents.</p>
</li>
<li><p>The integration with <code>VisualGridMapper</code> for downstream mathematical manipulation.</p>
</li>
</ul>
<p>The pipeline is a composition. The composition is the contribution.</p>
<hr />
<h2>6. Why this cell stayed empty</h2>
<p>If the deterministic-structural-frontend cell is valuable, why hadn't anyone filled it?</p>
<p>Three reasons, in order of how convincing each one is.</p>
<p><strong>Economics push toward backend.</strong> If you have a use case that needs structural PDF extraction, you almost certainly have a server. The serious tools live in Python and have for a decade. There's no incentive to port them unless you specifically need data to stay on the client device which is a real but niche requirement.</p>
<p><strong>Existing frontend tools are anchored to other quadrants.</strong> <code>pdf2htmlEX</code> is committed to visual fidelity. <code>tesseract.js</code> is committed to OCR. The transformers.js camp is committed to ML generalization. Each is well-architected for its quadrant and would require an architectural rewrite to drift into the deterministic-structural cell. Nobody had a reason to do that work.</p>
<p><strong>The pieces are scattered.</strong> PDF.js gives you the operator list but assumes you'll use it for rendering. Lattice algorithms are described in papers, not packaged as npm modules. KD-tree libraries assume preformatted data. Web Worker isolation has its own ergonomic learning curve. Climbing the staircase to assemble all of these is real engineering work, and unless you have a strong reason to be in this exact cell, the cost-benefit doesn't pencil.</p>
<p>We had a reason. The platform we're building is browser-native by <em>commitment</em>, not accident. Every other tool in our pipeline runs in the browser. Sending PDFs to a server for structural extraction would have broken the architectural model. So we climbed.</p>
<hr />
<h2>7. The lesson above the niche</h2>
<p>There's a generalization worth saying out loud, because it applies far beyond PDF tooling.</p>
<p>When you wonder whether you're reinventing a wheel, do the survey. But ask the right question. The question is not <em>"has anyone solved this problem?"</em> — the answer to that is almost always yes, somewhere. The question is:</p>
<blockquote>
<p>What set of constraints does my version satisfy that nobody else's version satisfies?</p>
</blockquote>
<p>Constraints are commitments. <em>No backend. No model weights. Worker isolation. Deterministic output. Per-page streaming. Open source.</em> Each one is a deliberate refusal of a path other people took.</p>
<p>The intersection of constraints is where new niches live. The math you use <em>inside</em> that intersection is often the same math everyone else uses. That's fine. The originality isn't in the math. It's in the negative space — the things you said no to.</p>
<p>The pipeline isn't different because the algorithms are different. It's different because of where it runs and what it refuses to be.</p>
<hr />
<p>The full pipeline is open source as part of the <a href="https://github.com/carnworkstudios"><code>GINEXYS</code></a> project. If you find a fifth camp I missed, or if you've built something that fills the empty quadrant differently, the issue tracker is open. I'm specifically curious whether anyone else has implemented in-browser CTM baking against pdfjs-dist's operator list — that piece felt the loneliest in my survey.</p>
]]></content:encoded></item><item><title><![CDATA[How to Stop PDF Parsers from Hallucinating Tables out of Thin Air]]></title><description><![CDATA[PDF extraction is usually blind.
If you've ever tried to write a script to scrape a PDF, you know exactly what I mean. You run the PDF through a generic text extractor, and instead of a clean table, y]]></description><link>https://ginexys.hashnode.dev/how-to-stop-pdf-parsers-from-hallucinating-tables-out-of-thin-air</link><guid isPermaLink="true">https://ginexys.hashnode.dev/how-to-stop-pdf-parsers-from-hallucinating-tables-out-of-thin-air</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[pdf]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[tables]]></category><category><![CDATA[algorithms]]></category><dc:creator><![CDATA[The Engineered Notes]]></dc:creator><pubDate>Wed, 13 May 2026 12:19:07 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69d89739fa7251682e618cf1/104bf70e-4968-48d9-845c-3454a2161c25.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>PDF extraction is usually blind.</p>
<p>If you've ever tried to write a script to scrape a PDF, you know exactly what I mean. You run the PDF through a generic text extractor, and instead of a clean table, you get a jammed wall of text where the columns are violently shoved into a single vertical stack.</p>
<p>Or worse, you try to use a table extractor, and it hallucinates tables everywhere. See a bold heading with an underline? The parser thinks that's a 1x1 table. See a horizontal divider between paragraphs? Boom, phantom table.</p>
<p>Why does this happen? Because most PDF parsers process the document in a strict, sequential pipeline. They look at all the lines. They look at all the text. And they just smash them together.</p>
<p>I got tired of this. So I re-engineered the extraction pipeline in our PDF processor to stop reading the document like a machine, and start <em>seeing</em> it like a human.</p>
<p>Here is the math behind Context-Aware PDF Extraction.</p>
<hr />
<h2>1. The Blind Extraction Problem</h2>
<p>Previously, our extraction pipeline worked like this:</p>
<ol>
<li><p>Find all horizontal and vertical line segments (<code>H-segs</code> and <code>V-segs</code>).</p>
</li>
<li><p>Run them through a <code>LatticeReconstructor</code> to find intersecting grids.</p>
</li>
<li><p>Treat every grid as a table.</p>
</li>
<li><p>Dump all the text in the document into those grids using a strict "is this point inside this box" check.</p>
</li>
</ol>
<p>This was a disaster for documents that mixed paragraphs with tables.</p>
<p>If a paragraph had a decorative underline, the <code>LatticeReconstructor</code> would see the H-line, panic, and try to build a table out of it. If text was slightly offset inside a table cell due to coordinate jitter, the "point-in-box" check would fail, and the text would just vanish from the output.</p>
<p>I needed the parser to understand <em>context</em>.</p>
<hr />
<h2>2. Enter the Context Classifier</h2>
<p>To fix this, I built the <code>contextClassifier</code>.</p>
<p>Instead of treating the PDF as a bucket of shapes and text, the <code>contextClassifier</code> walks the document and groups every single item into spatially bounded, typed regions: <code>TABLE</code>, <code>PARAGRAPH</code>, <code>HEADING</code>, <code>LIST</code>, and <code>IMAGE</code>.</p>
<p>But how do you tell a machine the difference between a table border and a decorative underline?</p>
<p>You use proximity math.</p>
<pre><code class="language-javascript">// KD-tree style proximity: check if text sits exactly on top of an H-line
for (const h of hSegs) {
    const hY = (h.y1 + h.y2) / 2;
    for (const tm of textMeta) {
        const yDist = hY - tm.vy; 
        
        // Underline: line is 0–5px below the text baseline
        if (yDist &gt;= -1 &amp;&amp; yDist &lt;= 5 &amp;&amp; overlappingXSpan(tm, h)) {
            underlineSegIds.add(h.id);
            break; 
        }
    }
}
</code></pre>
<p>If a horizontal line is exactly 0 to 5 pixels below a text baseline, and its width roughly matches the text width, it's not a table border. It's an underline.</p>
<p>By tagging and removing these underlines <em>before</em> we run the table reconstruction, we eliminate 99% of phantom tables.</p>
<hr />
<h2>3. Scoping the Text (No More Collisions)</h2>
<p>Once the tables are detected, we calculate the exact bounding box of the table grid.</p>
<p>Instead of throwing all the document's text at the table builder, the classifier scoops up <em>only</em> the text items that physically live inside that bounding box.</p>
<pre><code class="language-javascript">const tableTextIndices = [];
for (const tm of textMeta) {
    if (insideBBox(tm.vx, tm.vy, bbox)) {
        tableTextIndices.push(tm.idx);
        assignedTextIndices.add(tm.idx); // Mark as consumed!
    }
}
</code></pre>
<p>This does two things:</p>
<ol>
<li><p>It guarantees that table text doesn't accidentally leak into paragraphs.</p>
</li>
<li><p>It guarantees that paragraph text doesn't get sucked into table cells.</p>
</li>
</ol>
<p>Once a text item is claimed by a region, it's marked as consumed.</p>
<hr />
<h2>4. The Nearest-Cell Proximity Assignment</h2>
<p>Even with scoped text, getting the text into the correct table cell was still failing due to PDF rendering quirks. A cell might be at <code>x: 10.5</code>, but the text was at <code>x: 10.4</code>. A strict bounding box check would drop the text.</p>
<p>I ripped out the strict containment checks and replaced them with a nearest-neighbor proximity model.</p>
<p>For every piece of text, we find its nearest cell center using Euclidean distance. If it's within a 15px threshold, it snaps into place. No more jitter. No more dropped data.</p>
<hr />
<h2>5. The Page Assembler</h2>
<p>Finally, the <code>pageAssembler</code> takes over.</p>
<p>It receives an array of perfectly classified, non-overlapping regions. It sorts them top-to-bottom based on their Y-coordinates.</p>
<p>Then, it just iterates through them and calls the right extractor:</p>
<ul>
<li><p>If it's a <code>TABLE</code>, it sends the scoped text to the <code>tableBuilder</code>.</p>
</li>
<li><p>If it's a <code>HEADING</code>, it wraps it in an <code>&lt;h3&gt;</code> or <code>&lt;h4&gt;</code>.</p>
</li>
<li><p>If it's a <code>LIST</code>, it strips the bullet points and outputs clean <code>&lt;ul&gt;&lt;li&gt;</code> tags.</p>
</li>
<li><p>If it's a <code>PARAGRAPH</code>, it sends it to the <code>textRebuilder</code>.</p>
</li>
</ul>
<p>The result? True document reading order.</p>
<p>You upload a messy, complex PDF filled with tables, paragraphs, and lists. The pipeline classifies it, scopes the data, and spits out clean, semantically correct HTML.</p>
<p>No backend processing. No AI hallucination. Just pure, deterministic math running directly in your browser using <code>pdfjs-dist</code> and vanilla JS.</p>
<p>The PDF is finally readable.</p>
<p>Check out the repo <a href="https://github.com/carnworkstudios/doc-extractor">here</a> or give it a try in <a href="https://ginexys.com/tools/pdf-processor">Ginexys</a>. Let us know how it did</p>
]]></content:encoded></item><item><title><![CDATA[Cleaning Broken HTML Tables from PDFs, Scrapes, and Legacy Exports in Vanilla JS]]></title><description><![CDATA[HTML tables are liars.
If you haven't worked deeply with HTML tables, you might think a table is just a simple 2D array: table[row][col].
The moment an HTML table introduces a colspan or a rowspan, th]]></description><link>https://ginexys.hashnode.dev/cleaning-broken-html-tables-from-pdfs-scrapes-and-legacy-exports-in-vanilla-js</link><guid isPermaLink="true">https://ginexys.hashnode.dev/cleaning-broken-html-tables-from-pdfs-scrapes-and-legacy-exports-in-vanilla-js</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[csv-to-html]]></category><category><![CDATA[csv-to-sql]]></category><category><![CDATA[webdev]]></category><category><![CDATA[HTML5]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[The Engineered Notes]]></dc:creator><pubDate>Fri, 10 Apr 2026 14:42:10 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69d89739fa7251682e618cf1/448ed065-2c73-46d2-bbf4-749f4b2a7113.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>HTML tables are liars.</p>
<p>If you haven't worked deeply with HTML tables, you might think a table is just a simple 2D array: <code>table[row][col]</code>.</p>
<p>The moment an HTML table introduces a <code>colspan</code> or a <code>rowspan</code>, the visual <code>(x, y)</code> coordinate of a cell completely detaches from its DOM hierarchy. If row 1 has a cell with <code>colspan="3"</code>, then the second <code>&lt;td&gt;</code> in that row is visually in column 4, but programmatically it is <code>childNodes[1]</code>.</p>
<p>If you try to write a "select column" function by just iterating through <code>tr &gt; td:nth-child(n)</code>, your highlighting will look like abstract art the second it hits a merged cell.</p>
<p>I learned that the hard way.</p>
<p>If you work with scraped tables, PDF exports, legacy system data, or just need to clean up HTML tables before dropping them into a docs platform, this is for you.</p>
<p>What started as a small utility for cleaning up scraped tables eventually became <strong>TAFNE - Table Formatter and Node Editor</strong>, a browser-based table IDE for reshaping broken tabular data and exporting it into useful formats. The hardest part wasn’t rendering the table. It was teaching the browser how to understand the table the way a human does.</p>
<h3>What Didn't Work</h3>
<p>My first attempt was just checking <code>.prev()</code> and <code>.next()</code> and trying to keep a running tally of offset index values.</p>
<hr />
<h2>1. The Problem Space</h2>
<p>Try to write a function that highlights an entire column when you hover over a table header.</p>
<p>If the table represents a perfectly flat 2D array, it’s trivial: loop through every <code>&lt;tr&gt;</code> and add a CSS class to <code>childNodes[colIndex]</code>.</p>
<p>But what if you are given this table?</p>
<pre><code class="language-html">&lt;table id="messy-table"&gt;
  &lt;tr&gt;
    &lt;td rowspan="2"&gt;A&lt;/td&gt;
    &lt;td colspan="2"&gt;B&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;C&lt;/td&gt;
    &lt;td&gt;D&lt;/td&gt;
  &lt;/tr&gt;
&lt;/table&gt;
</code></pre>
<p>Visually, this is a 2x3 grid.</p>
<ul>
<li><p>Row 1, Col 1 is <code>A</code></p>
</li>
<li><p>Row 2, Col 1 is <em>also</em> <code>A</code> (because of <code>rowspan</code>)</p>
</li>
<li><p>Row 2, Col 2 is <code>C</code></p>
</li>
<li><p>Row 2, Col 3 is <code>D</code></p>
</li>
</ul>
<p>But programmatically? <code>C</code> is <code>tr[1].childNodes[0]</code>. It thinks it's in the first column, but visually it sits in the second.</p>
<p>My initial approach of checking <code>.prev()</code> and <code>.next()</code> and keeping a running tally of offset index values was naive. This completely breaks when a cell has both <code>colspan</code> and <code>rowspan</code> acting simultaneously, or when consecutive cells in a row have varying spans. The edge cases are endless.</p>
<p>I needed a topographic map of the DOM, not just a DOM tree.</p>
<hr />
<h2>2. The Solution: The <code>VisualGridMapper</code></h2>
<p>To perform complex UI actions like drag-and-drop or matrix transposition on a table, you need to translate the DOM into a strict, predictable Cartesian plane.</p>
<p>I built a class called the <code>VisualGridMapper</code>. Its sole job is to walk the table once and build a dense 2D array (<code>grid[row][col]</code>) that maps absolute visual coordinates back to their origin node.</p>
<p>Here is a simplified look at the mapping logic:</p>
<pre><code class="language-javascript">class VisualGridMapper {
    constructor($table) {
        this.grid = []; // 2D array: grid[row][col]
        this.cellMap = new Map(); // DOM Element -&gt; visual properties
        this.mapTable($table);
    }

    mapTable($table) {
        let currentRow = 0;

        $table.find('tr').each((rIndex, tr) =&gt; {
            if (!this.grid[currentRow]) this.grid[currentRow] = [];
            let currentCol = 0;

            $(tr).find('td, th').each((cIndex, cell) =&gt; {
                const \(cell = \)(cell);
                const rSpan = parseInt($cell.attr('rowspan')) || 1;
                const cSpan = parseInt($cell.attr('colspan')) || 1;

                // EDGE CASE: Skip cells that are already occupied by 
                // a rowspan from a previous row
                while (this.grid[currentRow][currentCol]) {
                    currentCol++;
                }

                // Record the origin node
                const cellData = {
                    element: cell,
                    isOrigin: true, // This is the actual DOM node
                    startRow: currentRow,
                    startCol: currentCol,
                    rowspan: rSpan,
                    colspan: cSpan
                };
                
                this.cellMap.set(cell, cellData);

                // Fill the physical space in our 2D array
                for (let r = 0; r &lt; rSpan; r++) {
                    for (let c = 0; c &lt; cSpan; c++) {
                        if (!this.grid[currentRow + r]) this.grid[currentRow + r] = [];
                        
                        this.grid[currentRow + r][currentCol + c] = {
                            element: cell,
                            isOrigin: (r === 0 &amp;&amp; c === 0)
                        };
                    }
                }
                currentCol += cSpan;
            });
            currentRow++;
        });
    }
}
</code></pre>
<h3>Handing the "Ghost Cell" Edge Case</h3>
<p>The <code>while (this.grid[currentRow][currentCol])</code> loop is the crucial edge case handler. As the parser moves through a <code>&lt;tr&gt;</code>, it checks the map to see if the current visual column is already physically occupied by an element from a row <em>above</em> it stretching down. If it is, the pointer advances silently, bumping the current row's children to the right so they align with their true visual placement.</p>
<h3>The Letdown that Became a Superpower</h3>
<p>Building this mapping layer was tedious. But once it existed, something amazing happened: <strong>complex table mutations fell out for free.</strong></p>
<p>Want to transpose a table? I didn't need to write complex DOM-shuffling logic. I just ran a standard matrix transpose on my <code>VisualGridMapper</code> array (<code>[row][col]</code> becomes <code>[col][row]</code>), swapped the <code>rowspan</code> and <code>colspan</code> values, merging cells, and splitting cells, all table mutations are now matrix problems. No worries about the complexities of sequentially re-rendering the DOM. Linear algebra solved the UI problem.</p>
<hr />
<h2>3 Why This Tool Exists</h2>
<p>TAFNE was built specifically for developers, data analysts, and technical writers. For people who deal with messy tabular data and need a cleaner way to work with it</p>
<p>You input or load a CSV, ASCII, text, or HTML, and TAFNE takes that <code>VisualGridMapper</code> and generates multiple formats directly into an embedded <strong>Monaco Editor</strong>.</p>
<p>It currently supports exports like:</p>
<ul>
<li><p>Markdown, for GitHub READMEs and docs.</p>
</li>
<li><p>JSON, for structured data pipelines or API work.</p>
</li>
<li><p>HTML, for clean table output.</p>
</li>
<li><p>SQL, which became the most useful export for me. Paste in a messy CSV, the tool can infer headers, generate a <code>CREATE TABLE</code> statement, and produce the corresponding <code>INSERT INTO</code> statements with escaped values.</p>
</li>
</ul>
<p>You can go from a mangled PDF scrape to a populated database backend in about 8 seconds, without writing a single line of backend parsing logic.</p>
<p>I'm still working to include more imports and exports such as LaTeX, and Excel. You can support the development of TAFNE by checking out <a href="https://github.com/carnworkstudios/TAFNE">GitHub</a>.</p>
<h2>4. The Architecture Choice</h2>
<p>The entire editor is built with Vanilla JavaScript and jQuery.</p>
<p>That wasn’t a nostalgic decision. It came out of the constraints of the tool itself.</p>
<p>I wanted the simplest possible setup: something you could open locally, run without a build step, and use without sending data to a backend. For a tool that may handle financial tables, internal reports, or scraped documents, local-first matters. The data should stay on the machine.</p>
<p>There was also a more practical reason: the DOM is already the thing I was trying to control.</p>
<p>For this kind of table manipulation, I didn’t want to constantly translate between a virtual state model and the browser’s actual structure. The table itself is the structure. So instead of forcing the problem into a framework-shaped box, I let the browser do what it was already good at, and used the mapper only when I needed to reason about the table mathematically.</p>
<p>That choice came with tradeoffs, of course.</p>
<p>Without framework lifecycles, I had to be much more disciplined about cleanup. Event handlers had to be namespaced carefully. Re-rendering meant I had to think hard about stale listeners. Undo and redo also took more manual work, because I couldn’t lean on immutable state patterns to do the bookkeeping for me.</p>
<p>But the tradeoff felt worth it for this project.</p>
<hr />
<h2>5. What I Learned</h2>
<p>The biggest lesson was that HTML tables are more than markup. If you want to make them editable, mergeable, split-able, or transposable, you need to stop treating them like a flat list and start treating them like a coordinate system.</p>
<p>That change in perspective unlocked the whole engine.</p>
<p>I didn’t begin with a grand plan to build a visual table IDE. I started with a broken problem, tried a few awkward fixes, and eventually found that the cleanest solution was to map the DOM into a visual grid first, then operate on that model instead of fighting the browser directly.</p>
<p>That’s usually how these tools come together: not through one elegant insight, but through a series of small, stubborn corrections until the structure finally makes sense.</p>
<p>The SQL emitter and the VisualGridMapper are both open source on GitHub: <a href="https://github.com/carnworkstudios/TAFNE">carnworkstudios/TAFNE</a>. I'd genuinely like feedback on the type inference logic. If you've solved similar problems differently, tell me in the comments or open an issue on the repo.</p>
]]></content:encoded></item></channel></rss>