Engineering Journal
Pdf Processor
Pdf Processor

Under the Hood: How We Turned Extracted HTML into a CAD Editor

2026-05-21

TLDR: HTML rendered in a browser is a spatial document. CSS Grid zones and DOM flow regions are CAD primitives. No canvas layer needed.


The model

The PDF processor emits structured HTML:

section.pdf-page-content[data-page][data-zones][data-page-width]
  └── div.pdf-zone.pdf-zone--cols-2        ← CSS Grid container
        ├── div.pdf-col.pdf-col--left
        │     └── div.pdf-region[data-rx][data-ry]
        │           └── h3.f0.ta-l
        └── div.pdf-col.pdf-col--right
              └── div.pdf-region[data-rx][data-ry]
                    └── div.pdf-table-wrap > table

Zones are display: grid; grid-template-columns: repeat(N, 1fr). Regions are flow children. The browser already computed every element's box model.

There is no coordinate system to maintain. There is no canvas to draw on. getBoundingClientRect() gives position. insertBefore gives reorder. The browser reflows the grid.


Mode toggle

One button. One CSS class. One contentEditable flip.

function _toggle() {
    _active = !_active;
    _preview = _resolvePreview(); // picks html-preview or visual-diff-html
    _preview.classList.toggle('selection-mode', _active);
    _preview.contentEditable = _active ? 'false' : 'true';
}

CSS scopes all selection-mode visuals to #html-preview.selection-mode and #visual-diff-html.selection-mode. Zones get a 3px solid left border. Regions get a light dashed outline. Nothing about the content changes.


Why handles must be position:absolute

Every drag handle is a <span class="sel-drag-handle"> prepended to the element. If the handle is display: inline-block or flex-shrink: 0, it becomes a grid or flex item. In a two-column zone, the handle occupies column 0 and shifts all content right.

The fix: position: absolute; top: 4px; left: 4px. Zones and regions get position: relative in selection mode. The handle overlays the element without participating in layout. Content is identical in both modes.


The drag system

Three types of drops, all using HTML5 drag events:

Zone reorder: Zones drag within their pdf-page-content. Drop above or below via target.before(draggedEl) or target.after(draggedEl). Grid reflows automatically.

Region to column: Regions can drop into a different pdf-col. Drop handler: target.appendChild(draggedEl) or target.prepend(draggedEl). Cross-column moves work because _isValidDrop allows region-to-pdf-col.

Quadrant drop (new column grid): Drop a region on the left 25% or right 25% of another region creates pdf-zone--cols-2:

function _wrapRegionsInGrid(dragged, target, side) {
    const bookmark = document.createElement('div');
    target.before(bookmark); // save insertion point before moving anything

const [leftRegion, rightRegion] = side === 'left' ? [dragged, target] : [target, dragged];

const newZone = document.createElement('div'); newZone.className = 'pdf-zone pdf-zone--cols-2';

['left', 'right'].forEach((name, i) => { const col = document.createElement('div'); col.className = pdf-col pdf-col--${name}; col.appendChild(i === 0 ? leftRegion : rightRegion); newZone.appendChild(col); });

bookmark.replaceWith(newZone); }

The bookmark pattern is the key: both target and dragged are removed from their parent before newZone exists, so you need to save the insertion point before touching either.


Ghost column expansion

When dragging a region near the edge of a zone (within 15% of zone width), a ghost column appears:

const colsMatch = zone.className.match(/pdf-zone--cols-(\d)/);
const cols = colsMatch ? parseInt(colsMatch[1]) : 1;
if (cols >= 4) return; // max columns

Note: reading from the class name, not from querySelectorAll('.pdf-col').length. A cols-1 zone has no pdf-col children — counting children always returns 0.

On ghost drop, _expandZoneAndDrop updates data-zones JSON, calls applyZones(pageEl, zones), then finds the rebuilt zone by index (the original reference is stale after DOM rebuild):

const rebuiltZone = [...pageEl.querySelectorAll('.pdf-zone')][zoneIdx];
if (rebuiltZone) _injectResizeDividers(rebuiltZone);

Column resize

Resize dividers are position: absolute within each multi-column zone (which has position: relative). On mousedown, we read getComputedStyle(zoneEl).gridTemplateColumns — the browser resolves 1fr to pixel values at this point.

function _onDividerMouseMove(e) {
    const delta = e.clientX - _resizeDrag.startX;
    const newWidths = [..._resizeDrag.startWidths];
    newWidths[colIdx]     = Math.max(40, startWidths[colIdx] + delta);
    newWidths[colIdx + 1] = Math.max(40, startWidths[colIdx + 1] - delta);
    zoneEl.style.gridTemplateColumns = newWidths.map(w => w + 'px').join(' ');
}

On mouseup, the final widths are stored in data-zones as colWidths: ['320px', '288px']. applyZones() reads them and sets zoneDiv.style.gridTemplateColumns as an inline override over the class-level repeat(N, 1fr).

Critical: _resizeDrag must be nulled before calling _saveColWidths. _saveColWidths calls _syncState which calls _removeAllDividers — if _resizeDrag is still set, the cleanup reads stale divider references.


State sync

Every mutation calls one function:

function _syncState() {
    _removeGhostCol();
    _removeAllDividers();
    _hidePropsPanel();
    _preview.querySelectorAll('.sel-drag-handle').forEach(h => h.remove());
    applyHtmlEverywhere(_preview.innerHTML, _preview);
    if (_active) {
        _attachHandles();
        _injectAllResizeDividers();
    }
}

UI chrome is stripped before serializing innerHTML. applyHtmlEverywhere pushes to state.pdf1.extractedHTML, the Monaco editor, and the visual diff surface. Then chrome is re-injected. The Monaco editor and visual diff always see clean HTML without drag handles or dividers.


data-zones schema (extended)

[
  {
    "y0": 0, "y1": 500,
    "cols": 2,
    "colWidths": ["320px", "288px"],
    "type": "flex-center"
  }
]

colWidths overrides repeat(N, 1fr) inline. type: "flex-center" switches the zone to display: flex; flex-direction: column; align-items: center. Both fields are optional and backwards-compatible.

Read this post in the full Engineering Journal →