Engineering Journal
Pdf Processor
Pdf Processor

Building an Interactive Region Editor on a PDF Analysis Canvas

2026-06-01

TLDR

The PDF Analyze tab canvas gained a full interactive region editor: click to select with 8 resize handles, drag to translate, double-click to spawn a custom region, keyboard shortcuts to reclassify, and a separate column split tool that overrides automatic column detection entirely.

Repo: tools/pdf-processor

The Problem

The Analyze tab showed the geometry canvas and overlaid region bounding boxes colored by type. Useful for inspection. Not useful when the pipeline got a region wrong. The only correction path was to adjust slider thresholds and re-extract the whole page, hoping the change moved in the right direction. There was no way to say "this region is a table, not a paragraph" and have the extraction respect that.


The Correct Model

Two modes, mutually exclusive via a toolbar toggle:

Select mode: interact with existing regions. Click to select, drag handles to resize, drag center to translate, keyboard to reclassify or delete.

Col split mode: place vertical split lines that override the automatic column detector entirely.

Both modes operate in canvas pixel space but store coordinates in worker viewport space (scale 2.0), so they survive re-extraction.


Select Mode: 8 Handles

Each selected region renders a dashed selection outline and 8 handle circles: four corners and four edge midpoints. On hover, the cursor changes to match the resize direction:

const cursors = {
  nw: 'nw-resize', n: 'n-resize', ne: 'ne-resize',
  w:  'w-resize',                  e: 'e-resize',
  sw: 'sw-resize', s: 's-resize', se: 'se-resize',
  center: 'move'
};

Dragging a handle resizes the bounding box. The delta in canvas pixels converts back to worker coordinates:

function canvasToWorker(canvasPx, canvasWidth, workerWidth) {
  return (canvasPx / canvasWidth) * workerWidth;
}
// workerWidth = pg.widthPx * (2.0 / 1.5) -- worker runs at scale 2.0

A 4px minimum size constraint prevents collapsing a region to zero. Dragging the center of the selection translates the whole box.


Keyboard Reclassification

Pressing a key while a region is selected overrides its type and marks it as algorithm: 'custom-override':

| Key | Region type | |-----|-------------| | L | LATTICE_TABLE | | S or T | STREAM_TABLE | | P | PARAGRAPH | | H | HEADING | | B | BOX | | I | IMAGE | | D | DIVIDER | | F | FOOTER | | R | HEADER | | Delete / Backspace | marks skip: true |

Delete does not erase the region from the canvas immediately. It marks skip: true, which tells the next re-extraction to exclude this region from the pipeline. On re-extraction the override is cleaned up and the page renders the updated output.

Double-clicking empty canvas space spawns a new BOX region centered on the cursor, auto-selected. Double-clicking an existing region focuses it without spawning.


Column Split Tool

The automatic column detector uses a bipartite scan to find the X position where the fewest text items cross. This works well on standard two-column academic layouts. It fails on documents with unusual column proportions, narrow gutters, or explicit visual dividers.

The column split tool lets the user draw vertical lines directly on the canvas. Each line is stored as a worker-scale X coordinate in _manualSplits. When the user triggers re-extraction with splits active, an early-return path in contextClassifier builds column boundaries directly from the user-specified X coordinates, bypassing all automatic detection:

if (manualSplits && manualSplits.length > 0) {
  // filter to 5-95% of viewport width, build rawSplits directly
  const filtered = manualSplits
    .filter(s => s.x > vpWidth  0.05 && s.x < vpWidth  0.95)
    .sort((a, b) => a.x - b.x);
  return buildSplitsFromManual(filtered, vpWidth);
  // returns early -- no bipartite scan, no fallback crossing scan
}

The split lines are dashed cyan on the canvas. A pill handle at the top allows drag repositioning. Double-click removes a line. All lines reset when the document is closed.


Tradeoffs

Custom-override regions survive only in memory during the current session. They are not persisted. Re-opening the document starts with automatic detection. This is intentional for the current scope: the editor is a correction tool, not a persistent annotation layer.

The skip: true delete pattern means the area is re-classified by the pipeline on the next extraction. Sometimes the pipeline will re-claim that area differently. If the user wanted the region gone entirely, they should use the region type key to reclassify it to something benign (BOX), not delete it.


What to Watch For

The coordinate conversion from canvas pixels to worker space depends on the canvas rendering width. If the canvas is resized (responsive layout changes) between when a selection is made and when re-extraction fires, the stored worker coordinates remain correct because they were already converted. The canvas dimensions at interaction time are the only ones that matter.

Read this post in the full Engineering Journal →