Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Import from anything.** One import that identifies a file by its *contents* rather than
its name — operators rename files, and a phone hands over an `image.jpg` that is really
HEIC. **PDF** is now a first-class source: a vector page imports as lines at the size the
page declares, keeping its page position so registration works exactly as it does for an
SVG; a page that is really a scan goes to the conversion wizard; a multi-page file asks
which page. HEIC is named rather than just rejected ("convert it on your phone"), anything
unreadable says so instead of adding an empty artwork, and phones get a **Take photo**
button so a sketch on paper can be traced without leaving the app. pdf.js is loaded on
demand, so the bundle every client downloads to jog the machine is unchanged.

### Fixed

- The daemon served `.mjs` as `application/octet-stream`, which browsers refuse to execute as
a module — a lazily-loaded chunk would have worked in the dev server and failed only in the
packaged build. `.wasm` and `.map` were missing too.
- **Holding tabs for cutting.** Short uncut bridges that keep each piece attached to the
sheet until it is snapped out by hand — the opposite of the overcut, and wanted just as
often. Count, width and a minimum contour length are set in the knife profile; bridges are
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,14 @@ unattended **Raspberry Pi** setup needs.

## What it does

- **One import, with a wizard for images.** SVGs are flattened to polylines in the browser
DOM (`getPointAtLength` / `getCTM`, no dependencies) and go straight onto the page at
their real size. A raster image opens the **import wizard**: adjust it (brightness,
- **One import for every source.** What a file *is* is read from its contents, not its name:
**SVG** and **vector PDF** go straight onto the page at the real size they declare (keeping
their page position, so registration works); a **PDF that is really a scan**, a photo, or
any raster the browser can decode opens the conversion wizard; a **multi-page PDF** asks
which page; **HEIC** says what to do about it rather than failing silently; and on a phone
there is a **Take photo** button, so a sketch on paper can be photographed and traced at
the machine. SVGs are flattened to polylines in the browser DOM (`getPointAtLength` /
`getCTM`, no dependencies). A raster image opens the **import wizard**: adjust it (brightness,
contrast, invert, rotate, crop), then choose how it becomes lines — **Outline**
(marching-squares iso-contours), **Hatching** (parallel lines, denser where the image is
darker), **Crosshatch** (more directions the darker the tone), **Stippling** (dots placed
Expand Down
6 changes: 6 additions & 0 deletions gateway/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,10 +521,16 @@ async function handleCommand(ws: WebSocket, msg: ClientMessage) {
const MIME: Record<string, string> = {
'.html': 'text/html',
'.js': 'text/javascript',
// A lazily-loaded chunk (the PDF worker) is served as .mjs. Browsers refuse
// to execute a module served as application/octet-stream, so without this the
// feature fails only in the packaged build — never in the dev server.
'.mjs': 'text/javascript',
'.css': 'text/css',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.json': 'application/json',
'.map': 'application/json',
'.wasm': 'application/wasm',
};
const httpServer = createServer(async (req, res) => {
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-12
43 changes: 43 additions & 0 deletions openspec/changes/archive/2026-09-12-import-sources/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
## Context

`onLoadImage` branched on `file.type` and the `.svg` extension, and everything else went to
`imageToField`, which is `<img>` decoding — so whatever the browser could decode worked, and
anything else produced an error with no explanation.

## Decisions

- **Sniff the 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. The
MIME type is kept as a tiebreak only where the bytes are genuinely ambiguous (a long XML prologue
pushing `<svg` past the sniffed window).
- **HEIC gets its own answer.** It is an ISO-BMFF file like an MP4, distinguished by its brand.
Naming it means the app can say what to do about it.
- **The pdf.js operator decoder is pure and the op codes are asserted at load.** Decoding runs on a
plain operator list, so it is testable without a PDF or a DOM; the hard-coded codes are checked
against the library's own at load time, because a renumbering in a future version would not break
the build — it would just make every PDF import as blank.
- **A PDF page is flipped at the boundary.** PDF user space is y-up from the bottom-left. The flip
happens in the decoder, so everything downstream keeps the single convention it already assumes.
- **Vector or scan is decided by what the page paints**, not by a setting: if there are paths, they
are what the operator wants; if there are none, the page is rasterised and the wizard takes over.
- **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. This is a rasterisation to trace, not something anyone
watches.
- **The page is painted white first.** A PDF page has no background of its own; without it,
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 camera is a separate control, mobile-only.** Putting `capture` on the main import would
force the camera and take away the file picker. The placement controls stay on the desktop
layout; what a phone gets is the ability to photograph a sketch and trace it.

## Risks / Trade-offs

- Vector PDFs vary enormously. This decoder handles paths, transforms and the save/restore stack;
it ignores clipping, patterns and shading, and counts text runs rather than tracing glyph
outlines (which would plot hollow letters). A page that is mostly text will import as very little
and say so.
- An unknown path opcode stops that chunk rather than guessing: opcodes and coordinates share one
stream, so reading past one turns the rest of the path into nonsense geometry.
34 changes: 34 additions & 0 deletions openspec/changes/archive/2026-09-12-import-sources/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## 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.

## What Changes

- **Files are identified by content** (`src/plot/sniff.ts`), not by extension: operators rename
files, and a phone hands over an `image.jpg` that is really HEIC.
- **PDF** (`src/plot/pdf.ts`): a vector page becomes lines at the size the page declares — with the
page position kept, so Place on page and the registration wizard work as they do for an SVG. A
page that is really a scan goes to the import wizard. Multi-page files ask which page.
- **HEIC is named, not just rejected**: most browsers cannot decode it, and "convert it on your
phone" is useful where "could not read that image" is not.
- **Take photo** on a phone, via the file input's `capture` attribute — so a sketch can be
photographed and traced standing at the machine.
- **Anything unreadable says so**, instead of adding a silent empty artwork.

## Capabilities

### Modified Capabilities
- `image-import-wizard`: one import accepts vector, raster and PDF, identified by content.
- `responsive-control`: importing a photograph is available on a phone (the camera is the point).
- `pi-deployment`: the daemon serves `.mjs`, `.wasm` and `.map` with correct types.

## Impact

- **Code:** new `src/plot/sniff.ts` and `src/plot/pdf.ts` (+ tests); `src/ui/App.tsx`;
`gateway/server.ts`.
- **Dependency:** `pdfjs-dist`, imported on demand. The main bundle is unchanged; the library and
its worker are separate chunks, fetched only when a PDF is opened.
- **Fixed in passing:** the daemon's static server had no MIME type for `.mjs`, so a lazily-loaded
module chunk would have failed in the packaged build while working perfectly in the dev server.
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
## ADDED Requirements

### Requirement: Sources are identified by content

The import SHALL decide what a file is from its contents rather than its name or declared type, and
SHALL accept vector files, PDFs and any raster image the browser can decode.

#### Scenario: A renamed file

- **WHEN** a file's extension does not match its contents
- **THEN** it is imported according to what it actually is

#### Scenario: Something that is not an image at all

- **WHEN** the file cannot be identified
- **THEN** the operator is told, and no empty artwork is added

#### Scenario: An image the browser cannot decode

- **WHEN** the file is HEIC and the browser has no decoder for it
- **THEN** the message says so and what to do about it, rather than reporting a generic failure

### Requirement: PDF pages import as lines or as an image

A PDF page that contains vector paths SHALL be imported as lines at the size the page declares,
keeping its position on the page. A page without paths SHALL be treated as a picture and sent to
the conversion wizard. A file with several pages SHALL ask which page to import.

#### Scenario: A cut file

- **WHEN** the operator imports a vector PDF
- **THEN** its paths are added at the page's real size, positioned as the page positions them, so
registration against a matching print works as it does for an SVG

#### Scenario: A scan

- **WHEN** the page contains no paths
- **THEN** it is rendered and the conversion wizard opens on it

#### Scenario: Several pages

- **WHEN** the PDF has more than one page
- **THEN** the operator chooses which one, and the imported artwork says which page it came from

#### Scenario: Text in a PDF

- **WHEN** a page contains text
- **THEN** it is not traced — a PDF's text is glyph outlines, which plot as hollow letters — and the
operator is told how much was skipped
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
## ADDED Requirements

### Requirement: The served GUI's assets have correct content types

The daemon SHALL serve every asset type the built GUI produces with a content type browsers accept,
including JavaScript module files (`.mjs`), WebAssembly and source maps.

#### Scenario: A lazily-loaded module chunk

- **WHEN** the GUI loads a feature whose code is split into a `.mjs` chunk
- **THEN** the daemon serves it as JavaScript and the browser executes it, rather than refusing it
for its content type
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## ADDED Requirements

### Requirement: Photographing a sketch from a phone

On a phone the app SHALL offer taking a photograph and importing an image, so a drawing on paper
can be photographed and traced without leaving the app. The placement controls remain on the
desktop layout.

#### Scenario: Taking a photo

- **WHEN** the operator uses the take-photo control on a phone
- **THEN** the camera opens, and the picture taken goes into the conversion wizard

#### Scenario: Not on the desktop layout

- **WHEN** the app is used on a wide screen
- **THEN** the camera control is not shown, because there is nothing to photograph with
34 changes: 34 additions & 0 deletions openspec/changes/archive/2026-09-12-import-sources/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## 1. Identification

- [x] 1.1 `src/plot/sniff.ts`: PDF, SVG, raster signatures, HEIC by ISO-BMFF brand, MIME tiebreak
- [x] 1.2 Unit tests for each signature, HEIC vs MP4, SVG after a BOM/prologue, MIME never
overriding a signature, and unknown files staying unknown

## 2. PDF

- [x] 2.1 `src/plot/pdf.ts`: pure operator-list decoder (paths, transforms, save/restore, curves),
page size in mm, y-flip, text/image reporting, op-code assertion
- [x] 2.2 Unit tests: page size, flip, closed contour, cubic and quadratic curves, transform
apply/restore/nesting, text counted, images reported, degenerate subpath dropped, unknown
opcode stops the chunk, several subpaths, missing page box
- [x] 2.3 Lazy loader: vector geometry, raster rendering with print intent and a white background,
worker as a Vite-fingerprinted URL

## 3. Import flow

- [x] 3.1 Route by sniffed kind; multi-page picker; vector page keeps its page offset
- [x] 3.2 HEIC message; unreadable files reported rather than silently empty
- [x] 3.3 Mobile-only "Take photo" with `capture`

## 4. Daemon

- [x] 4.1 Serve `.mjs`, `.wasm` and `.map` with correct content types

## 5. Docs, gate, verification

- [x] 5.1 README, CHANGELOG
- [x] 5.2 `mise run ci` green
- [x] 5.3 Verified in the browser against the packaged build: a vector PDF imports at its real size
(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; the camera input appears only at phone width
48 changes: 48 additions & 0 deletions openspec/specs/image-import-wizard/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,51 @@ image's overall brightness.

- **WHEN** the image has no sharp boundary
- **THEN** no lines are produced, and the wizard says there is nothing to add

### Requirement: Sources are identified by content

The import SHALL decide what a file is from its contents rather than its name or declared type, and
SHALL accept vector files, PDFs and any raster image the browser can decode.

#### Scenario: A renamed file

- **WHEN** a file's extension does not match its contents
- **THEN** it is imported according to what it actually is

#### Scenario: Something that is not an image at all

- **WHEN** the file cannot be identified
- **THEN** the operator is told, and no empty artwork is added

#### Scenario: An image the browser cannot decode

- **WHEN** the file is HEIC and the browser has no decoder for it
- **THEN** the message says so and what to do about it, rather than reporting a generic failure

### Requirement: PDF pages import as lines or as an image

A PDF page that contains vector paths SHALL be imported as lines at the size the page declares,
keeping its position on the page. A page without paths SHALL be treated as a picture and sent to
the conversion wizard. A file with several pages SHALL ask which page to import.

#### Scenario: A cut file

- **WHEN** the operator imports a vector PDF
- **THEN** its paths are added at the page's real size, positioned as the page positions them, so
registration against a matching print works as it does for an SVG

#### Scenario: A scan

- **WHEN** the page contains no paths
- **THEN** it is rendered and the conversion wizard opens on it

#### Scenario: Several pages

- **WHEN** the PDF has more than one page
- **THEN** the operator chooses which one, and the imported artwork says which page it came from

#### Scenario: Text in a PDF

- **WHEN** a page contains text
- **THEN** it is not traced — a PDF's text is glyph outlines, which plot as hollow letters — and the
operator is told how much was skipped
10 changes: 10 additions & 0 deletions openspec/specs/pi-deployment/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,13 @@ Setting up a Pi SHALL be done by installing a single versioned Debian package (`
- **WHEN** the package is purged
- **THEN** the service is stopped and disabled and the package-managed files are removed

### Requirement: The served GUI's assets have correct content types

The daemon SHALL serve every asset type the built GUI produces with a content type browsers accept,
including JavaScript module files (`.mjs`), WebAssembly and source maps.

#### Scenario: A lazily-loaded module chunk

- **WHEN** the GUI loads a feature whose code is split into a `.mjs` chunk
- **THEN** the daemon serves it as JavaScript and the browser executes it, rather than refusing it
for its content type
15 changes: 15 additions & 0 deletions openspec/specs/responsive-control/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,18 @@ From the phone view the operator SHALL be able to control a plot running on the
- **WHEN** a plot is running and the operator uses the phone view to pause, resume, or stop it
- **THEN** the command takes effect on the daemon-driven plot and the view's live state and progress update accordingly

### Requirement: Photographing a sketch from a phone

On a phone the app SHALL offer taking a photograph and importing an image, so a drawing on paper
can be photographed and traced without leaving the app. The placement controls remain on the
desktop layout.

#### Scenario: Taking a photo

- **WHEN** the operator uses the take-photo control on a phone
- **THEN** the camera opens, and the picture taken goes into the conversion wizard

#### Scenario: Not on the desktop layout

- **WHEN** the app is used on a wide screen
- **THEN** the camera control is not shown, because there is nothing to photograph with
Loading