Demo and product page for Klarbild
A static Astro page. The sheet playground imports the print engine (printlayout.ts, paper.ts) verbatim from the Klarbild repository and runs it in the browser, so the layout on screen is the application's own arithmetic rather than a mock-up. The PDF it offers is drawn from the same placements with pdf-lib. No backend, nothing uploaded, no third-party requests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
This commit is contained in:
19 files changed
+7309
No files matched your search
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.astro
|
||||
*.log
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Build the static site, then serve it with nginx. Two stages, so the image that
|
||||
# runs carries no toolchain — about 50 MB instead of about 400.
|
||||
FROM node:20-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --no-audit --no-fund || npm install --no-audit --no-fund
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,34 @@
|
||||
# klarbild-demo
|
||||
|
||||
The product and demo page for [Klarbild](https://github.com/tillheidrich/klarbild),
|
||||
live at **klarbild-demo.heidrich-digital.de**.
|
||||
|
||||
A single static Astro page. The interesting part is `src/lib/playground.ts`: it
|
||||
imports `src/engine/printlayout.ts` and `src/engine/paper.ts` **verbatim from the
|
||||
Klarbild repository** and runs them in the visitor's browser. So the sheet you see
|
||||
being laid out is not a mock-up — it is the application's own arithmetic, and the
|
||||
PDF the page hands you is drawn from the same placements (with pdf-lib in the
|
||||
browser rather than sharp on a server, which is why it carries outlines instead of
|
||||
photographs).
|
||||
|
||||
Nothing is uploaded and there is no backend.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Keeping the engine in sync
|
||||
|
||||
`src/engine/*` are copies. When the print engine changes upstream, copy them again:
|
||||
|
||||
```bash
|
||||
cp ../klarbild/src/lib/{printlayout,paper,printoversize}.ts src/engine/
|
||||
# printoversize.ts imports './printlayout.ts'; drop the extension for the bundler
|
||||
sed -i "s#from './printlayout.ts'#from './printlayout'#" src/engine/printoversize.ts
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Dockerfile: Node builds the site, nginx serves it on port 80. Coolify deploys on
|
||||
push to `main`.
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
// A static site. Everything the visitor can play with runs in their browser —
|
||||
// there is no server here to send a picture to.
|
||||
export default defineConfig({
|
||||
output: 'static',
|
||||
site: 'https://klarbild-demo.heidrich-digital.de',
|
||||
build: { inlineStylesheets: 'auto' },
|
||||
});
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Hashed asset file names, so they can be cached hard. The HTML is not hashed
|
||||
# and must not be, or a redeploy would go unnoticed for a year.
|
||||
location /_astro/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
# This page loads nothing from anywhere else, so it can say so.
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript image/svg+xml application/json;
|
||||
gzip_min_length 1024;
|
||||
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
error_page 404 /index.html;
|
||||
}
|
||||
Generated
+5555
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "klarbild-demo",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Product and demo site for Klarbild — klarbild-demo.heidrich-digital.de",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/space-grotesk": "^5.1.0",
|
||||
"@fontsource/space-mono": "^5.1.0",
|
||||
"astro": "^5.5.0",
|
||||
"pdf-lib": "^1.17.1"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 229 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,201 @@
|
||||
// Paper and image sizes for the print sheet (the "Print" module, no AI involved).
|
||||
// All dimensions in millimetres — mm is the leading unit of this module, because a
|
||||
// print sheet is a physical thing. Pixels only come into being at render time (mm → dpi).
|
||||
|
||||
export interface PaperSize { id: string; label: string; w: number; h: number; group: string }
|
||||
|
||||
/** Sheet sizes (portrait convention: short side × long side). */
|
||||
export const PAPERS: PaperSize[] = [
|
||||
// DIN
|
||||
{ id: 'A6', label: 'DIN A6', w: 105, h: 148, group: 'DIN' },
|
||||
{ id: 'A5', label: 'DIN A5', w: 148, h: 210, group: 'DIN' },
|
||||
{ id: 'A4', label: 'DIN A4', w: 210, h: 297, group: 'DIN' },
|
||||
{ id: 'A3', label: 'DIN A3', w: 297, h: 420, group: 'DIN' },
|
||||
{ id: 'A3plus', label: 'DIN A3+ (Super A3)', w: 329, h: 483, group: 'DIN' },
|
||||
{ id: 'A2', label: 'DIN A2', w: 420, h: 594, group: 'DIN' },
|
||||
{ id: 'A1', label: 'DIN A1', w: 594, h: 841, group: 'DIN' },
|
||||
// Photo paper (cut sheets, as sold over the counter)
|
||||
{ id: 'F9x13', label: 'Photo paper 9 × 13 cm', w: 90, h: 130, group: 'Photo paper' },
|
||||
{ id: 'F10x15', label: 'Photo paper 10 × 15 cm', w: 100, h: 150, group: 'Photo paper' },
|
||||
{ id: 'F13x18', label: 'Photo paper 13 × 18 cm', w: 130, h: 180, group: 'Photo paper' },
|
||||
{ id: 'F15x20', label: 'Photo paper 15 × 20 cm', w: 150, h: 200, group: 'Photo paper' },
|
||||
{ id: 'F20x30', label: 'Photo paper 20 × 30 cm', w: 200, h: 300, group: 'Photo paper' },
|
||||
{ id: 'F10x10', label: 'Photo paper 10 × 10 cm', w: 100, h: 100, group: 'Photo paper' },
|
||||
{ id: 'F13x13', label: 'Photo paper 13 × 13 cm', w: 130, h: 130, group: 'Photo paper' },
|
||||
{ id: 'F4x6', label: 'Photo paper 4 × 6 in (102 × 152 mm)', w: 101.6, h: 152.4, group: 'Photo paper' },
|
||||
{ id: 'F5x7', label: 'Photo paper 5 × 7 in (127 × 178 mm)', w: 127, h: 177.8, group: 'Photo paper' },
|
||||
{ id: 'F8x10', label: 'Photo paper 8 × 10 in (203 × 254 mm)', w: 203.2, h: 254, group: 'Photo paper' },
|
||||
// US
|
||||
{ id: 'Letter', label: 'US Letter', w: 215.9, h: 279.4, group: 'US' },
|
||||
{ id: 'Legal', label: 'US Legal', w: 215.9, h: 355.6, group: 'US' },
|
||||
];
|
||||
|
||||
/** Image sizes (the trim size of a single image on the sheet). */
|
||||
export interface PhotoSize { id: string; label: string; w: number; h: number; group: string }
|
||||
|
||||
export const PHOTO_SIZES: PhotoSize[] = [
|
||||
// --- Passport / ID photo (national requirements) ---
|
||||
{ id: 'P35x45', label: 'Passport photo 35 × 45 mm (DE/EU, biometric)', w: 35, h: 45, group: 'Passport photo' },
|
||||
{ id: 'P33x48', label: 'China visa 33 × 48 mm', w: 33, h: 48, group: 'Passport photo' },
|
||||
{ id: 'P50x50', label: 'US visa 51 × 51 mm (2 × 2 in)', w: 50.8, h: 50.8, group: 'Passport photo' },
|
||||
{ id: 'P50x70', label: 'Canada passport photo 50 × 70 mm', w: 50, h: 70, group: 'Passport photo' },
|
||||
{ id: 'P45x35', label: 'Passport photo landscape 45 × 35 mm', w: 45, h: 35, group: 'Passport photo' },
|
||||
{ id: 'P25x35', label: 'Small ID 25 × 35 mm', w: 25, h: 35, group: 'Passport photo' },
|
||||
|
||||
// --- Small sizes (nursery, school, wallet) ---
|
||||
{ id: 'K20x30', label: '2 × 3 cm', w: 20, h: 30, group: 'Small' },
|
||||
{ id: 'K30x40', label: '3 × 4 cm', w: 30, h: 40, group: 'Small' },
|
||||
{ id: 'K35x50', label: '3.5 × 5 cm', w: 35, h: 50, group: 'Small' },
|
||||
{ id: 'K40x50', label: '4 × 5 cm', w: 40, h: 50, group: 'Small' },
|
||||
{ id: 'K45x60', label: '4.5 × 6 cm', w: 45, h: 60, group: 'Small' },
|
||||
{ id: 'K50x70', label: '5 × 7 cm', w: 50, h: 70, group: 'Small' },
|
||||
{ id: 'K60x80', label: '6 × 8 cm', w: 60, h: 80, group: 'Small' },
|
||||
{ id: 'K60x90', label: '6 × 9 cm', w: 60, h: 90, group: 'Small' },
|
||||
{ id: 'K70x100', label: '7 × 10 cm', w: 70, h: 100, group: 'Small' },
|
||||
|
||||
// --- Classic photo sizes ---
|
||||
{ id: 'S9x13', label: '9 × 13 cm', w: 90, h: 130, group: 'Photo' },
|
||||
{ id: 'S10x15', label: '10 × 15 cm', w: 100, h: 150, group: 'Photo' },
|
||||
{ id: 'S11x15', label: '11 × 15 cm', w: 110, h: 150, group: 'Photo' },
|
||||
{ id: 'S12x15', label: '12 × 15 cm', w: 120, h: 150, group: 'Photo' },
|
||||
{ id: 'S13x18', label: '13 × 18 cm', w: 130, h: 180, group: 'Photo' },
|
||||
{ id: 'S15x20', label: '15 × 20 cm', w: 150, h: 200, group: 'Photo' },
|
||||
{ id: 'S15x21', label: '15 × 21 cm', w: 150, h: 210, group: 'Photo' },
|
||||
{ id: 'S18x24', label: '18 × 24 cm', w: 180, h: 240, group: 'Photo' },
|
||||
{ id: 'S20x25', label: '20 × 25 cm', w: 200, h: 250, group: 'Photo' },
|
||||
{ id: 'S20x30', label: '20 × 30 cm', w: 200, h: 300, group: 'Photo' },
|
||||
{ id: 'S24x30', label: '24 × 30 cm', w: 240, h: 300, group: 'Photo' },
|
||||
{ id: 'S28x35', label: '28 × 35 cm', w: 280, h: 350, group: 'Photo' },
|
||||
|
||||
// --- Inch sizes (US photo labs, frames bought abroad) ---
|
||||
{ id: 'Z2_5x3_5', label: 'Wallet 2.5 × 3.5 in (64 × 89 mm)', w: 63.5, h: 88.9, group: 'Inches (US)' },
|
||||
{ id: 'Z3_5x5', label: '3.5 × 5 in (89 × 127 mm)', w: 88.9, h: 127, group: 'Inches (US)' },
|
||||
{ id: 'Z4x6', label: '4 × 6 in (102 × 152 mm)', w: 101.6, h: 152.4, group: 'Inches (US)' },
|
||||
{ id: 'Z5x7', label: '5 × 7 in (127 × 178 mm)', w: 127, h: 177.8, group: 'Inches (US)' },
|
||||
{ id: 'Z8x10', label: '8 × 10 in (203 × 254 mm)', w: 203.2, h: 254, group: 'Inches (US)' },
|
||||
{ id: 'Z11x14', label: '11 × 14 in (279 × 356 mm)', w: 279.4, h: 355.6, group: 'Inches (US)' },
|
||||
{ id: 'Z16x20', label: '16 × 20 in (406 × 508 mm)', w: 406.4, h: 508, group: 'Inches (US)' },
|
||||
{ id: 'Z24x36', label: '24 × 36 in (610 × 914 mm)', w: 609.6, h: 914.4, group: 'Inches (US)' },
|
||||
|
||||
// --- Instant-film look-alikes (image area, not card size) ---
|
||||
{ id: 'I46x62', label: 'Instax mini · image area 46 × 62 mm', w: 46, h: 62, group: 'Instant photo' },
|
||||
{ id: 'I62x62', label: 'Instax Square · image area 62 × 62 mm', w: 62, h: 62, group: 'Instant photo' },
|
||||
{ id: 'I99x62', label: 'Instax Wide · image area 99 × 62 mm', w: 99, h: 62, group: 'Instant photo' },
|
||||
{ id: 'I79x79', label: 'Polaroid · image area 79 × 79 mm', w: 79, h: 79, group: 'Instant photo' },
|
||||
{ id: 'I88x107', label: 'Polaroid · whole card 88 × 107 mm', w: 88, h: 107, group: 'Instant photo' },
|
||||
|
||||
// --- Poster and frame sizes ---
|
||||
{ id: 'R30x40', label: '30 × 40 cm', w: 300, h: 400, group: 'Poster & frames' },
|
||||
{ id: 'R30x45', label: '30 × 45 cm', w: 300, h: 450, group: 'Poster & frames' },
|
||||
{ id: 'R40x50', label: '40 × 50 cm', w: 400, h: 500, group: 'Poster & frames' },
|
||||
{ id: 'R40x60', label: '40 × 60 cm', w: 400, h: 600, group: 'Poster & frames' },
|
||||
{ id: 'R45x60', label: '45 × 60 cm', w: 450, h: 600, group: 'Poster & frames' },
|
||||
{ id: 'R50x70', label: '50 × 70 cm', w: 500, h: 700, group: 'Poster & frames' },
|
||||
{ id: 'R60x80', label: '60 × 80 cm', w: 600, h: 800, group: 'Poster & frames' },
|
||||
{ id: 'R60x90', label: '60 × 90 cm', w: 600, h: 900, group: 'Poster & frames' },
|
||||
{ id: 'R70x100', label: '70 × 100 cm', w: 700, h: 1000, group: 'Poster & frames' },
|
||||
{ id: 'R80x120', label: '80 × 120 cm', w: 800, h: 1200, group: 'Poster & frames' },
|
||||
{ id: 'R100x140', label: '100 × 140 cm', w: 1000, h: 1400, group: 'Poster & frames' },
|
||||
|
||||
// --- Squares ---
|
||||
{ id: 'Q10x10', label: '10 × 10 cm', w: 100, h: 100, group: 'Square' },
|
||||
{ id: 'Q13x13', label: '13 × 13 cm', w: 130, h: 130, group: 'Square' },
|
||||
{ id: 'Q15x15', label: '15 × 15 cm', w: 150, h: 150, group: 'Square' },
|
||||
{ id: 'Q20x20', label: '20 × 20 cm', w: 200, h: 200, group: 'Square' },
|
||||
{ id: 'Q25x25', label: '25 × 25 cm', w: 250, h: 250, group: 'Square' },
|
||||
{ id: 'Q30x30', label: '30 × 30 cm', w: 300, h: 300, group: 'Square' },
|
||||
{ id: 'Q40x40', label: '40 × 40 cm', w: 400, h: 400, group: 'Square' },
|
||||
{ id: 'Q50x50', label: '50 × 50 cm', w: 500, h: 500, group: 'Square' },
|
||||
{ id: 'Q70x70', label: '70 × 70 cm', w: 700, h: 700, group: 'Square' },
|
||||
|
||||
// --- DIN ---
|
||||
{ id: 'DA7', label: 'DIN A7 (7.4 × 10.5 cm)', w: 74, h: 105, group: 'DIN' },
|
||||
{ id: 'DA6', label: 'DIN A6 (10.5 × 14.8 cm)', w: 105, h: 148, group: 'DIN' },
|
||||
{ id: 'DA5', label: 'DIN A5 (14.8 × 21 cm)', w: 148, h: 210, group: 'DIN' },
|
||||
{ id: 'DA4', label: 'DIN A4 (21 × 29.7 cm)', w: 210, h: 297, group: 'DIN' },
|
||||
{ id: 'DA3', label: 'DIN A3 (29.7 × 42 cm)', w: 297, h: 420, group: 'DIN' },
|
||||
{ id: 'DA3P', label: 'DIN A3+ (32.9 × 48.3 cm)', w: 329, h: 483, group: 'DIN' },
|
||||
{ id: 'DA2', label: 'DIN A2 (42 × 59.4 cm)', w: 420, h: 594, group: 'DIN' },
|
||||
{ id: 'DA1', label: 'DIN A1 (59.4 × 84.1 cm)', w: 594, h: 841, group: 'DIN' },
|
||||
{ id: 'DA0', label: 'DIN A0 (84.1 × 118.9 cm)', w: 841, h: 1189, group: 'DIN' },
|
||||
|
||||
// --- Cards and printed matter ---
|
||||
{ id: 'C85x55', label: 'Business card 85 × 55 mm', w: 85, h: 55, group: 'Cards' },
|
||||
{ id: 'C148x105', label: 'Postcard 14.8 × 10.5 cm (A6 landscape)', w: 148, h: 105, group: 'Cards' },
|
||||
{ id: 'C99x210', label: 'DL 9.9 × 21 cm', w: 99, h: 210, group: 'Cards' },
|
||||
{ id: 'C210x99', label: 'DL landscape 21 × 9.9 cm', w: 210, h: 99, group: 'Cards' },
|
||||
{ id: 'C54x86', label: 'Credit card 85.6 × 54 mm', w: 85.6, h: 54, group: 'Cards' },
|
||||
{ id: 'C70x100', label: 'Bookmark 7 × 21 cm', w: 70, h: 210, group: 'Cards' },
|
||||
|
||||
// --- Aspect ratios as a print size (worked out on 30 cm) ---
|
||||
{ id: 'W16x9', label: '16:9 "The Frame" (30 cm wide)', w: 300, h: 168.75, group: 'Aspect ratio' },
|
||||
{ id: 'W9x16', label: '9:16 portrait (30 cm tall)', w: 168.75, h: 300, group: 'Aspect ratio' },
|
||||
{ id: 'W21x9', label: '21:9 cinema (30 cm wide)', w: 300, h: 128.57, group: 'Aspect ratio' },
|
||||
{ id: 'W2x1', label: '2:1 panorama (30 cm wide)', w: 300, h: 150, group: 'Aspect ratio' },
|
||||
{ id: 'W3x2', label: '3:2 (30 cm wide)', w: 300, h: 200, group: 'Aspect ratio' },
|
||||
{ id: 'W2x3', label: '2:3 (30 cm tall)', w: 200, h: 300, group: 'Aspect ratio' },
|
||||
{ id: 'W4x3', label: '4:3 (30 cm wide)', w: 300, h: 225, group: 'Aspect ratio' },
|
||||
{ id: 'W3x4', label: '3:4 (30 cm tall)', w: 225, h: 300, group: 'Aspect ratio' },
|
||||
{ id: 'W5x4', label: '5:4 (30 cm wide)', w: 300, h: 240, group: 'Aspect ratio' },
|
||||
{ id: 'W4x5', label: '4:5 Instagram (30 cm tall)', w: 240, h: 300, group: 'Aspect ratio' },
|
||||
{ id: 'W1x1', label: '1:1 square (30 cm)', w: 300, h: 300, group: 'Aspect ratio' },
|
||||
];
|
||||
|
||||
export const paperById = (id: string) => PAPERS.find((p) => p.id === id) || null;
|
||||
export const photoById = (id: string) => PHOTO_SIZES.find((p) => p.id === id) || null;
|
||||
|
||||
/**
|
||||
* Free-text size → mm. Deliberately generous, because people type sizes the way they say them:
|
||||
* "12x15" → 120 × 150 mm (cm is the default unit)
|
||||
* "12 × 15 cm" → 120 × 150 mm
|
||||
* "35x45mm" → 35 × 45 mm
|
||||
* "5" → 50 × 50 mm (square)
|
||||
* "4:3 / 15" → aspect ratio 4:3, longer side 15 cm → 150 × 112.5 mm
|
||||
*/
|
||||
export function parseSizeMm(input: string): { w: number; h: number } | null {
|
||||
const t = (input || '').trim().toLowerCase().replace(/,/g, '.');
|
||||
if (!t) return null;
|
||||
|
||||
// Aspect ratio with a target edge: "4:3 / 15" or "4:3 15cm"
|
||||
const ratio = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)\s*(?:[/@ ]\s*(\d+(?:\.\d+)?)\s*(mm|cm)?)?$/.exec(t);
|
||||
if (ratio && ratio[3]) {
|
||||
// a:b is read as width:height — so "3:4/15" is portrait, "4:3/15" landscape.
|
||||
const a = parseFloat(ratio[1]), b = parseFloat(ratio[2]);
|
||||
const long = ratio[4] === 'mm' ? parseFloat(ratio[3]) : parseFloat(ratio[3]) * 10;
|
||||
if (!a || !b || !long) return null;
|
||||
return a >= b ? norm(long, long * (b / a)) : norm(long * (a / b), long);
|
||||
}
|
||||
|
||||
const unit = /mm\s*$/.test(t) ? 1 : 10; // no unit given: cm
|
||||
const body = t.replace(/\s*(mm|cm)\s*$/, '').trim();
|
||||
|
||||
const wh = /^(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)$/.exec(body);
|
||||
if (wh) return norm(parseFloat(wh[1]) * unit, parseFloat(wh[2]) * unit);
|
||||
|
||||
const sq = /^(\d+(?:\.\d+)?)$/.exec(body);
|
||||
if (sq) { const n = parseFloat(sq[1]) * unit; return norm(n, n); }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function norm(w: number, h: number): { w: number; h: number } | null {
|
||||
const r = (n: number) => Math.round(n * 100) / 100;
|
||||
if (!(w > 0) || !(h > 0) || w > 2000 || h > 2000) return null;
|
||||
return { w: r(w), h: r(h) };
|
||||
}
|
||||
|
||||
export const mmToPx = (mm: number, dpi: number) => Math.round((mm / 25.4) * dpi);
|
||||
export const mmToPt = (mm: number) => (mm / 25.4) * 72;
|
||||
|
||||
/** Readable caption for a size — without losing the real decimal places. */
|
||||
export function labelMm(w: number, h: number): string {
|
||||
const mm = (n: number) => dec(Math.round(n * 10) / 10);
|
||||
const cm = (n: number) => dec(Math.round(n * 100) / 1000);
|
||||
return w < 100 && h < 100 ? `${mm(w)} × ${mm(h)} mm` : `${cm(w)} × ${cm(h)} cm`;
|
||||
}
|
||||
// Decimal separator of the caption. A point, i.e. English notation. The output
|
||||
// of `labelMm` is assembled at run time out of numbers, so it cannot go through
|
||||
// the dictionary — whatever is written here is what every instance shows, and an
|
||||
// English instance must not print "11,25 × 15 cm". `tests/printlayout.test.ts`
|
||||
// pins the spelling.
|
||||
const dec = (n: number) => String(n);
|
||||
@@ -0,0 +1,392 @@
|
||||
// Pure layout mathematics for the print sheet — mm, no I/O, no dependencies.
|
||||
// Used identically by the preview in the browser and by the PDF renderer on the
|
||||
// server, so that "what I see" and "what gets printed" are the same calculation.
|
||||
//
|
||||
// Coordinates: origin at the top left, x to the right, y downwards (as on screen/SVG).
|
||||
// The PDF renderer mirrors y, because PDF counts from the bottom left.
|
||||
|
||||
export interface PlaceSpec {
|
||||
id: string; // points at an image
|
||||
wMm: number; // trim size (trim box) width
|
||||
hMm: number; // trim size height
|
||||
count: number; // how many times onto the paper
|
||||
allowRotate?: boolean;
|
||||
}
|
||||
|
||||
export interface SheetSpec {
|
||||
wMm: number;
|
||||
hMm: number;
|
||||
marginMm: number; // the printer's non-printable margin (safety margin)
|
||||
gapMm: number; // distance between two trim sizes
|
||||
bleedMm?: number; // bleed per side (the image reaches beyond)
|
||||
center?: boolean; // centre the block on the sheet (default: true)
|
||||
}
|
||||
|
||||
export interface Placement {
|
||||
specId: string;
|
||||
copy: number; // 1-based
|
||||
x: number; y: number; // top left corner of the trim box
|
||||
w: number; h: number; // trim box
|
||||
rotated: boolean; // image placed rotated by 90°
|
||||
}
|
||||
|
||||
export interface Line { x1: number; y1: number; x2: number; y2: number }
|
||||
|
||||
export interface Page { placements: Placement[] }
|
||||
|
||||
export interface LayoutResult {
|
||||
pages: Page[];
|
||||
/** What does not fit on any sheet (e.g. image larger than the paper). */
|
||||
unplaced: { specId: string; count: number; reason: string }[];
|
||||
perSheet: number; // pieces on the first sheet (a figure for the UI)
|
||||
}
|
||||
|
||||
export type MarkMode = 'none' | 'corner' | 'grid';
|
||||
|
||||
const EPS = 1e-6;
|
||||
|
||||
/** Effective gap: the bleeds of two neighbours must not overlap. */
|
||||
export function effectiveGap(sheet: SheetSpec): number {
|
||||
return Math.max(sheet.gapMm || 0, 2 * (sheet.bleedMm || 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Packs the trim sizes onto as few sheets as possible.
|
||||
*
|
||||
* Two routes, deliberately kept apart:
|
||||
* 1. All images the same size → exact grid (columns × rows). Perfectly
|
||||
* aligned, so that continuous cut lines really do run through.
|
||||
* 2. Mixed sizes → MaxRects (best-short-side-fit) with optional
|
||||
* 90° rotation. This is the case "one large image plus eight passport
|
||||
* photos": the small ones move into the leftover area next to the large one.
|
||||
*
|
||||
* The gap is carried along as an "inflated" rectangle (image + gap), which is
|
||||
* why the spacing is right everywhere, including at the edge.
|
||||
*/
|
||||
export function layout(specs: PlaceSpec[], sheet: SheetSpec): LayoutResult {
|
||||
const gap = effectiveGap(sheet);
|
||||
const bleed = sheet.bleedMm || 0;
|
||||
const inset = (sheet.marginMm || 0) + bleed;
|
||||
const availW = sheet.wMm - 2 * inset;
|
||||
const availH = sheet.hMm - 2 * inset;
|
||||
|
||||
const unplaced: LayoutResult['unplaced'] = [];
|
||||
const units: { specId: string; copy: number; w: number; h: number; rot: boolean }[] = [];
|
||||
|
||||
for (const s of specs) {
|
||||
const n = Math.max(0, Math.floor(s.count || 0));
|
||||
if (!n) continue;
|
||||
const fitsPlain = s.wMm <= availW + EPS && s.hMm <= availH + EPS;
|
||||
const fitsRot = s.allowRotate !== false && s.hMm <= availW + EPS && s.wMm <= availH + EPS;
|
||||
if (!fitsPlain && !fitsRot) {
|
||||
unplaced.push({ specId: s.id, count: n, reason: 'larger than the usable area of the sheet' });
|
||||
continue;
|
||||
}
|
||||
for (let c = 1; c <= n; c++) units.push({ specId: s.id, copy: c, w: s.wMm, h: s.hMm, rot: false });
|
||||
}
|
||||
if (!units.length) return { pages: [], unplaced, perSheet: 0 };
|
||||
|
||||
const uniform = units.every((u) => Math.abs(u.w - units[0].w) < EPS && Math.abs(u.h - units[0].h) < EPS);
|
||||
let pages: Page[];
|
||||
|
||||
if (uniform) {
|
||||
pages = gridPack(units, specs, availW, availH, gap);
|
||||
} else {
|
||||
// Decide the orientation per image size (not per copy) — otherwise identical
|
||||
// images end up lying every which way on the sheet. Try all sensible
|
||||
// combinations and take the best one.
|
||||
const ids = [...new Set(units.map((u) => u.specId))];
|
||||
const rotatable = ids.filter((id) => {
|
||||
const sp = specs.find((s) => s.id === id)!;
|
||||
return sp.allowRotate !== false && Math.abs(sp.wMm - sp.hMm) > EPS;
|
||||
});
|
||||
const tooBig = ids.length > 5 || units.length > 150;
|
||||
// With very many sizes/pieces, do not try every combination — but still give
|
||||
// every size an orientation in which it fits at all.
|
||||
const variants: Record<string, boolean>[] = tooBig
|
||||
? [Object.fromEntries(ids.map((id) => {
|
||||
const sp = specs.find((x) => x.id === id)!;
|
||||
const plainFits = sp.wMm <= availW + EPS && sp.hMm <= availH + EPS;
|
||||
return [id, !plainFits && sp.allowRotate !== false];
|
||||
}))]
|
||||
: combos(rotatable).map((set) => Object.fromEntries(ids.map((id) => [id, set.has(id)])));
|
||||
|
||||
let best: Page[] | null = null, bestScore = -Infinity;
|
||||
for (const [vi, rotMap] of variants.entries()) {
|
||||
const oriented = units.map((u) => {
|
||||
const r = !!rotMap[u.specId];
|
||||
const sp = specs.find((s) => s.id === u.specId)!;
|
||||
// Only allow orientations that fit onto the sheet at all.
|
||||
const w = r ? sp.hMm : sp.wMm, h = r ? sp.wMm : sp.hMm;
|
||||
return { ...u, w, h, rot: r };
|
||||
});
|
||||
if (oriented.some((u) => u.w > availW + EPS || u.h > availH + EPS)) continue;
|
||||
const cand = maxRectsPack(oriented, availW, availH, gap);
|
||||
const placed = cand.reduce((n, p) => n + p.placements.length, 0);
|
||||
if (placed < units.length) continue;
|
||||
// Few sheets counts for most, then a full first page, then "as little
|
||||
// rotated as possible" (the orientation the user asked for is more likely
|
||||
// to survive).
|
||||
const score = -cand.length * 1000 + (cand[0]?.placements.length || 0) - 0.01 * vi
|
||||
- 0.1 * Object.values(rotMap).filter(Boolean).length;
|
||||
if (score > bestScore) { bestScore = score; best = cand; }
|
||||
}
|
||||
pages = best || maxRectsPack(units.map((u) => ({ ...u, rot: false })), availW, availH, gap);
|
||||
// Count the missing pieces per image size — do not blame them all on the first one.
|
||||
const placedKeys = new Set(pages.flatMap((pg) => pg.placements.map((pl) => `${pl.specId}#${pl.copy}`)));
|
||||
const missing = new Map<string, number>();
|
||||
for (const u of units) if (!placedKeys.has(`${u.specId}#${u.copy}`)) missing.set(u.specId, (missing.get(u.specId) || 0) + 1);
|
||||
for (const [id, n] of missing) unplaced.push({ specId: id, count: n, reason: 'no room on the sheet' });
|
||||
}
|
||||
|
||||
// Align on the sheet: centre of the block, or top left corner.
|
||||
for (const pg of pages) {
|
||||
let offX = inset, offY = inset;
|
||||
if (sheet.center !== false && pg.placements.length) {
|
||||
const minX = Math.min(...pg.placements.map((p) => p.x));
|
||||
const maxX = Math.max(...pg.placements.map((p) => p.x + p.w));
|
||||
const minY = Math.min(...pg.placements.map((p) => p.y));
|
||||
const maxY = Math.max(...pg.placements.map((p) => p.y + p.h));
|
||||
offX = inset + (availW - (maxX - minX)) / 2 - minX;
|
||||
offY = inset + (availH - (maxY - minY)) / 2 - minY;
|
||||
}
|
||||
for (const p of pg.placements) { p.x = round(p.x + offX); p.y = round(p.y + offY); }
|
||||
}
|
||||
|
||||
return { pages, unplaced, perSheet: pages[0]?.placements.length || 0 };
|
||||
}
|
||||
|
||||
/** Images of equal size: a clean grid, row by row. */
|
||||
function gridPack(units: { specId: string; copy: number; w: number; h: number }[], specs: PlaceSpec[],
|
||||
availW: number, availH: number, gap: number): Page[] {
|
||||
const spec = specs.find((s) => s.id === units[0].specId);
|
||||
let { w, h } = units[0];
|
||||
let rot = false;
|
||||
const fit = (a: number, b: number) => ({
|
||||
cols: Math.max(0, Math.floor((availW + gap + EPS) / (a + gap))),
|
||||
rows: Math.max(0, Math.floor((availH + gap + EPS) / (b + gap))),
|
||||
});
|
||||
// The orientation chosen by the user takes precedence. It is rotated only if
|
||||
// that actually lowers the number of sheets (or if nothing fits unrotated).
|
||||
let best = fit(w, h);
|
||||
if (spec?.allowRotate !== false && Math.abs(w - h) > EPS) {
|
||||
const alt = fit(h, w);
|
||||
const plainN = best.cols * best.rows, altN = alt.cols * alt.rows;
|
||||
const pages = (n: number) => (n > 0 ? Math.ceil(units.length / n) : Infinity);
|
||||
if (pages(altN) < pages(plainN)) { best = alt; [w, h] = [h, w]; rot = true; }
|
||||
}
|
||||
const perPage = best.cols * best.rows;
|
||||
if (!perPage) return [];
|
||||
|
||||
const pages: Page[] = [];
|
||||
for (let i = 0; i < units.length; i += perPage) {
|
||||
const chunk = units.slice(i, i + perPage);
|
||||
const placements: Placement[] = chunk.map((u, k) => {
|
||||
const c = k % best.cols, r = Math.floor(k / best.cols);
|
||||
return { specId: u.specId, copy: u.copy, x: round(c * (w + gap)), y: round(r * (h + gap)), w, h, rotated: rot };
|
||||
});
|
||||
pages.push({ placements });
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
interface FreeRect { x: number; y: number; w: number; h: number }
|
||||
|
||||
/** Mixed sizes: MaxRects with best-short-side-fit, orientation already decided. */
|
||||
function maxRectsPack(units: { specId: string; copy: number; w: number; h: number; rot: boolean }[],
|
||||
availW: number, availH: number, gap: number): Page[] {
|
||||
// Large ones first — otherwise the small pieces block the good areas.
|
||||
const todo = [...units].sort((a, b) =>
|
||||
(b.w * b.h) - (a.w * a.h) || Math.max(b.w, b.h) - Math.max(a.w, a.h) ||
|
||||
a.specId.localeCompare(b.specId) || a.copy - b.copy);
|
||||
|
||||
const W = availW + gap, H = availH + gap; // inflated area
|
||||
const pages: Page[] = [];
|
||||
// Emergency brake against degenerate input (many sizes × many pieces): the
|
||||
// packer runs synchronously in the server process and must not block it.
|
||||
let steps = 0;
|
||||
const MAX_STEPS = 2_000_000;
|
||||
|
||||
while (todo.length) {
|
||||
let free: FreeRect[] = [{ x: 0, y: 0, w: W, h: H }];
|
||||
const placements: Placement[] = [];
|
||||
|
||||
for (;;) {
|
||||
let bestIdx = -1, bestScore = Infinity, bestAlt = Infinity;
|
||||
let bestRect: FreeRect | null = null;
|
||||
|
||||
for (let i = 0; i < todo.length; i++) {
|
||||
const u = todo[i];
|
||||
const cw = u.w + gap, ch = u.h + gap;
|
||||
if ((steps += free.length) > MAX_STEPS) break;
|
||||
for (const r of free) {
|
||||
if (cw > r.w + EPS || ch > r.h + EPS) continue;
|
||||
const short = Math.min(r.w - cw, r.h - ch);
|
||||
// On a tie, take the area furthest to the top/left — that gives a
|
||||
// calm, readable sheet instead of scattered subjects.
|
||||
const pos = r.y * 10000 + r.x;
|
||||
if (short < bestScore - EPS || (Math.abs(short - bestScore) < EPS && pos < bestAlt - EPS)) {
|
||||
bestScore = short; bestAlt = pos; bestIdx = i; bestRect = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestIdx < 0 || !bestRect || steps > MAX_STEPS) break;
|
||||
|
||||
const u = todo.splice(bestIdx, 1)[0];
|
||||
placements.push({ specId: u.specId, copy: u.copy, x: round(bestRect.x), y: round(bestRect.y), w: u.w, h: u.h, rotated: u.rot });
|
||||
free = splitFree(free, { x: bestRect.x, y: bestRect.y, w: u.w + gap, h: u.h + gap });
|
||||
}
|
||||
|
||||
if (!placements.length) break; // nothing placeable → stop
|
||||
placements.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
pages.push({ placements });
|
||||
if (pages.length > 200 || steps > MAX_STEPS) break; // emergency brake
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
/** All subsets (orientation combinations), stably sorted. */
|
||||
function combos(ids: string[]): Set<string>[] {
|
||||
const out: Set<string>[] = [];
|
||||
const n = Math.min(ids.length, 8);
|
||||
for (let m = 0; m < (1 << n); m++) {
|
||||
const s = new Set<string>();
|
||||
for (let i = 0; i < n; i++) if (m & (1 << i)) s.add(ids[i]);
|
||||
out.push(s);
|
||||
}
|
||||
// First "nothing rotated", then ascending by number of rotations.
|
||||
return out.sort((a, b) => a.size - b.size);
|
||||
}
|
||||
|
||||
function splitFree(free: FreeRect[], used: FreeRect): FreeRect[] {
|
||||
const out: FreeRect[] = [];
|
||||
for (const r of free) {
|
||||
const overlap = used.x < r.x + r.w - EPS && used.x + used.w > r.x + EPS
|
||||
&& used.y < r.y + r.h - EPS && used.y + used.h > r.y + EPS;
|
||||
if (!overlap) { out.push(r); continue; }
|
||||
if (used.x > r.x + EPS) out.push({ x: r.x, y: r.y, w: used.x - r.x, h: r.h });
|
||||
if (used.x + used.w < r.x + r.w - EPS) out.push({ x: used.x + used.w, y: r.y, w: r.x + r.w - (used.x + used.w), h: r.h });
|
||||
if (used.y > r.y + EPS) out.push({ x: r.x, y: r.y, w: r.w, h: used.y - r.y });
|
||||
if (used.y + used.h < r.y + r.h - EPS) out.push({ x: r.x, y: used.y + used.h, w: r.w, h: r.y + r.h - (used.y + used.h) });
|
||||
}
|
||||
// Throw away rectangles contained in others (otherwise the list grows without bound).
|
||||
return out.filter((a, i) => a.w > EPS && a.h > EPS && !out.some((b, j) =>
|
||||
j !== i && b.x <= a.x + EPS && b.y <= a.y + EPS &&
|
||||
b.x + b.w >= a.x + a.w - EPS && b.y + b.h >= a.y + a.h - EPS &&
|
||||
(b.w > a.w + EPS || b.h > a.h + EPS || j < i)));
|
||||
}
|
||||
|
||||
/** How many trim sizes fit onto one sheet at most? (A figure for the UI.) */
|
||||
export function capacity(spec: Omit<PlaceSpec, 'count'>, sheet: SheetSpec): number {
|
||||
// Calculate directly instead of packing — otherwise the trial run caps the answer.
|
||||
const gap = effectiveGap(sheet);
|
||||
const inset = (sheet.marginMm || 0) + (sheet.bleedMm || 0);
|
||||
const availW = sheet.wMm - 2 * inset, availH = sheet.hMm - 2 * inset;
|
||||
const grid = (w: number, h: number) =>
|
||||
Math.max(0, Math.floor((availW + gap + EPS) / (w + gap))) *
|
||||
Math.max(0, Math.floor((availH + gap + EPS) / (h + gap)));
|
||||
const plain = grid(spec.wMm, spec.hMm);
|
||||
const rot = spec.allowRotate !== false ? grid(spec.hMm, spec.wMm) : 0;
|
||||
return Math.max(plain, rot);
|
||||
}
|
||||
|
||||
export interface MarkOptions {
|
||||
mode: MarkMode;
|
||||
lengthMm?: number; // length of one mark (standard: 4–10 mm)
|
||||
offsetMm?: number; // distance outwards from the trim size
|
||||
bleedMm?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop marks as line segments.
|
||||
* - `corner`: four pairs of corner marks per image, outside the trim size (the
|
||||
* Adobe / print-shop convention).
|
||||
* - `grid`: continuous lines across the whole sheet at every cut edge — for the
|
||||
* cutting rule or the guillotine, when there are many identical
|
||||
* images on the sheet.
|
||||
*/
|
||||
export function cutMarks(page: Page, sheet: SheetSpec, opt: MarkOptions): Line[] {
|
||||
if (opt.mode === 'none') return [];
|
||||
const len = opt.lengthMm ?? 4;
|
||||
// The offset has to clear the printed bleed — otherwise the mark lands on the
|
||||
// subject. A requested value that is too small is therefore raised.
|
||||
const off = Math.max(opt.offsetMm ?? 0, (opt.bleedMm ?? 0) + 1, 2);
|
||||
const lines: Line[] = [];
|
||||
|
||||
if (opt.mode === 'corner') {
|
||||
for (const p of page.placements) {
|
||||
const L = p.x, R = p.x + p.w, T = p.y, B = p.y + p.h;
|
||||
// per corner: one horizontal and one vertical mark, pointing outwards
|
||||
for (const [x, y, sx, sy] of [[L, T, -1, -1], [R, T, 1, -1], [L, B, -1, 1], [R, B, 1, 1]] as const) {
|
||||
lines.push({ x1: x + sx * off, y1: y, x2: x + sx * (off + len), y2: y });
|
||||
lines.push({ x1: x, y1: y + sy * off, x2: x, y2: y + sy * (off + len) });
|
||||
}
|
||||
}
|
||||
return clip(lines, sheet);
|
||||
}
|
||||
|
||||
// grid: draw the cut edges across the whole sheet — but only where no other
|
||||
// image is in the way. Otherwise the line would run right through the
|
||||
// neighbouring subject.
|
||||
const xs = uniq(page.placements.flatMap((p) => [p.x, p.x + p.w]));
|
||||
const ys = uniq(page.placements.flatMap((p) => [p.y, p.y + p.h]));
|
||||
for (const x of xs) {
|
||||
const blocked = page.placements
|
||||
.filter((p) => p.x + EPS < x && x < p.x + p.w - EPS)
|
||||
.map((p) => [p.y, p.y + p.h] as [number, number]);
|
||||
for (const [a, b] of complement(blocked, 0, sheet.hMm)) lines.push({ x1: x, y1: a, x2: x, y2: b });
|
||||
}
|
||||
for (const y of ys) {
|
||||
const blocked = page.placements
|
||||
.filter((p) => p.y + EPS < y && y < p.y + p.h - EPS)
|
||||
.map((p) => [p.x, p.x + p.w] as [number, number]);
|
||||
for (const [a, b] of complement(blocked, 0, sheet.wMm)) lines.push({ x1: a, y1: y, x2: b, y2: y });
|
||||
}
|
||||
return lines.filter((l) => Math.abs(l.x2 - l.x1) > 0.2 || Math.abs(l.y2 - l.y1) > 0.2);
|
||||
}
|
||||
|
||||
const uniq = (ns: number[]) => [...new Set(ns.map(round))].sort((a, b) => a - b);
|
||||
|
||||
/** Free stretches in [from,to] after subtracting the occupied intervals. */
|
||||
function complement(blocked: [number, number][], from: number, to: number): [number, number][] {
|
||||
const sorted = [...blocked].sort((a, b) => a[0] - b[0]);
|
||||
const out: [number, number][] = [];
|
||||
let cur = from;
|
||||
for (const [a, b] of sorted) {
|
||||
if (a > cur) out.push([cur, Math.min(a, to)]);
|
||||
cur = Math.max(cur, b);
|
||||
if (cur >= to) break;
|
||||
}
|
||||
if (cur < to) out.push([cur, to]);
|
||||
return out.filter(([a, b]) => b - a > 0.2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smallest gap at which the chosen marks still make sense:
|
||||
* the corner marks of two neighbours each need (offset + length) of room, and
|
||||
* bleeds must not overlap.
|
||||
*/
|
||||
export function recommendedGap(mode: MarkMode, bleedMm = 0, lengthMm = 4, offsetMm?: number): number {
|
||||
const off = offsetMm ?? Math.max(2, bleedMm + 1);
|
||||
const base = 2 * bleedMm;
|
||||
if (mode === 'corner') return Math.max(base, 2 * (off + lengthMm));
|
||||
if (mode === 'grid') return Math.max(base, 0);
|
||||
return base;
|
||||
}
|
||||
|
||||
const round = (n: number) => Math.round(n * 1000) / 1000;
|
||||
|
||||
/** Cut back, or drop, marks that would reach beyond the edge of the sheet. */
|
||||
function clip(lines: Line[], sheet: SheetSpec): Line[] {
|
||||
const cx = (v: number) => Math.min(sheet.wMm, Math.max(0, v));
|
||||
const cy = (v: number) => Math.min(sheet.hMm, Math.max(0, v));
|
||||
return lines
|
||||
.map((l) => ({ x1: cx(l.x1), y1: cy(l.y1), x2: cx(l.x2), y2: cy(l.y2) }))
|
||||
.filter((l) => Math.abs(l.x2 - l.x1) > 0.2 || Math.abs(l.y2 - l.y1) > 0.2);
|
||||
}
|
||||
|
||||
/** Is the source resolution enough for the trim size at the wanted dpi? */
|
||||
export function dpiCheck(srcPx: number, mm: number, wantDpi: number): { dpi: number; ok: boolean } {
|
||||
const dpi = mm > 0 ? (srcPx / (mm / 25.4)) : 0;
|
||||
return { dpi: Math.round(dpi), ok: dpi >= wantDpi * 0.9 };
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Oversize images: what to do when the trim size is larger than the sheet?
|
||||
// 20 × 30 cm on A4 photo paper is the everyday case — exactly 3 mm are missing.
|
||||
// Pure mathematics, no dependencies (like printlayout.ts), so that browser and
|
||||
// server use the same calculation.
|
||||
import type { Placement, SheetSpec } from './printlayout';
|
||||
|
||||
const EPS = 1e-6;
|
||||
const round = (n: number) => Math.round(n * 1000) / 1000;
|
||||
|
||||
export type Oversize =
|
||||
| 'fit' // do nothing, report it as "does not fit" (default)
|
||||
| 'overflow' // place it centred on the sheet, the oversize is cut off in printing
|
||||
| 'tile'; // spread it over several sheets (poster print, to be glued together)
|
||||
|
||||
/** Does the trim size fit onto the usable area of the sheet — rotated if need be? */
|
||||
export function fitsOnSheet(wMm: number, hMm: number, sheet: SheetSpec, allowRotate = true): boolean {
|
||||
const inset = (sheet.marginMm || 0) + (sheet.bleedMm || 0);
|
||||
const availW = sheet.wMm - 2 * inset, availH = sheet.hMm - 2 * inset;
|
||||
const plain = wMm <= availW + EPS && hMm <= availH + EPS;
|
||||
const rot = allowRotate && hMm <= availW + EPS && wMm <= availH + EPS;
|
||||
return plain || rot;
|
||||
}
|
||||
|
||||
export interface OverflowInfo {
|
||||
/** Placement on the sheet — may be negative, in which case the image reaches beyond. */
|
||||
placement: Placement;
|
||||
rotated: boolean;
|
||||
/** How much is lost per side (mm), worked out after rotating. */
|
||||
lostLeft: number; lostRight: number; lostTop: number; lostBottom: number;
|
||||
lostMm: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Place an image that is too large centred on the **whole sheet** — deliberately
|
||||
* not on the usable area, because otherwise you give away the margin that a
|
||||
* borderless printer can very well print on. Whatever reaches beyond the sheet is
|
||||
* missing afterwards; how much, is in `lost*`.
|
||||
*/
|
||||
export function overflowPlacement(
|
||||
specId: string, copy: number, wMm: number, hMm: number, sheet: SheetSpec, allowRotate = true,
|
||||
): OverflowInfo {
|
||||
const loss = (w: number, h: number) => Math.max(0, w - sheet.wMm) + Math.max(0, h - sheet.hMm);
|
||||
const rotated = !!allowRotate && loss(hMm, wMm) < loss(wMm, hMm) - EPS;
|
||||
const w = rotated ? hMm : wMm, h = rotated ? wMm : hMm;
|
||||
|
||||
const x = round((sheet.wMm - w) / 2), y = round((sheet.hMm - h) / 2);
|
||||
const lostLeft = round(Math.max(0, -x)), lostTop = round(Math.max(0, -y));
|
||||
const lostRight = round(Math.max(0, x + w - sheet.wMm));
|
||||
const lostBottom = round(Math.max(0, y + h - sheet.hMm));
|
||||
return {
|
||||
placement: { specId, copy, x, y, w, h, rotated },
|
||||
rotated, lostLeft, lostRight, lostTop, lostBottom,
|
||||
lostMm: round(lostLeft + lostRight + lostTop + lostBottom),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In a poster print every sheet carries "Sheet 2/4 · row 1, column 2" — that is
|
||||
* the assembly instruction and is therefore always printed (8 pt from 3 mm below
|
||||
* the top edge, needs about 6 mm). If the paper margin is tighter than that, the
|
||||
* tile has to move down accordingly; so that it does not stick out at the bottom,
|
||||
* the sheet is calculated correspondingly lower. Browser and server use the same
|
||||
* function, otherwise the preview would show a different number of sheets than
|
||||
* the finished PDF.
|
||||
*/
|
||||
export const NOTE_MM = 6;
|
||||
export function noteInset(sheet: SheetSpec): number {
|
||||
return Math.max(0, NOTE_MM - (sheet.marginMm || 0));
|
||||
}
|
||||
export function tileSheet(sheet: SheetSpec): SheetSpec {
|
||||
const n = noteInset(sheet);
|
||||
return n ? { ...sheet, hMm: sheet.hMm - n } : sheet;
|
||||
}
|
||||
|
||||
export interface Tile {
|
||||
col: number; row: number; // 0-based
|
||||
xMm: number; yMm: number; // section of the target image, from the top left corner
|
||||
wMm: number; hMm: number;
|
||||
glueRight: boolean; glueBottom: boolean; // a neighbouring tile joins on there
|
||||
}
|
||||
|
||||
export interface TilePlan { cols: number; rows: number; tiles: Tile[]; overlapMm: number }
|
||||
|
||||
/**
|
||||
* Poster print: spread one image over several sheets.
|
||||
* The tiles overlap by `overlapMm` — that strip is the glue flap. It is printed
|
||||
* on both neighbouring sheets; when assembling, you lay the one sheet onto the
|
||||
* other along the dashed line.
|
||||
*/
|
||||
export function tilePlan(wMm: number, hMm: number, sheet: SheetSpec, overlapMm = 10): TilePlan {
|
||||
const inset = sheet.marginMm || 0;
|
||||
const availW = Math.max(10, sheet.wMm - 2 * inset);
|
||||
const availH = Math.max(10, sheet.hMm - 2 * inset);
|
||||
const o = Math.max(0, Math.min(overlapMm, Math.min(availW, availH) / 3));
|
||||
|
||||
const stepX = Math.max(1, availW - o), stepY = Math.max(1, availH - o);
|
||||
const cols = Math.max(1, Math.ceil((wMm - o) / stepX));
|
||||
const rows = Math.max(1, Math.ceil((hMm - o) / stepY));
|
||||
|
||||
const tiles: Tile[] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x = c * stepX, y = r * stepY;
|
||||
const w = Math.min(availW, wMm - x), h = Math.min(availH, hMm - y);
|
||||
if (w <= 0.5 || h <= 0.5) continue;
|
||||
tiles.push({
|
||||
col: c, row: r, xMm: round(x), yMm: round(y), wMm: round(w), hMm: round(h),
|
||||
glueRight: c < cols - 1, glueBottom: r < rows - 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { cols, rows, tiles, overlapMm: round(o) };
|
||||
}
|
||||
|
||||
/** The section of one tile, nested inside the user's crop. */
|
||||
export function tileCrop(
|
||||
crop: { x: number; y: number; w: number; h: number },
|
||||
tile: Tile, wMm: number, hMm: number,
|
||||
): { x: number; y: number; w: number; h: number } {
|
||||
return {
|
||||
x: crop.x + (tile.xMm / wMm) * crop.w,
|
||||
y: crop.y + (tile.yMm / hMm) * crop.h,
|
||||
w: (tile.wMm / wMm) * crop.w,
|
||||
h: (tile.hMm / hMm) * crop.h,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* The sheet playground.
|
||||
*
|
||||
* This is not a mock-up of the print studio: it imports `engine/printlayout.ts`
|
||||
* and `engine/paper.ts` verbatim from the Klarbild repository and runs them in
|
||||
* your browser. The numbers you see here are the numbers the application
|
||||
* computes, and the PDF you download is laid out by the same arithmetic — it is
|
||||
* drawn with pdf-lib on this page rather than with sharp on a server, which is
|
||||
* why it carries outlines instead of photographs.
|
||||
*
|
||||
* Nothing is uploaded and there is no server behind this page.
|
||||
*/
|
||||
|
||||
import { layout, cutMarks, effectiveGap, recommendedGap, type PlaceSpec, type SheetSpec, type MarkMode } from '../engine/printlayout';
|
||||
import { PAPERS, PHOTO_SIZES, labelMm } from '../engine/paper';
|
||||
|
||||
const MM_PT = 72 / 25.4;
|
||||
|
||||
type State = {
|
||||
paperId: string;
|
||||
landscape: boolean;
|
||||
sizeId: string;
|
||||
count: number;
|
||||
marks: MarkMode;
|
||||
bleedMm: number;
|
||||
marginMm: number;
|
||||
allowRotate: boolean;
|
||||
};
|
||||
|
||||
const state: State = {
|
||||
paperId: 'A4',
|
||||
landscape: false,
|
||||
sizeId: 'P35x45',
|
||||
count: 8,
|
||||
marks: 'corner',
|
||||
bleedMm: 0,
|
||||
marginMm: 5,
|
||||
allowRotate: true,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ helpers */
|
||||
|
||||
function paper() {
|
||||
const p = PAPERS.find((x) => x.id === state.paperId) ?? PAPERS[2];
|
||||
return state.landscape ? { ...p, w: p.h, h: p.w } : p;
|
||||
}
|
||||
|
||||
function size() {
|
||||
return PHOTO_SIZES.find((x) => x.id === state.sizeId) ?? PHOTO_SIZES[0];
|
||||
}
|
||||
|
||||
function sheetSpec(): SheetSpec {
|
||||
const p = paper();
|
||||
return {
|
||||
wMm: p.w,
|
||||
hMm: p.h,
|
||||
marginMm: state.marginMm,
|
||||
gapMm: recommendedGap(state.marks, state.bleedMm),
|
||||
bleedMm: state.bleedMm,
|
||||
center: true,
|
||||
};
|
||||
}
|
||||
|
||||
function placeSpec(): PlaceSpec {
|
||||
const s = size();
|
||||
return { id: 'cell', wMm: s.w, hMm: s.h, count: state.count, allowRotate: state.allowRotate };
|
||||
}
|
||||
|
||||
function compute() {
|
||||
const sheet = sheetSpec();
|
||||
const result = layout([placeSpec()], sheet);
|
||||
return { sheet, result };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- render */
|
||||
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
function el(name: string, attrs: Record<string, string | number>) {
|
||||
const n = document.createElementNS(NS, name);
|
||||
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, String(v));
|
||||
return n;
|
||||
}
|
||||
|
||||
function drawSheet(host: SVGSVGElement, pageIndex = 0) {
|
||||
const { sheet, result } = compute();
|
||||
const page = result.pages[pageIndex];
|
||||
host.setAttribute('viewBox', `0 0 ${sheet.wMm} ${sheet.hMm}`);
|
||||
host.replaceChildren();
|
||||
|
||||
host.appendChild(el('rect', { x: 0, y: 0, width: sheet.wMm, height: sheet.hMm, fill: '#fff' }));
|
||||
// The printer's non-printable margin, as a hairline so you can see what is lost.
|
||||
host.appendChild(el('rect', {
|
||||
x: sheet.marginMm, y: sheet.marginMm,
|
||||
width: Math.max(0, sheet.wMm - 2 * sheet.marginMm),
|
||||
height: Math.max(0, sheet.hMm - 2 * sheet.marginMm),
|
||||
fill: 'none', stroke: 'var(--line)', 'stroke-width': 0.2, 'stroke-dasharray': '2 2',
|
||||
}));
|
||||
|
||||
if (!page) return { sheet, result };
|
||||
|
||||
page.placements.forEach((pl, i) => {
|
||||
const g = el('g', { class: 'cell', style: `--i:${i}` });
|
||||
if (sheet.bleedMm) {
|
||||
g.appendChild(el('rect', {
|
||||
x: pl.x - sheet.bleedMm, y: pl.y - sheet.bleedMm,
|
||||
width: pl.w + 2 * sheet.bleedMm, height: pl.h + 2 * sheet.bleedMm,
|
||||
fill: 'var(--bleed)',
|
||||
}));
|
||||
}
|
||||
g.appendChild(el('rect', { x: pl.x, y: pl.y, width: pl.w, height: pl.h, fill: 'var(--cellfill)' }));
|
||||
// A stand-in for the photograph: a horizon and a disc, so the cell reads as
|
||||
// a picture without pretending to be one.
|
||||
g.appendChild(el('circle', {
|
||||
cx: pl.x + pl.w / 2, cy: pl.y + pl.h * 0.38,
|
||||
r: Math.min(pl.w, pl.h) * 0.19, fill: 'var(--cellink)',
|
||||
}));
|
||||
g.appendChild(el('path', {
|
||||
d: `M${pl.x} ${pl.y + pl.h} C ${pl.x + pl.w * 0.12} ${pl.y + pl.h * 0.62}, ${pl.x + pl.w * 0.88} ${pl.y + pl.h * 0.62}, ${pl.x + pl.w} ${pl.y + pl.h} Z`,
|
||||
fill: 'var(--cellink)',
|
||||
}));
|
||||
host.appendChild(g);
|
||||
});
|
||||
|
||||
for (const line of cutMarks(page, sheet, { mode: state.marks, lengthMm: 4, offsetMm: 3 })) {
|
||||
host.appendChild(el('line', {
|
||||
x1: line.x1, y1: line.y1, x2: line.x2, y2: line.y2,
|
||||
stroke: 'var(--mark)', 'stroke-width': 0.25,
|
||||
}));
|
||||
}
|
||||
return { sheet, result };
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- PDF */
|
||||
|
||||
async function buildPdf(): Promise<Blob> {
|
||||
const { PDFDocument, rgb } = await import('pdf-lib');
|
||||
const { sheet, result } = compute();
|
||||
const doc = await PDFDocument.create();
|
||||
doc.setTitle('Klarbild sheet (demo)');
|
||||
doc.setCreator('Klarbild demo — klarbild-demo.heidrich-digital.de');
|
||||
|
||||
for (const page of result.pages) {
|
||||
const pg = doc.addPage([sheet.wMm * MM_PT, sheet.hMm * MM_PT]);
|
||||
// PDF counts from the bottom left; the layout counts from the top left.
|
||||
const flip = (yMm: number, hMm: number) => (sheet.hMm - yMm - hMm) * MM_PT;
|
||||
|
||||
for (const pl of page.placements) {
|
||||
if (sheet.bleedMm) {
|
||||
pg.drawRectangle({
|
||||
x: (pl.x - sheet.bleedMm) * MM_PT,
|
||||
y: flip(pl.y - sheet.bleedMm, pl.h + 2 * sheet.bleedMm),
|
||||
width: (pl.w + 2 * sheet.bleedMm) * MM_PT,
|
||||
height: (pl.h + 2 * sheet.bleedMm) * MM_PT,
|
||||
color: rgb(0.925, 0.933, 0.925),
|
||||
});
|
||||
}
|
||||
pg.drawRectangle({
|
||||
x: pl.x * MM_PT, y: flip(pl.y, pl.h),
|
||||
width: pl.w * MM_PT, height: pl.h * MM_PT,
|
||||
borderColor: rgb(0.72, 0.72, 0.70), borderWidth: 0.4,
|
||||
});
|
||||
}
|
||||
for (const line of cutMarks(page, sheet, { mode: state.marks, lengthMm: 4, offsetMm: 3 })) {
|
||||
pg.drawLine({
|
||||
start: { x: line.x1 * MM_PT, y: (sheet.hMm - line.y1) * MM_PT },
|
||||
end: { x: line.x2 * MM_PT, y: (sheet.hMm - line.y2) * MM_PT },
|
||||
thickness: 0.25, color: rgb(0.4, 0.4, 0.38),
|
||||
});
|
||||
}
|
||||
}
|
||||
return new Blob([await doc.save()], { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- wire */
|
||||
|
||||
export function mountPlayground(root: HTMLElement) {
|
||||
const svg = root.querySelector<SVGSVGElement>('#sheet')!;
|
||||
const out = {
|
||||
fits: root.querySelector<HTMLElement>('[data-out=fits]')!,
|
||||
pages: root.querySelector<HTMLElement>('[data-out=pages]')!,
|
||||
cell: root.querySelector<HTMLElement>('[data-out=cell]')!,
|
||||
paper: root.querySelector<HTMLElement>('[data-out=paper]')!,
|
||||
gap: root.querySelector<HTMLElement>('[data-out=gap]')!,
|
||||
note: root.querySelector<HTMLElement>('[data-out=note]')!,
|
||||
};
|
||||
|
||||
// Paper and size menus come from the same tables the application uses.
|
||||
const paperSel = root.querySelector<HTMLSelectElement>('#paper')!;
|
||||
for (const group of [...new Set(PAPERS.map((p) => p.group))]) {
|
||||
const og = document.createElement('optgroup');
|
||||
og.label = group;
|
||||
for (const p of PAPERS.filter((x) => x.group === group)) {
|
||||
const o = document.createElement('option');
|
||||
o.value = p.id;
|
||||
o.textContent = `${p.label} · ${labelMm(p.w, p.h)}`;
|
||||
og.appendChild(o);
|
||||
}
|
||||
paperSel.appendChild(og);
|
||||
}
|
||||
paperSel.value = state.paperId;
|
||||
|
||||
const sizeSel = root.querySelector<HTMLSelectElement>('#size')!;
|
||||
for (const group of [...new Set(PHOTO_SIZES.map((s) => s.group))]) {
|
||||
const og = document.createElement('optgroup');
|
||||
og.label = group;
|
||||
for (const s of PHOTO_SIZES.filter((x) => x.group === group)) {
|
||||
const o = document.createElement('option');
|
||||
o.value = s.id;
|
||||
o.textContent = s.label;
|
||||
og.appendChild(o);
|
||||
}
|
||||
sizeSel.appendChild(og);
|
||||
}
|
||||
sizeSel.value = state.sizeId;
|
||||
|
||||
function refresh() {
|
||||
const { sheet, result } = drawSheet(svg);
|
||||
const s = size();
|
||||
const wanted = state.count;
|
||||
const placed = result.pages.reduce((n, p) => n + p.placements.length, 0);
|
||||
|
||||
out.fits.textContent = String(placed);
|
||||
out.pages.textContent = String(result.pages.length);
|
||||
out.cell.textContent = labelMm(s.w, s.h);
|
||||
out.paper.textContent = labelMm(sheet.wMm, sheet.hMm);
|
||||
out.gap.textContent = `${effectiveGap(sheet).toFixed(1)} mm`;
|
||||
|
||||
if (placed === 0) {
|
||||
out.note.textContent = `${s.label} does not fit on this sheet at 100 %. In the application this is where you choose overflow or tiling.`;
|
||||
out.note.dataset.tone = 'warn';
|
||||
} else if (placed < wanted) {
|
||||
out.note.textContent = `${placed} of ${wanted} fit on ${result.pages.length} sheet${result.pages.length > 1 ? 's' : ''}.`;
|
||||
out.note.dataset.tone = 'warn';
|
||||
} else {
|
||||
out.note.textContent = `All ${placed} fit${result.pages.length > 1 ? ` across ${result.pages.length} sheets` : ' on one sheet'}. Nothing is scaled.`;
|
||||
out.note.dataset.tone = 'ok';
|
||||
}
|
||||
}
|
||||
|
||||
root.addEventListener('input', (e) => {
|
||||
const t = e.target as HTMLInputElement | HTMLSelectElement;
|
||||
switch (t.id) {
|
||||
case 'paper': state.paperId = t.value; break;
|
||||
case 'size': state.sizeId = t.value; break;
|
||||
case 'count': state.count = Math.max(1, Math.min(200, Number(t.value) || 1)); break;
|
||||
case 'bleed': state.bleedMm = Number(t.value); root.querySelector('[data-val=bleed]')!.textContent = `${state.bleedMm} mm`; break;
|
||||
case 'margin': state.marginMm = Number(t.value); root.querySelector('[data-val=margin]')!.textContent = `${state.marginMm} mm`; break;
|
||||
default: return;
|
||||
}
|
||||
refresh();
|
||||
});
|
||||
|
||||
root.addEventListener('click', (e) => {
|
||||
const b = (e.target as HTMLElement).closest<HTMLButtonElement>('button[data-set]');
|
||||
if (!b) return;
|
||||
const [key, value] = b.dataset.set!.split(':');
|
||||
if (key === 'marks') state.marks = value as MarkMode;
|
||||
if (key === 'orientation') state.landscape = value === 'landscape';
|
||||
if (key === 'rotate') state.allowRotate = !state.allowRotate;
|
||||
for (const sib of root.querySelectorAll<HTMLButtonElement>(`button[data-set^="${key}:"]`)) {
|
||||
sib.setAttribute('aria-pressed', String(sib.dataset.set === b.dataset.set));
|
||||
}
|
||||
if (key === 'rotate') b.setAttribute('aria-pressed', String(state.allowRotate));
|
||||
refresh();
|
||||
});
|
||||
|
||||
const dl = root.querySelector<HTMLButtonElement>('#download')!;
|
||||
dl.addEventListener('click', async () => {
|
||||
const label = dl.textContent;
|
||||
dl.disabled = true;
|
||||
dl.textContent = 'Building the PDF …';
|
||||
try {
|
||||
const blob = await buildPdf();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `klarbild-demo-${state.sizeId}-${state.count}-on-${state.paperId}.pdf`;
|
||||
a.click();
|
||||
// Revoke late: Safari cancels the download if the URL dies too early.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||
} finally {
|
||||
dl.disabled = false;
|
||||
dl.textContent = label;
|
||||
}
|
||||
});
|
||||
|
||||
refresh();
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
---
|
||||
import '@fontsource/space-grotesk/400.css';
|
||||
import '@fontsource/space-grotesk/500.css';
|
||||
import '@fontsource/space-grotesk/700.css';
|
||||
import '@fontsource/space-mono/400.css';
|
||||
import '@fontsource/space-mono/700.css';
|
||||
import '../styles/tokens.css';
|
||||
import '../styles/site.css';
|
||||
|
||||
const GITHUB = 'https://github.com/tillheidrich/klarbild';
|
||||
const GITEA = 'https://git.heidrich-digital.de/till/klarbild-oss';
|
||||
|
||||
const modules = [
|
||||
{ id: 'print', title: 'Print studio', core: true,
|
||||
what: 'Exact physical sizes, sheet layout with crop marks, bleed, tiling, 1:1 PDF.',
|
||||
need: 'Nothing. Always on.' },
|
||||
{ id: 'ai', title: 'Image generation',
|
||||
what: 'Clean up a screenshot, convert a photo, combine several, generate from text.',
|
||||
need: 'An OpenRouter key. Paid per image.' },
|
||||
{ id: 'delivery', title: 'Delivery',
|
||||
what: 'Push finished files to an SFTP or FTPS server, plus extra targets per folder.',
|
||||
need: 'A server you already have.' },
|
||||
{ id: 'mirror', title: 'Backup mirror',
|
||||
what: 'A second copy of every result, filed by month.',
|
||||
need: 'A second SFTP/FTPS target.' },
|
||||
{ id: 'share', title: 'Share links',
|
||||
what: 'Public links that expire, can be revoked, can carry a passphrase, and ZIP up a whole set.',
|
||||
need: 'A publicly reachable address.' },
|
||||
{ id: 'mail', title: 'Email',
|
||||
what: 'Password reset, job notices, sending share links.',
|
||||
need: 'Your own SMTP mailbox.' },
|
||||
{ id: 'telegram', title: 'Telegram bot',
|
||||
what: 'The whole tool from a chat window instead of a browser.',
|
||||
need: 'A bot token from BotFather.' },
|
||||
];
|
||||
|
||||
const facts = [
|
||||
{ h: '86 image sizes', p: 'In ten groups — passport photo, small prints, photo formats, US inches, instant-camera, poster and frame sizes, squares, DIN, cards, plain aspect ratios. Or type your own: 25x35, 13x18, 10x7.5.' },
|
||||
{ h: '19 paper formats', p: 'A6 to A2 including A3+, photo paper from 9 × 13 to 20 × 30 cm, US Letter and Legal — or a custom sheet.' },
|
||||
{ h: 'Crop marks', p: 'Corner marks at 0.25 pt outside the trim, or continuous lines when every cell is the same size. Bleed 0 to 10 mm.' },
|
||||
{ h: 'Oversize, handled', p: 'A 20 × 30 print on A4 is not an error. Fit tells you it will not go; overflow centres it over the whole sheet and quantifies the loss first; tile spreads it over several sheets with a glue flap and a page number.' },
|
||||
{ h: 'Crop or letterbox', p: 'When a picture does not match the target ratio you choose per picture — crop with a zoom-and-pan preview, or a border in a colour you pick. Nothing is ever stretched.' },
|
||||
{ h: 'One engine', p: 'The browser preview and the server-side PDF run the same arithmetic. That file is printlayout.ts, it has no dependencies, and it has its own tests.' },
|
||||
];
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#EAEAE3" />
|
||||
<title>Klarbild — print pictures at the size you actually asked for</title>
|
||||
<meta name="description" content="Self-hosted print studio. Put pictures on paper at exact physical sizes, with crop marks, bleed and tiling, and a PDF page that measures what you asked for. Open source, AGPL-3.0. Image generation and delivery are optional modules." />
|
||||
<link rel="canonical" href="https://klarbild-demo.heidrich-digital.de/" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Klarbild — print pictures at the size you actually asked for" />
|
||||
<meta property="og:description" content="A self-hosted print studio with a sheet layout engine you can try in your browser. Open source, AGPL-3.0." />
|
||||
<meta property="og:image" content="https://klarbild-demo.heidrich-digital.de/shots/print-studio.png" />
|
||||
<meta property="og:url" content="https://klarbild-demo.heidrich-digital.de/" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<div class="shell top-in">
|
||||
<a class="brand" href="/"><span class="regmark"></span>klarbild</a>
|
||||
<nav>
|
||||
<a class="hide-s" href="#try">Try it</a>
|
||||
<a class="hide-s" href="#modules">Modules</a>
|
||||
<a class="hide-s" href="#install">Install</a>
|
||||
<a class="cta" href={GITHUB} rel="noopener">Source</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="hero shell">
|
||||
<div class="hero-grid">
|
||||
<div>
|
||||
<span class="kicker">Self-hosted · AGPL-3.0</span>
|
||||
<h1>Print pictures at the size you <span>actually asked for</span>.</h1>
|
||||
<p class="lead">
|
||||
Your printer driver has a checkbox called “fit to page”. Tick it and a
|
||||
35 × 45 mm passport photo comes out 34 × 44. Untick it and you are guessing.
|
||||
Klarbild lays the sheet out in millimetres and hands you a PDF that measures
|
||||
what it says.
|
||||
</p>
|
||||
<div class="btnrow">
|
||||
<a class="btn primary" href="#try">Lay out a sheet</a>
|
||||
<a class="btn" href={GITHUB} rel="noopener">Read the source</a>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="hero-side">
|
||||
<dl>
|
||||
<dt class="num">210.00</dt><dd>× 297.00 mm on the page when you ask for A4 — measured, not assumed</dd>
|
||||
<dt class="num">0</dt><dd>outbound requests from the print studio. It is arithmetic, sharp and pdf-lib</dd>
|
||||
<dt class="num">124</dt><dd>tests over the layout engine, the naming scheme and the path checks</dd>
|
||||
<dt class="num">1</dt><dd>container and a database for the default install</dd>
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ------------------------------------------------------- playground -->
|
||||
<div class="pg shell" id="try">
|
||||
<div class="pg-shell" id="playground">
|
||||
<div class="pg-head">
|
||||
<h2>Lay out a sheet</h2>
|
||||
<p class="fine">Runs the application's own layout code, here in your browser. Nothing is uploaded.</p>
|
||||
</div>
|
||||
|
||||
<div class="pg-body">
|
||||
<div class="pg-controls">
|
||||
<div class="field">
|
||||
<label for="paper">Paper</label>
|
||||
<select id="paper"></select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Sheet orientation</label>
|
||||
<div class="seg">
|
||||
<button type="button" data-set="orientation:portrait" aria-pressed="true">Portrait</button>
|
||||
<button type="button" data-set="orientation:landscape" aria-pressed="false">Landscape</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="size">Picture size</label>
|
||||
<select id="size"></select>
|
||||
</div>
|
||||
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label for="count">How many</label>
|
||||
<input id="count" type="number" min="1" max="200" value="8" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Cutting aid</label>
|
||||
<div class="seg">
|
||||
<button type="button" data-set="marks:none" aria-pressed="false">None</button>
|
||||
<button type="button" data-set="marks:corner" aria-pressed="true">Corner</button>
|
||||
<button type="button" data-set="marks:grid" aria-pressed="false">Lines</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="slider-head"><label for="bleed">Bleed</label><span data-val="bleed">0 mm</span></div>
|
||||
<input id="bleed" type="range" min="0" max="10" step="1" value="0" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="slider-head"><label for="margin">Printer margin</label><span data-val="margin">5 mm</span></div>
|
||||
<input id="margin" type="range" min="0" max="20" step="1" value="5" />
|
||||
</div>
|
||||
|
||||
<div class="toggle">
|
||||
<button type="button" data-set="rotate:toggle" aria-pressed="true" aria-label="Rotate pictures to save space"></button>
|
||||
<span>Rotate pictures to save space</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pg-stage">
|
||||
<div class="stage-sheet">
|
||||
<svg id="sheet" viewBox="0 0 210 297" role="img" aria-label="Sheet preview"></svg>
|
||||
</div>
|
||||
|
||||
<dl class="readout">
|
||||
<div><dt>Fit</dt><dd data-out="fits">–</dd></div>
|
||||
<div><dt>Sheets</dt><dd data-out="pages">–</dd></div>
|
||||
<div><dt>Cell</dt><dd data-out="cell">–</dd></div>
|
||||
<div><dt>Paper</dt><dd data-out="paper">–</dd></div>
|
||||
<div><dt>Gap</dt><dd data-out="gap">–</dd></div>
|
||||
</dl>
|
||||
|
||||
<p class="note" data-out="note" data-tone="ok"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pg-foot">
|
||||
<button class="btn primary" id="download" type="button">Download this sheet as a PDF</button>
|
||||
<p>
|
||||
The demo draws outlines instead of photographs — the real thing puts your
|
||||
pictures in these boxes at the resolution you choose. Measure the PDF: the page
|
||||
and every box are exactly the millimetres shown above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ---------------------------------------------------------- what -->
|
||||
<section id="what">
|
||||
<div class="shell">
|
||||
<span class="kicker">The print studio</span>
|
||||
<h2>Everything a photo shop's software does, minus the photo shop.</h2>
|
||||
<div class="facts">
|
||||
{facts.map((f) => (
|
||||
<div><h3>{f.h}</h3><p>{f.p}</p></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="shell zig">
|
||||
<div class="prose">
|
||||
<span class="kicker">In the application</span>
|
||||
<h2>Eight passport photos, one sheet, one cut line each.</h2>
|
||||
<p class="lead">
|
||||
Pick the size, say how many, and the sheet fills itself. Equal sizes snap to an
|
||||
exact grid; mixed sizes go through a packer. Every cell carries its measurement
|
||||
as a caption, and the caption gets out of the way when there is no room at that edge.
|
||||
</p>
|
||||
<p>
|
||||
The same picture may appear twice at two different sizes. Layouts you repeat become
|
||||
presets. The last hundred runs are kept as <em>instructions</em> rather than as PDFs —
|
||||
one to three kilobytes each instead of eighty megabytes — so “print that again” costs
|
||||
nothing.
|
||||
</p>
|
||||
</div>
|
||||
<figure>
|
||||
<img src="/shots/print-studio.png" width="2800" height="1800"
|
||||
alt="The Klarbild print studio with eight passport photos laid out on an A4 sheet, each with corner marks" />
|
||||
<figcaption>The print studio. Print-only install — no library, no queue, no model.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="shell zig flip">
|
||||
<div class="prose">
|
||||
<span class="kicker">Two languages</span>
|
||||
<h2>English by default, German all the way through.</h2>
|
||||
<p class="lead">
|
||||
Not a half-translated interface: 1,038 strings, both complete. The English
|
||||
sentence is the translation key, so a missing entry falls back to readable
|
||||
English rather than to <code>print.sheet.title</code>.
|
||||
</p>
|
||||
<p>
|
||||
One environment variable sets the default; visitors switch with the button in the
|
||||
corner and the choice sticks. Adding a third language means copying one JSON file
|
||||
and translating its values.
|
||||
</p>
|
||||
</div>
|
||||
<figure>
|
||||
<img src="/shots/print-de.png" width="2800" height="1800"
|
||||
alt="The same print studio screen rendered in German" />
|
||||
<figcaption>The same screen, <code>?lang=de</code>.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------------- modules -->
|
||||
<section id="modules">
|
||||
<div class="shell">
|
||||
<span class="kicker">Modules</span>
|
||||
<h2>You decide what this install is.</h2>
|
||||
<p class="lead prose">
|
||||
Klarbild is one codebase but not one product. The print studio is the core.
|
||||
Everything that needs an account somewhere else is a module, and every module is
|
||||
off until you switch it on. A module that is off has no navigation entry, no
|
||||
settings card, and its API routes answer 404 rather than 403 — a disabled instance
|
||||
does not advertise what it could have done.
|
||||
</p>
|
||||
|
||||
<div class="mods">
|
||||
{modules.map((m) => (
|
||||
<div class:list={['mod', { core: m.core }]}>
|
||||
<code>{m.id}</code>
|
||||
<p><strong>{m.title}</strong> — {m.what}</p>
|
||||
<span class="need">{m.need}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style="margin-top:28px; max-width:56ch;">
|
||||
<pre><span class="c"># the default: the print studio and nothing else</span>
|
||||
KLARBILD_MODULES=print
|
||||
|
||||
<span class="c"># pick what you want</span>
|
||||
KLARBILD_MODULES=print,ai,share
|
||||
|
||||
<span class="c"># everything</span>
|
||||
KLARBILD_MODULES=all</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------------- install -->
|
||||
<section id="install">
|
||||
<div class="shell split-code">
|
||||
<div class="prose">
|
||||
<span class="kicker">Install</span>
|
||||
<h2>Four lines, then a password in the log.</h2>
|
||||
<p>
|
||||
The default compose file runs the application and a PostgreSQL, with files on a
|
||||
volume. No object store, no API key, no account anywhere. A second compose file
|
||||
adds MinIO and turns every module on, for people who want that.
|
||||
</p>
|
||||
<p>
|
||||
There are no built-in accounts. The first boot creates one administrator — from
|
||||
<code>ADMIN_USERNAME</code> and <code>ADMIN_PASSWORD</code> if you set them, and
|
||||
otherwise with a generated password printed to the log exactly once.
|
||||
</p>
|
||||
<div class="btnrow">
|
||||
<a class="btn primary" href={GITHUB} rel="noopener">GitHub</a>
|
||||
<a class="btn" href={GITEA} rel="noopener">Gitea</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<pre>git clone {GITHUB}.git
|
||||
cd klarbild
|
||||
cp .env.example .env <span class="c"># three secrets, the file says which</span>
|
||||
docker compose up -d
|
||||
|
||||
docker compose logs app <span class="c"># your admin password, printed once</span></pre>
|
||||
<p style="font-size:.84rem; color:var(--ink-soft); margin-top:14px;">
|
||||
Astro 5 on Node, PostgreSQL, sharp and pdf-lib. About 13,600 lines across 116
|
||||
files. Sixteen forward-only migrations that run themselves on boot. Type check,
|
||||
tests and a secret scan run in CI on every push.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="shell prose">
|
||||
<span class="kicker">Why self-hosted</span>
|
||||
<h2>These are passport photos and family pictures.</h2>
|
||||
<p class="lead">
|
||||
The print studio never makes an outbound request at all. With the AI module on, only
|
||||
what you hand the model leaves the building, and the key is yours.
|
||||
</p>
|
||||
<p>
|
||||
Share links are built to be careful rather than convenient: they expire after thirty
|
||||
days by default, can be revoked, can carry a passphrase, and are <code>noindex</code>.
|
||||
Access hangs on the link rather than on the picture, so guessing an image id returns
|
||||
a 404. The view counter records <em>when</em> and <em>with what</em>, never <em>who</em> —
|
||||
the address is hashed with a server secret and the link id, one row per visitor per
|
||||
hour, deleted after ninety days.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="shell foot-in">
|
||||
<a class="brand" href="/"><span class="regmark"></span>klarbild</a>
|
||||
<a href={GITHUB} rel="noopener">GitHub</a>
|
||||
<a href={GITEA} rel="noopener">Gitea</a>
|
||||
<a href={`${GITHUB}/blob/main/LICENSE`} rel="noopener">AGPL-3.0</a>
|
||||
<span class="fine">
|
||||
A demo page. The layout engine is real; the pictures are outlines. Built by
|
||||
<a href="https://heidrich-digital.de" rel="noopener">Heidrich Digital</a>.
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
import { mountPlayground } from '../lib/playground';
|
||||
const root = document.getElementById('playground');
|
||||
if (root) mountPlayground(root);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,204 @@
|
||||
/* Klarbild demo site — the product's own tokens, one accent, warm paper. */
|
||||
|
||||
:root {
|
||||
--measure: 62ch;
|
||||
--gutter: clamp(20px, 5vw, 64px);
|
||||
--wide: 1240px;
|
||||
--bleed: oklch(88% 0.012 150);
|
||||
--cellfill: oklch(93.5% 0.012 150);
|
||||
--cellink: oklch(58% 0.035 158);
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(70rem 40rem at 82% -10%, oklch(45% 0.23 267 / 0.05), transparent 60%),
|
||||
var(--paper);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* Paper grain. Fixed and pointer-transparent so it never repaints on scroll. */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.32;
|
||||
mix-blend-mode: multiply;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.28'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.shell { width: min(100% - var(--gutter) * 2, var(--wide)); margin-inline: auto; }
|
||||
|
||||
/* The top bar is sticky, so anything jumped to needs room above it. */
|
||||
:target, section[id], #try { scroll-margin-top: 78px; }
|
||||
html { scroll-behavior: smooth; }
|
||||
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
|
||||
.prose { max-width: var(--measure); }
|
||||
|
||||
h1, h2, h3 { font-family: var(--font-display); letter-spacing: -0.03em; line-height: 1.02; margin: 0; }
|
||||
h1 { font-size: clamp(2.6rem, 7vw, 5.1rem); font-weight: 700; }
|
||||
h2 { font-size: clamp(1.7rem, 3.6vw, 2.7rem); font-weight: 700; }
|
||||
h3 { font-size: 1.05rem; font-weight: 700; letter-spacing: -0.01em; }
|
||||
p { margin: 0 0 1em; }
|
||||
.lead { font-size: clamp(1.05rem, 1.8vw, 1.28rem); color: var(--ink-soft); line-height: 1.6; }
|
||||
.kicker { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.2em;
|
||||
text-transform: uppercase; color: var(--ink-soft); display: block; margin-bottom: 14px; }
|
||||
.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ------------------------------------------------------------------- header */
|
||||
|
||||
.top {
|
||||
position: sticky; top: 0; z-index: 30;
|
||||
backdrop-filter: saturate(1.3) blur(10px);
|
||||
background: color-mix(in oklab, var(--paper) 82%, transparent);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.top-in { display: flex; align-items: center; gap: 18px; padding: 13px 0;
|
||||
padding-top: calc(13px + env(safe-area-inset-top, 0px)); }
|
||||
.brand { display: inline-flex; align-items: center; gap: 9px; font-family: var(--font-display);
|
||||
font-weight: 700; font-size: 1.14rem; color: var(--ink); text-decoration: none; letter-spacing: -0.02em; }
|
||||
.top nav { margin-left: auto; display: flex; align-items: center; gap: 6px; }
|
||||
.top nav a { color: var(--ink-soft); text-decoration: none; font-size: 0.88rem;
|
||||
padding: 7px 11px; border-radius: 999px; }
|
||||
.top nav a:hover { color: var(--ink); background: var(--card); }
|
||||
.top nav a.cta { color: #fff; background: var(--accent); font-weight: 600; }
|
||||
.top nav a.cta:hover { background: var(--accent-2); color: #fff; }
|
||||
@media (max-width: 720px) { .top nav a.hide-s { display: none; } }
|
||||
|
||||
/* --------------------------------------------------------------------- hero */
|
||||
|
||||
.hero { padding: clamp(48px, 9vw, 108px) 0 clamp(36px, 6vw, 64px); position: relative; z-index: 2; }
|
||||
.hero-grid { display: grid; gap: clamp(28px, 5vw, 56px); align-items: end;
|
||||
grid-template-columns: minmax(0, 1fr); }
|
||||
@media (min-width: 900px) { .hero-grid { grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); } }
|
||||
.hero h1 span { color: var(--accent); }
|
||||
.hero .lead { margin-top: 22px; max-width: 46ch; }
|
||||
.hero-side { border-left: 1px solid var(--line); padding-left: clamp(18px, 3vw, 30px); }
|
||||
@media (max-width: 899px) { .hero-side { border-left: 0; padding-left: 0; border-top: 1px solid var(--line); padding-top: 22px; } }
|
||||
.hero-side dl { display: grid; grid-template-columns: auto 1fr; gap: 9px 16px; margin: 0; }
|
||||
.hero-side dt { font-family: var(--font-mono); font-size: 1.05rem; font-weight: 700;
|
||||
font-variant-numeric: tabular-nums; color: var(--ink); }
|
||||
.hero-side dd { margin: 0; font-size: 0.86rem; color: var(--ink-soft); align-self: center; }
|
||||
|
||||
.btnrow { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 30px; }
|
||||
.btn { display: inline-flex; align-items: center; gap: 8px; border: 1px solid var(--line);
|
||||
background: var(--card); color: var(--ink); text-decoration: none; font-weight: 600;
|
||||
font-size: 0.93rem; padding: 12px 18px; border-radius: var(--radius); cursor: pointer;
|
||||
font-family: inherit; transition: transform .12s cubic-bezier(.16,1,.3,1), background .16s, border-color .16s; }
|
||||
.btn:hover { border-color: var(--ink-soft); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff;
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.16); }
|
||||
.btn.primary:hover { background: var(--accent-2); border-color: var(--accent-2); }
|
||||
.btn[disabled] { opacity: .55; cursor: progress; }
|
||||
|
||||
/* --------------------------------------------------------------- playground */
|
||||
|
||||
.pg { position: relative; z-index: 2; padding-bottom: clamp(40px, 7vw, 88px); }
|
||||
.pg-shell { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius);
|
||||
box-shadow: 0 18px 50px -34px oklch(21% 0.01 95 / 0.5); overflow: hidden; }
|
||||
.pg-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px 18px;
|
||||
padding: 15px 18px; border-bottom: 1px solid var(--line); }
|
||||
.pg-head h2 { font-size: 1.05rem; }
|
||||
.pg-head .fine { font-size: 0.82rem; color: var(--ink-soft); margin-left: auto; }
|
||||
.pg-body { display: grid; grid-template-columns: minmax(0, 1fr); }
|
||||
@media (min-width: 880px) { .pg-body { grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); } }
|
||||
.pg-controls { padding: 20px 18px; display: grid; gap: 17px; align-content: start; }
|
||||
@media (min-width: 880px) { .pg-controls { border-right: 1px solid var(--line); } }
|
||||
.field { display: grid; gap: 6px; }
|
||||
.field > label { font-family: var(--font-mono); font-size: 10px; letter-spacing: .16em;
|
||||
text-transform: uppercase; color: var(--ink-soft); }
|
||||
select, input[type=number] { font-family: inherit; font-size: 0.94rem; color: var(--ink);
|
||||
background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius-sm);
|
||||
padding: 9px 10px; width: 100%; }
|
||||
input[type=range] { width: 100%; accent-color: var(--accent); }
|
||||
.seg { display: flex; gap: 0; border: 1px solid var(--line); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.seg button { flex: 1; font-family: inherit; font-size: 0.85rem; padding: 9px 6px; border: 0;
|
||||
background: var(--paper); color: var(--ink-soft); cursor: pointer; border-right: 1px solid var(--line); }
|
||||
.seg button:last-child { border-right: 0; }
|
||||
.seg button[aria-pressed=true] { background: var(--accent-bg); color: var(--accent); font-weight: 600; }
|
||||
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.slider-head { display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.slider-head span { font-family: var(--font-mono); font-size: 0.78rem; color: var(--ink); }
|
||||
.toggle { display: flex; align-items: center; gap: 9px; font-size: 0.88rem; color: var(--ink-soft); }
|
||||
.toggle button { width: 38px; height: 22px; border-radius: 999px; border: 1px solid var(--line);
|
||||
background: var(--paper); position: relative; cursor: pointer; padding: 0; }
|
||||
.toggle button::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px;
|
||||
border-radius: 50%; background: var(--mark); transition: transform .16s cubic-bezier(.16,1,.3,1), background .16s; }
|
||||
.toggle button[aria-pressed=true] { border-color: var(--accent); background: var(--accent-bg); }
|
||||
.toggle button[aria-pressed=true]::after { transform: translateX(16px); background: var(--accent); }
|
||||
|
||||
.pg-stage { padding: 22px 18px 26px; display: grid; gap: 16px; align-content: start;
|
||||
background: repeating-linear-gradient(45deg, transparent 0 11px, oklch(21% 0.01 95 / 0.015) 11px 22px); }
|
||||
.stage-sheet { display: grid; place-items: center; }
|
||||
#sheet { width: auto; max-width: 100%; max-height: min(58vh, 520px);
|
||||
filter: drop-shadow(0 10px 26px oklch(21% 0.01 95 / 0.16)); background: #fff; }
|
||||
#sheet .cell { animation: pop .34s cubic-bezier(.16,1,.3,1) backwards;
|
||||
animation-delay: calc(var(--i) * 22ms); }
|
||||
@keyframes pop { from { opacity: 0; transform: scale(.965); } }
|
||||
@media (prefers-reduced-motion: reduce) { #sheet .cell { animation: none; } }
|
||||
|
||||
.readout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px;
|
||||
background: var(--line); border: 1px solid var(--line); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
@media (min-width: 560px) { .readout { grid-template-columns: repeat(5, minmax(0, 1fr)); } }
|
||||
.readout div { background: var(--card); padding: 10px 12px; }
|
||||
.readout dt { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .15em;
|
||||
text-transform: uppercase; color: var(--ink-soft); margin-bottom: 3px; }
|
||||
.readout dd { margin: 0; font-family: var(--font-mono); font-size: 0.95rem; font-weight: 700;
|
||||
font-variant-numeric: tabular-nums; }
|
||||
.note { font-size: 0.88rem; padding: 10px 12px; border-left: 2px solid var(--mark);
|
||||
background: var(--paper); color: var(--ink-soft); }
|
||||
.note[data-tone=ok] { border-left-color: var(--ok); }
|
||||
.note[data-tone=warn] { border-left-color: var(--warn); color: var(--ink); }
|
||||
.pg-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 12px;
|
||||
padding: 14px 18px; border-top: 1px solid var(--line); }
|
||||
.pg-foot p { margin: 0; font-size: 0.82rem; color: var(--ink-soft); max-width: 52ch; }
|
||||
|
||||
/* ----------------------------------------------------------------- sections */
|
||||
|
||||
section { position: relative; z-index: 2; padding: clamp(46px, 8vw, 96px) 0; }
|
||||
section + section { border-top: 1px solid var(--line); }
|
||||
|
||||
.zig { display: grid; gap: clamp(24px, 4vw, 52px); align-items: center;
|
||||
grid-template-columns: minmax(0, 1fr); }
|
||||
@media (min-width: 880px) { .zig { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } }
|
||||
@media (min-width: 880px) { .zig.flip > :first-child { order: 2; } }
|
||||
.zig figure { margin: 0; border: 1px solid var(--line); border-radius: var(--radius);
|
||||
overflow: hidden; background: var(--card); box-shadow: 0 16px 44px -34px oklch(21% 0.01 95 / 0.55); }
|
||||
.zig figure img { display: block; width: 100%; height: auto; }
|
||||
.zig figcaption { font-size: 0.8rem; color: var(--ink-soft); padding: 9px 12px; border-top: 1px solid var(--line); }
|
||||
|
||||
.facts { display: grid; gap: 0; margin-top: 30px; border-top: 1px solid var(--line); }
|
||||
.facts > div { display: grid; gap: 4px; padding: 16px 0; border-bottom: 1px solid var(--line); }
|
||||
@media (min-width: 680px) { .facts > div { grid-template-columns: 13rem minmax(0, 1fr); gap: 20px; align-items: baseline; } }
|
||||
.facts h3 { color: var(--ink); }
|
||||
.facts p { margin: 0; color: var(--ink-soft); font-size: 0.94rem; }
|
||||
|
||||
/* Modules: a list that reads as a switchboard, not three equal cards. */
|
||||
.mods { display: grid; gap: 0; border-top: 1px solid var(--line); margin-top: 32px; }
|
||||
.mod { display: grid; gap: 4px 18px; padding: 15px 0; border-bottom: 1px solid var(--line);
|
||||
grid-template-columns: minmax(0, 1fr); }
|
||||
@media (min-width: 760px) { .mod { grid-template-columns: 9.5rem minmax(0, 1fr) 15rem; align-items: baseline; } }
|
||||
.mod code { font-family: var(--font-mono); font-size: 0.86rem; color: var(--accent); }
|
||||
.mod.core code { color: var(--ink); }
|
||||
.mod p { margin: 0; font-size: 0.94rem; }
|
||||
.mod .need { font-size: 0.83rem; color: var(--ink-soft); }
|
||||
.mod.core { background: linear-gradient(90deg, var(--accent-bg), transparent 60%); }
|
||||
|
||||
pre { background: var(--ink); color: oklch(94% 0.01 95); font-family: var(--font-mono);
|
||||
font-size: 0.83rem; line-height: 1.7; padding: 16px 18px; border-radius: var(--radius);
|
||||
overflow-x: auto; margin: 0; }
|
||||
pre .c { color: oklch(66% 0.02 95); }
|
||||
code { font-family: var(--font-mono); font-size: 0.9em; }
|
||||
|
||||
.split-code { display: grid; gap: clamp(22px, 4vw, 44px); grid-template-columns: minmax(0, 1fr); align-items: start; }
|
||||
@media (min-width: 880px) { .split-code { grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); } }
|
||||
|
||||
footer { position: relative; z-index: 2; border-top: 1px solid var(--line);
|
||||
padding: 40px 0 calc(48px + env(safe-area-inset-bottom, 0px)); }
|
||||
.foot-in { display: flex; flex-wrap: wrap; gap: 14px 28px; align-items: baseline; }
|
||||
footer a { color: var(--ink-soft); text-decoration: none; font-size: 0.88rem; }
|
||||
footer a:hover { color: var(--accent); }
|
||||
footer .fine { font-size: 0.8rem; color: var(--ink-soft); margin-left: auto; }
|
||||
@media (max-width: 620px) { footer .fine { margin-left: 0; width: 100%; } }
|
||||
@@ -0,0 +1,70 @@
|
||||
/* Klarbild — Design-Tokens (OKLCH; siehe 06 §6). Identity:
|
||||
Space Grotesk + Space Mono, kräftiges Blau, warmes Papier/Karte, Passermarke,
|
||||
scharfe Ecken, technisch-editorial. Fonts self-hosted (kein Google/US). */
|
||||
:root {
|
||||
/* Flächen (Papier/Karte) */
|
||||
--paper: oklch(92.8% 0.005 95); /* #EAEAE3 */
|
||||
--card: oklch(98.4% 0.004 95); /* #FBFBF7 */
|
||||
--surface: var(--card);
|
||||
--line: oklch(86.8% 0.007 95); /* #D7D6CC */
|
||||
--mark: oklch(70% 0.006 95); /* #A7A69B Passermarke */
|
||||
|
||||
/* Text */
|
||||
--ink: oklch(21% 0.01 95); /* #16150F */
|
||||
--ink-soft: oklch(49% 0.008 95); /* #6A685D */
|
||||
--soft: var(--ink-soft);
|
||||
|
||||
/* Akzent — kräftiges Blau */
|
||||
--accent: oklch(45% 0.23 267); /* #1B3BE0 */
|
||||
--accent-2: oklch(38% 0.21 267); /* #122BA8 */
|
||||
--accent-bg: oklch(45% 0.23 267 / 0.06);
|
||||
|
||||
/* Zustände */
|
||||
--ok: oklch(60% 0.15 150);
|
||||
--warn: oklch(72% 0.15 75);
|
||||
--err: oklch(55% 0.19 25);
|
||||
|
||||
/* Form */
|
||||
--radius: 4px;
|
||||
--radius-sm: 3px;
|
||||
--shadow: 0 1px 2px oklch(21% 0.01 95 / 0.05);
|
||||
--font-sans: 'Space Grotesk', system-ui, -apple-system, sans-serif;
|
||||
--font-display: 'Space Grotesk', system-ui, sans-serif;
|
||||
--font-mono: 'Space Mono', ui-monospace, monospace;
|
||||
--maxw: 1060px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--paper); color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px; line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: var(--accent); }
|
||||
|
||||
/* Mono-Label (uppercase, gesperrt) — durchgängiges UI-Motiv */
|
||||
.mono-label {
|
||||
font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.18em;
|
||||
text-transform: uppercase; color: var(--soft);
|
||||
}
|
||||
|
||||
/* Passermarke (Kreuz) — Markenelement */
|
||||
.regmark { width: 15px; height: 15px; position: relative; display: inline-block; }
|
||||
.regmark::before, .regmark::after { content: ''; position: absolute; background: var(--accent); }
|
||||
.regmark::before { left: 50%; top: 0; width: 1.5px; height: 100%; transform: translateX(-50%); }
|
||||
.regmark::after { top: 50%; left: 0; height: 1.5px; width: 100%; transform: translateY(-50%); }
|
||||
|
||||
/* Mobile-First: 16px-Felder gegen iOS-Zoom beim Fokus */
|
||||
@media (max-width: 767px) {
|
||||
input, select, textarea { font-size: 16px !important; }
|
||||
}
|
||||
/* Taktiles Feedback auf allen Buttons */
|
||||
button { transition: transform 0.06s ease; }
|
||||
button:active { transform: scale(0.97); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
|
||||
}
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
Reference in new issue
Block a user