The crop frame had width:100% together with max-height, so the height was
clamped while the width stayed - the frame lost the target aspect ratio and
object-fit:fill stretched the picture into it. Measured at 390px: frame 0.843
instead of 0.778, picture 8.3% too wide. The width now follows from the allowed
height and the ratio, and the image carries its own aspect-ratio instead of a
second percentage, so it cannot stretch at all.
Dragging was fine but felt dead: at fill zoom the crop sits at the stop in one
axis. The editor now states which way it can move, double-tap zooms, arrow keys
nudge, and the zoom slider shows its factor.
Sizes: 41 -> 86 in ten groups, including US inch sizes, instant-film picture
areas, cards, DIN A0-A7 and eleven aspect ratios as physical sizes.
The tab is called Drucken now; /druck and /passbilder redirect there.
Security and robustness:
- EXIF orientation is now applied before any geometry. Phone photos carry the
rotation only as metadata; sharp was cropping the unrotated raster, so a
portrait shot came out of the printer sideways and wrongly framed.
- renderCell no longer materialises the padded image at source resolution.
It is one extract-resize-extend chain now, which is also sharp's internal
order. A panorama into a narrow contain target used to build a ~960 MB
intermediate and then fail; it is 90 ms and a few MB now.
- Target size is capped (300 Mpx) and bleedMm is clamped in /api/print/single,
which had no bound at all.
- The delivery gallery is validated before use - posixpath.join let a crafted
name escape the target's base folder and create directories there.
- Sheet requests are capped at 500 pieces and the packer has a step budget, so
a degenerate request cannot block the single-threaded server.
- Print presets: delete only your own (admins all), config size limit, count
limit, and by_name honours anonymous_generations.
- Telegram callbacks require an active pairing, like every other path.
- Error responses no longer leak storage paths or delivery hostnames.
Correctness:
- allowRotate:undefined now means allowed, consistently with the packer.
- The many-formats shortcut no longer drops a format that only fits rotated.
- unplaced names the format that is actually missing, not the first one.
- Crop marks never sit inside the printed bleed - the offset is raised.
- capacity() computes the grid instead of probing with 200 copies.
- Image keys in the sheet cannot collide with a cell literally named x::rot.
- labelMm keeps real decimals; parseSizeMm reads a:b as width:height, so
3:4/15 is portrait and 4:3/15 is landscape.
- The footer is skipped when there is no free space at the bottom.
- The UI warns when corner marks do not fit the margin, and when continuous
guides are used with mixed sizes.
Tests: 21 -> 31, each finding has a regression test.
Print now offers every format the AI pipeline knows (9x13 to 60x90, DIN A6-A2,
squares, poster/frame sizes to 70x100, and the screen ratios as physical sizes).
When a picture does not match the target ratio the user picks per image between
cropping to fill and keeping the whole picture on a border colour - never a
stretch. The module is called Passbilder now; /druck redirects.
Fixes two real defects: sharp runs extend after resize, so padded cells came out
oversized (a 35x45 mm cell became 35x171 mm with a border), and the grid packer
rotated a single portrait photo just because more would fit sideways.
Mobile: cards become rows, touch targets ~40px, crop editor as a bottom sheet.
Verified at 390/820/1440px - all three produce the same PDF.
Telegram gains a fully button-driven print flow (photo -> size -> count ->
A4 PDF with corner marks), running inline without the queue or a model. MCP
gains exact_size and print_sheet so an assistant can produce print-ready files
from a local folder. Keeps the rule that every feature works on all three ways in.
- delivery.ts: upload the stored result in its chosen format (output_ext,
global or per-recipe) instead of always converting non-alpha to JPEG.
- jobs POST froze the snapshot without output_ext -> per-conversion/per-recipe
format choice was silently dropped. Now carried through.
- telegram.ts: recipe snapshots carry output_ext + delivery_target_id
(custom formats already flow via output_format).
- mcp/klarbild-mcp.mjs: process_images gains tasks, output_ext, orientation,
crop_mode, contour_mm, picdrop_gallery; job_status shows finished_at + errors.
- Docs: llms.txt, mcp/README, changelog updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6