Skip to content

feat(plot): import PDFs, HEIC and camera photos, identified by content - #77

Merged
BernardJen merged 1 commit into
mainfrom
feat/import-sources
Sep 12, 2026
Merged

BernardJen merged 1 commit into
mainfrom
feat/import-sources

Conversation

@BernardJen

Copy link
Copy Markdown
Contributor

Closes #56.

Why

The import button said "+ Image" but meant "SVG, PNG, JPEG or WebP", and it decided which by looking at the file's name. Half an operator's sources are neither: the print-and-cut files come as PDF, phones produce HEIC, and a sketch on paper is a photograph that has to be taken, not found.

Files are identified by their bytes

Extensions lie, MIME types are often just the extension in disguise, and the cost of being wrong is a silent empty artwork rather than an error. src/plot/sniff.ts is pure, so each format's signature is pinned by a test rather than discovered on a machine with vinyl in it. The MIME type survives only as a tiebreak where the bytes are genuinely ambiguous (a long XML prologue pushing <svg past the sniffed window) — and never overrides a signature that disagrees.

PDF is a first-class source

  • A page with paths becomes lines at the size the page declares, keeping its page position, so Place on page and the registration wizard work exactly as they do for an SVG.
  • A page without paths is a scan: it's rendered and handed to the conversion wizard.
  • Several pages → the operator picks one, and the artwork records which.
  • Text is counted, not traced — a PDF's text is glyph outlines, which plot as hollow letters.

The operator decoder is pure (it runs on a plain operator list, testable without a PDF or a DOM), and the hard-coded pdf.js op codes are asserted against the library's own at load time: a renumbering in a future version wouldn't break the build, it would just make every PDF import as blank. The y-flip happens in the decoder, at the boundary, so everything downstream keeps the single convention it already assumes.

Two things learned by actually running it:

  • Rasterising uses print intent, although nothing is printed. pdf.js drives its display render loop with requestAnimationFrame, which doesn't fire in a background tab — an import started and then left alone would hang for ever.
  • The page is painted white first. A PDF page has no background of its own, so transparent areas read as black and the whole page traces as one solid blob.

pdf.js is imported on demand — it's by far the largest dependency here and most sessions never open a PDF, so it must not sit in the bundle every client downloads to jog the machine. The library (431 kB) and its worker (1.3 MB) come out as separate chunks; the main bundle is unchanged.

Fixed in passing

The daemon served .mjs as application/octet-stream, which browsers refuse to execute as a module. A lazily-loaded chunk would have worked perfectly in the dev server and failed only from the .deb. .wasm and .map were missing too.

Verification

mise run ci green — 409 tests, 24 of them new across sniffing and PDF decoding (page size, y-flip, closed contours, cubic and quadratic curves, transform apply/restore/nesting, text counted, images reported, degenerate subpath dropped, an unknown opcode stopping the chunk rather than misreading coordinates).

Driven against the packaged build: a vector PDF imports at 88.2 × 211.7 mm with the right page offset (17.6, 50.1); a three-page PDF asks which page and imports page 2; a filled path imports as its outline; a page whose only content is an image XObject opens the wizard; and the camera input appears only at phone width.

🤖 Generated with Claude Code

The import button said "+ Image" but meant "SVG, PNG, JPEG or WebP", and it
decided which by looking at the file's *name*. Half an operator's sources are
neither: the print-and-cut files come as PDF, phones produce HEIC, and a sketch
on paper is a photograph that has to be taken, not found.

Files are now identified by their bytes. Extensions lie, MIME types are often
just the extension in disguise, and the cost of being wrong is a silent empty
artwork rather than an error. The sniffer is pure, so each format's signature
is pinned by a test rather than discovered on a machine with vinyl in it.

PDF is a first-class source. A page with paths becomes lines at the size the
page declares, keeping its page position so Place on page and the registration
wizard work exactly as they do for an SVG; a page without paths is a scan, and
goes to the conversion wizard; a multi-page file asks which page. The operator
decoder is pure — it runs on a plain operator list, testable without a PDF or a
DOM — and the hard-coded pdf.js op codes are asserted against the library's own
at load time, because a renumbering would not break the build, it would just
make every PDF import as blank. The y-flip happens in the decoder, at the
boundary, so everything downstream keeps the one convention it assumes.

Two things learned by running it:

- Rasterising uses *print* intent although nothing is printed. pdf.js drives
  its display render loop with requestAnimationFrame, which does not fire in a
  background tab — an import started and then left alone would hang for ever.
- The page is painted white first. A PDF page has no background of its own, so
  transparent areas read as black and the whole page traces as one solid blob.

pdf.js is imported on demand: it is by far the largest dependency here and most
sessions never open a PDF, so it must not sit in the bundle every client
downloads to jog the machine. The library and its worker come out as separate
chunks.

Also fixes a packaging bug this uncovered: the daemon served `.mjs` as
application/octet-stream, which browsers refuse to execute as a module. A
lazily-loaded chunk would have worked perfectly in the dev server and failed
only from the .deb.

HEIC is named rather than merely rejected — most browsers cannot decode it, and
"convert it on your phone" is useful where "could not read that image" is not.
Phones get a Take photo button (the file input's capture attribute) so a sketch
can be traced standing at the machine; the placement controls stay on desktop.

Verified against the packaged build: a vector PDF imports at 88.2 × 211.7 mm
with the right page offset, a three-page PDF asks which page and imports page
2, a filled path imports as its outline, a page whose only content is an image
opens the wizard, and the camera input appears only at phone width.

Closes #56

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@BernardJen
BernardJen merged commit 2b13881 into main Sep 12, 2026
2 checks passed
@BernardJen
BernardJen deleted the feat/import-sources branch September 12, 2026 10:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Import sources: JPEG/HEIC, PDF and camera photos alongside PNG/SVG

1 participant