diff --git a/README.md b/README.md
index 42c125a..8205df6 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,10 @@
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
+Two static Astro pages — `/` in English and `/de/` in German, from one shared
+component and one copy file (`src/lib/copy.ts`). The languages are separate URLs
+rather than a runtime switch, so each one is linkable, indexable and works without
+JavaScript; the button in the header is an ordinary link. 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
@@ -11,6 +14,12 @@ 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).
+The preset builder in the image-generation section does the same with
+`src/engine/format.ts`: it composes a real Klarbild recipe and shows the exact
+target pixels, but it never calls a model. Generating on a public page would mean
+a key on that page and a bill for every curious visitor — and the picture would
+say something about the model rather than about Klarbild.
+
Nothing is uploaded and there is no backend.
```bash
@@ -23,7 +32,7 @@ npm run dev
`src/engine/*` are copies. When the print engine changes upstream, copy them again:
```bash
-cp ../klarbild/src/lib/{printlayout,paper,printoversize}.ts src/engine/
+cp ../klarbild/src/lib/{printlayout,paper,printoversize,format}.ts src/engine/
cp ../klarbild/src/styles/tokens.css src/styles/tokens.css
# printoversize.ts imports './printlayout.ts'; drop the extension for the bundler
sed -i "s#from './printlayout.ts'#from './printlayout'#" src/engine/printoversize.ts
diff --git a/public/shots/studio.png b/public/shots/studio.png
new file mode 100644
index 0000000..ae843ab
Binary files /dev/null and b/public/shots/studio.png differ
diff --git a/src/components/Page.astro b/src/components/Page.astro
new file mode 100644
index 0000000..54743b8
--- /dev/null
+++ b/src/components/Page.astro
@@ -0,0 +1,415 @@
+---
+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';
+import type { Copy } from '../lib/copy';
+
+interface Props { copy: Copy; canonical: string }
+const { copy: c, canonical } = Astro.props;
+
+const GITHUB = 'https://github.com/tillheidrich/klarbild';
+const SITE = 'https://klarbild-demo.heidrich-digital.de';
+
+// The output sizes the preset builder offers, straight from the application's
+// own table. `sticker` carries its own 5 × 5 cm.
+const FORMATS = [
+ { id: 'keep', label: c.htmlLang === 'de' ? 'Original behalten' : 'Keep original' },
+ { id: '9x13', label: '9 × 13 cm' }, { id: '10x15', label: '10 × 15 cm' },
+ { id: '13x18', label: '13 × 18 cm' }, { id: '15x20', label: '15 × 20 cm' },
+ { id: '20x30', label: '20 × 30 cm' }, { id: '30x40', label: '30 × 40 cm' },
+ { id: '30x45', label: '30 × 45 cm' }, { id: '40x50', label: '40 × 50 cm' },
+ { id: '40x60', label: '40 × 60 cm' }, { id: '50x70', label: '50 × 70 cm' },
+ { id: '60x90', label: '60 × 90 cm' },
+ { id: 'A4', label: 'DIN A4' }, { id: 'A3', label: 'DIN A3' }, { id: 'A2', label: 'DIN A2' },
+ { id: '20x20', label: '20 × 20 cm' }, { id: '30x30', label: '30 × 30 cm' },
+ { id: 'sticker', label: c.htmlLang === 'de' ? 'Sticker 5 × 5 cm' : 'Sticker 5 × 5 cm' },
+];
+
+const dpiLine = c.htmlLang === 'de' ? '{w} × {h} px bei {dpi} dpi' : '{w} × {h} px at {dpi} dpi';
+---
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/engine/format.ts b/src/engine/format.ts
new file mode 100644
index 0000000..2d26682
--- /dev/null
+++ b/src/engine/format.ts
@@ -0,0 +1,77 @@
+// Target sizes & exact pixel calculation (see 01 §5, 06 §3).
+// px = round(cm / 2.54 * dpi)
+
+export type Orientation = 'portrait' | 'landscape';
+
+export interface Dimensions { w: number; h: number; cm?: [number, number]; label: string; }
+
+// cm sizes: [width, height] in the portrait convention (short side × long side)
+const CM: Record = {
+ '9x13': [9, 13], '10x15': [10, 15], '13x18': [13, 18], '15x20': [15, 20],
+ '20x30': [20, 30], '30x40': [30, 40], '30x45': [30, 45], '40x50': [40, 50],
+ '40x60': [40, 60], '50x70': [50, 70], '60x90': [60, 90],
+ A4: [21, 29.7], A3: [29.7, 42], A2: [42, 59.4],
+ '20x20': [20, 20], '30x30': [30, 30],
+};
+
+// Pure screen sizes (fixed pixels, no cm/dpi)
+const SCREEN: Record = {
+ tv169: [3840, 2160], // 16:9 landscape
+ portrait916: [2160, 3840], // 9:16 portrait
+};
+
+export const cmToPx = (cm: number, dpi: number) => Math.round((cm / 2.54) * dpi);
+
+export interface ResolveInput {
+ format: string; // a key from above, 'sticker', 'custom' or 'keep'
+ orientation?: Orientation;
+ dpi?: number;
+ customCm?: [number, number]; // for 'sticker'/'custom'
+}
+
+/** Returns exact target pixels + label. `keep` → null (keep the original). */
+export function resolveDimensions(input: ResolveInput): Dimensions | null {
+ const { format } = input;
+ const dpi = input.dpi ?? 300;
+ const orient = input.orientation ?? 'portrait';
+
+ if (format === 'keep') return null;
+
+ if (format in SCREEN) {
+ const [w, h] = SCREEN[format];
+ return { w, h, label: format === 'tv169' ? 'TV 16:9' : 'Portrait' };
+ }
+
+ let cm: [number, number] | undefined;
+ if (format in CM) cm = CM[format];
+ else if (format === 'sticker' || format === 'custom') cm = input.customCm ?? [5, 5];
+ else {
+ // Read freely defined sizes straight out of the key — no database migration needed:
+ // "sticker5" / "sticker-7,5" → square N×N cm; "25x35" / "25×35" → W×H cm.
+ const sq = /^sticker[-_ ]?(\d+(?:[.,]\d+)?)$/i.exec(format);
+ const wh = /^(\d+(?:[.,]\d+)?)\s*[x×*]\s*(\d+(?:[.,]\d+)?)$/i.exec(format);
+ const num = (s: string) => parseFloat(s.replace(',', '.'));
+ if (sq) { const n = num(sq[1]); cm = [n, n]; }
+ else if (wh) cm = [num(wh[1]), num(wh[2])];
+ }
+ if (!cm || !cm[0] || !cm[1] || cm[0] > 300 || cm[1] > 300)
+ throw new Error(`Unknown format: ${format}`);
+
+ // Portrait convention → apply the requested orientation
+ let [wCm, hCm] = cm;
+ if (orient === 'landscape') [wCm, hCm] = [hCm, wCm];
+ return { w: cmToPx(wCm, dpi), h: cmToPx(hCm, dpi), cm: [wCm, hCm],
+ label: `${cm[0]}×${cm[1]} cm` };
+}
+
+/** Simplified aspect ratio as "w:h" for the image API. */
+export function aspectRatio(w: number, h: number): string {
+ const g = gcd(w, h);
+ return `${Math.round(w / g)}:${Math.round(h / g)}`;
+}
+function gcd(a: number, b: number): number { return b === 0 ? a : gcd(b, a % b); }
+
+/** Is the native resolution enough for real 300 dpi? (A hint in the UI.) */
+export function upscaleWarning(srcW: number, srcH: number, target: Dimensions): boolean {
+ return srcW < target.w * 0.95 || srcH < target.h * 0.95;
+}
diff --git a/src/lib/copy.ts b/src/lib/copy.ts
new file mode 100644
index 0000000..3a780c1
--- /dev/null
+++ b/src/lib/copy.ts
@@ -0,0 +1,394 @@
+/**
+ * Every sentence on this page, in both languages.
+ *
+ * The site is static, so the two languages are two pages (`/` and `/de/`)
+ * rather than a runtime switch. That keeps the page working without
+ * JavaScript, gives each language its own URL to link and to index, and means
+ * the language button is an ordinary link.
+ */
+
+export type Lang = 'en' | 'de';
+
+export interface Copy {
+ htmlLang: string;
+ otherHref: string;
+ otherLabel: string;
+ title: string;
+ description: string;
+ nav: { try: string; ai: string; modules: string; install: string; source: string };
+ hero: {
+ kicker: string;
+ h1a: string;
+ h1accent: string;
+ lead: string;
+ ctaPrimary: string;
+ ctaSecondary: string;
+ stats: { n: string; t: string }[];
+ };
+ pg: {
+ title: string;
+ fine: string;
+ paper: string;
+ orientation: string;
+ portrait: string;
+ landscape: string;
+ size: string;
+ count: string;
+ marks: string;
+ marksNone: string;
+ marksCorner: string;
+ marksGrid: string;
+ bleed: string;
+ margin: string;
+ rotate: string;
+ outFit: string;
+ outSheets: string;
+ outCell: string;
+ outPaper: string;
+ outGap: string;
+ download: string;
+ downloadBusy: string;
+ footnote: string;
+ noteAllFit: string;
+ noteAllFitMulti: string;
+ notePartial: string;
+ noteNone: string;
+ };
+ what: { kicker: string; h2: string; facts: { h: string; p: string }[] };
+ shot1: { kicker: string; h2: string; lead: string; body: string; caption: string };
+ ai: {
+ kicker: string;
+ h2: string;
+ lead: string;
+ body: string;
+ caption: string;
+ builderTitle: string;
+ builderFine: string;
+ presetLabel: string;
+ tasksLabel: string;
+ tasks: { id: string; name: string; hint: string }[];
+ formatLabel: string;
+ orientationLabel: string;
+ extLabel: string;
+ resultLabel: string;
+ resultKeep: string;
+ recipeLabel: string;
+ examplesLabel: string;
+ presets: { name: string; tasks: string[]; format: string; orientation: 'portrait' | 'landscape'; ext?: string; note: string }[];
+ honest: string;
+ };
+ lang: { kicker: string; h2: string; lead: string; body: string; caption: string };
+ modules: {
+ kicker: string; h2: string; lead: string; off: string;
+ rows: { id: string; title: string; what: string; need: string; core?: boolean }[];
+ envComment1: string; envComment2: string; envComment3: string;
+ };
+ install: { kicker: string; h2: string; p1: string; p2: string; cta1: string; cta2: string; comment1: string; comment2: string; stack: string };
+ privacy: { kicker: string; h2: string; lead: string; body: string };
+ footer: { fine: string };
+}
+
+const TASKS_EN = [
+ { id: 'clean', name: 'Clean up', hint: 'Frame, wall and shop interface removed.' },
+ { id: 'cutout', name: 'Cut out', hint: 'Subject only, background transparent.' },
+ { id: 'format', name: 'Format', hint: 'Brought to an exact size.' },
+ { id: 'contour', name: 'Contour', hint: 'White sticker edge. Needs Cut out.' },
+];
+
+const TASKS_DE = [
+ { id: 'clean', name: 'Bereinigen', hint: 'Rahmen, Wand und Shop-Oberfläche weg.' },
+ { id: 'cutout', name: 'Freistellen', hint: 'Nur das Motiv, Hintergrund transparent.' },
+ { id: 'format', name: 'Format', hint: 'Auf ein exaktes Maß gebracht.' },
+ { id: 'contour', name: 'Kontur', hint: 'Weißer Stickerrand. Braucht Freistellen.' },
+];
+
+/** The examples that ship as seeded recipes, plus two the page adds for range. */
+const PRESETS_EN = [
+ { name: 'Clean up only', tasks: ['clean'], format: 'keep', orientation: 'landscape' as const,
+ note: 'A marketplace screenshot with the frame, the wall and the shop chrome taken out. Size untouched.' },
+ { name: 'Poster 30 × 40', tasks: ['clean', 'format'], format: '30x40', orientation: 'portrait' as const,
+ note: 'Cleaned and brought to poster size — 3543 × 4724 px at 300 dpi, ready for the print studio.' },
+ { name: 'Cut out, transparent PNG', tasks: ['clean', 'cutout'], format: 'keep', orientation: 'landscape' as const, ext: 'png',
+ note: 'Subject free of its background. PNG, because JPG has no transparency.' },
+ { name: 'Sticker 5 cm', tasks: ['clean', 'cutout', 'format', 'contour'], format: 'sticker', orientation: 'landscape' as const,
+ note: 'Cut out, squared off and given a 3 mm white edge, so a cutting machine has something to follow.' },
+ { name: 'Square 30 × 30 for social', tasks: ['clean', 'format'], format: '30x30', orientation: 'landscape' as const,
+ note: 'One source, one square, no guesswork about where the crop lands.' },
+ { name: 'DIN A3 for the wall', tasks: ['clean', 'format'], format: 'A3', orientation: 'portrait' as const,
+ note: 'The same picture at 29.7 × 42 cm. Change one line of the preset and it is A2.' },
+];
+
+const PRESETS_DE = [
+ { name: 'Nur bereinigen', tasks: ['clean'], format: 'keep', orientation: 'landscape' as const,
+ note: 'Ein Marktplatz-Screenshot ohne Rahmen, Wand und Shop-Oberfläche. Maß bleibt, wie es war.' },
+ { name: 'Poster 30 × 40', tasks: ['clean', 'format'], format: '30x40', orientation: 'portrait' as const,
+ note: 'Bereinigt und auf Postermaß gebracht — 3543 × 4724 px bei 300 dpi, fertig fürs Druckstudio.' },
+ { name: 'Freistellen, transparentes PNG', tasks: ['clean', 'cutout'], format: 'keep', orientation: 'landscape' as const, ext: 'png',
+ note: 'Motiv ohne Hintergrund. PNG, weil JPG keine Transparenz kann.' },
+ { name: 'Sticker 5 cm', tasks: ['clean', 'cutout', 'format', 'contour'], format: 'sticker', orientation: 'landscape' as const,
+ note: 'Freigestellt, quadratisch gesetzt und mit 3 mm weißem Rand, damit der Schneideplotter etwas zum Folgen hat.' },
+ { name: 'Quadrat 30 × 30 für Social', tasks: ['clean', 'format'], format: '30x30', orientation: 'landscape' as const,
+ note: 'Eine Quelle, ein Quadrat, kein Raten, wo der Beschnitt landet.' },
+ { name: 'DIN A3 für die Wand', tasks: ['clean', 'format'], format: 'A3', orientation: 'portrait' as const,
+ note: 'Dasselbe Bild auf 29,7 × 42 cm. Eine Zeile im Preset ändern und es ist A2.' },
+];
+
+const MODULES_EN = [
+ { 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: 'A key for an images API. 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 MODULES_DE = [
+ { id: 'print', title: 'Druckstudio', core: true,
+ what: 'Exakte physische Maße, Bogen mit Schnittmarken, Beschnittzugabe, Kachelung, 1:1-PDF.', need: 'Nichts. Immer an.' },
+ { id: 'ai', title: 'Bildgenerierung',
+ what: 'Screenshot bereinigen, Foto umwandeln, mehrere kombinieren, aus Text neu erzeugen.', need: 'Ein Schlüssel für eine Bild-API. Kostet je Bild.' },
+ { id: 'delivery', title: 'Auslieferung',
+ what: 'Fertige Dateien auf einen SFTP- oder FTPS-Server schieben, mit Zusatzzielen je Ordner.', need: 'Einen Server, den du schon hast.' },
+ { id: 'mirror', title: 'Sicherungsspiegel',
+ what: 'Eine zweite Kopie jedes Ergebnisses, nach Monat abgelegt.', need: 'Ein zweites SFTP-/FTPS-Ziel.' },
+ { id: 'share', title: 'Teilen-Links',
+ what: 'Öffentliche Links, die ablaufen, widerrufbar sind, ein Zugangswort tragen können und eine ganze Auswahl als ZIP packen.', need: 'Eine öffentlich erreichbare Adresse.' },
+ { id: 'mail', title: 'E-Mail',
+ what: 'Passwort zurücksetzen, Auftragshinweise, Teilen-Links verschicken.', need: 'Ein eigenes Postfach.' },
+ { id: 'telegram', title: 'Telegram-Bot',
+ what: 'Das ganze Werkzeug aus einem Chatfenster statt aus dem Browser.', need: 'Einen Bot-Token von BotFather.' },
+];
+
+export const EN: Copy = {
+ htmlLang: 'en',
+ otherHref: '/de/',
+ otherLabel: 'DE',
+ title: 'Klarbild — print pictures at the size you actually asked for',
+ description: '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.',
+ nav: { try: 'Try it', ai: 'Image generation', modules: 'Modules', install: 'Install', source: 'Source' },
+ hero: {
+ kicker: 'Self-hosted · AGPL-3.0',
+ h1a: 'Print pictures at the size you ',
+ h1accent: 'actually asked for',
+ 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.',
+ ctaPrimary: 'Lay out a sheet',
+ ctaSecondary: 'Read the source',
+ stats: [
+ { n: '210.00', t: '× 297.00 mm on the page when you ask for A4 — measured, not assumed' },
+ { n: '0', t: 'outbound requests from the print studio. It is arithmetic, sharp and pdf-lib' },
+ { n: '124', t: 'tests over the layout engine, the naming scheme and the path checks' },
+ { n: '1', t: 'container and a database for the default install' },
+ ],
+ },
+ pg: {
+ title: 'Lay out a sheet',
+ fine: "Runs the application's own layout code, here in your browser. Nothing is uploaded.",
+ paper: 'Paper', orientation: 'Sheet orientation', portrait: 'Portrait', landscape: 'Landscape',
+ size: 'Picture size', count: 'How many', marks: 'Cutting aid',
+ marksNone: 'None', marksCorner: 'Corner', marksGrid: 'Lines',
+ bleed: 'Bleed', margin: 'Printer margin', rotate: 'Rotate pictures to save space',
+ outFit: 'Fit', outSheets: 'Sheets', outCell: 'Cell', outPaper: 'Paper', outGap: 'Gap',
+ download: 'Download this sheet as a PDF', downloadBusy: 'Building the PDF …',
+ footnote: '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.',
+ noteAllFit: 'All {n} fit on one sheet. Nothing is scaled.',
+ noteAllFitMulti: 'All {n} fit across {pages} sheets. Nothing is scaled.',
+ notePartial: '{placed} of {n} fit on {pages} sheet(s).',
+ noteNone: '{size} does not fit on this sheet at 100 %. In the application this is where you choose overflow or tiling.',
+ },
+ what: {
+ kicker: 'The print studio',
+ h2: "Everything a photo shop's software does, minus the photo shop.",
+ 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 A1 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.' },
+ ],
+ },
+ shot1: {
+ kicker: 'In the application',
+ h2: 'Eight passport photos, one sheet, one cut line each.',
+ 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.',
+ body: 'The same picture may appear twice at two different sizes. Layouts you repeat become presets. The last hundred runs are kept as instructions rather than as PDFs — one to three kilobytes each instead of eighty megabytes — so “print that again” costs nothing.',
+ caption: 'The print studio. Print-only install — no library, no queue, no model.',
+ },
+ ai: {
+ kicker: 'Optional module: ai',
+ h2: 'And if you do want a model, it is one switch and your own key.',
+ lead: 'Image generation is a module. It is off until you turn it on, it needs a key you bring yourself, and it costs money per image — which is exactly why it is not the default. With it on, the studio gets four tasks and every combination of them can be saved as a preset.',
+ body: 'Requests go to {base}/images in the OpenAI image shape. The default base is OpenRouter — one key, many models, and the model list fills itself in. Point IMAGE_API_BASE_URL at a LiteLLM gateway, a company proxy or a server of your own and Klarbild talks to that instead. Either way the pictures go to exactly one endpoint, and you chose which.',
+ caption: 'The studio with the module on: three modes, four tasks, saved presets, exact output size.',
+ builderTitle: 'Build a preset',
+ builderFine: 'Not a generator — this composes the recipe and shows what it would run. No key, no request, no picture leaves this page.',
+ presetLabel: 'Start from an example',
+ tasksLabel: 'What should happen?',
+ tasks: TASKS_EN,
+ formatLabel: 'Output size',
+ orientationLabel: 'Orientation',
+ extLabel: 'File format',
+ resultLabel: 'Result',
+ resultKeep: 'Original size kept',
+ recipeLabel: 'The recipe this saves',
+ examplesLabel: 'What it is for',
+ presets: PRESETS_EN,
+ honest: 'The demo stops at the recipe on purpose. Generating here would mean a key on a public page and a bill for whoever is curious — and it would show you a picture from a model rather than anything about Klarbild. What Klarbild does is the part above: the tasks, the exact target size, and the preset you save once and reuse.',
+ },
+ lang: {
+ kicker: 'Two languages',
+ h2: 'English by default, German all the way through.',
+ lead: 'Not a half-translated interface: 1,067 entries, covering every translatable string in the source. The English sentence is the translation key, so a missing entry falls back to readable English rather than to print.sheet.title.',
+ body: '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.',
+ caption: 'The same screen, ?lang=de.',
+ },
+ modules: {
+ kicker: 'Modules',
+ h2: 'You decide what this install is.',
+ lead: '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.',
+ off: 'A module that is off is off: no navigation entry, no settings card, the page redirects, and its API routes answer 404 rather than 403 — a disabled instance does not advertise what it could have done.',
+ rows: MODULES_EN,
+ envComment1: '# the default: the print studio and nothing else',
+ envComment2: '# pick what you want',
+ envComment3: '# everything',
+ },
+ install: {
+ kicker: 'Install',
+ h2: 'Four lines, then a password in the log.',
+ p1: '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.',
+ p2: 'There are no built-in accounts. The first boot creates one administrator — from ADMIN_USERNAME and ADMIN_PASSWORD if you set them, and otherwise with a generated password printed to the log exactly once.',
+ cta1: 'Source on GitHub', cta2: 'Releases',
+ comment1: '# four values at the top; the file says which',
+ comment2: '# your admin password, printed once',
+ stack: 'Astro on Node, PostgreSQL, sharp and pdf-lib. About 13,700 lines across 117 files. Sixteen forward-only migrations that run themselves on boot. Type check, tests and a secret scan run in CI on every push.',
+ },
+ privacy: {
+ kicker: 'Why self-hosted',
+ h2: 'These are passport photos and family pictures.',
+ lead: 'The print studio never makes an outbound request at all. With the image module on, only what you hand the model leaves the building, and the key is yours.',
+ body: '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 noindex. Access hangs on the link rather than on the picture, so guessing an image id returns a 404. The view counter records when and with what, never who — the address is hashed with a server secret and the link id, one row per visitor per hour, deleted after ninety days.',
+ },
+ footer: { fine: 'A demo page. The layout engine is real; the pictures are outlines. Built by' },
+};
+
+export const DE: Copy = {
+ htmlLang: 'de',
+ otherHref: '/',
+ otherLabel: 'EN',
+ title: 'Klarbild — Bilder drucken in genau dem Maß, das du wolltest',
+ description: 'Selbst gehostetes Druckstudio. Bilder in exakten physischen Maßen aufs Papier bringen — mit Schnittmarken, Beschnittzugabe und Kachelung, und einer PDF-Seite, die misst, was draufsteht. Open Source, AGPL-3.0. Bildgenerierung und Auslieferung sind abschaltbare Module.',
+ nav: { try: 'Ausprobieren', ai: 'Bildgenerierung', modules: 'Module', install: 'Installieren', source: 'Quellcode' },
+ hero: {
+ kicker: 'Selbst gehostet · AGPL-3.0',
+ h1a: 'Bilder drucken in genau dem Maß, ',
+ h1accent: 'das du wolltest',
+ lead: 'Im Druckertreiber gibt es ein Häkchen namens „an Seite anpassen". Mit Häkchen kommt ein 35 × 45 mm Passbild als 34 × 44 heraus. Ohne Häkchen rätst du. Klarbild rechnet den Bogen in Millimetern und gibt dir ein PDF, das misst, was draufsteht.',
+ ctaPrimary: 'Bogen setzen',
+ ctaSecondary: 'Quellcode lesen',
+ stats: [
+ { n: '210,00', t: '× 297,00 mm auf der Seite, wenn du A4 sagst — nachgemessen, nicht angenommen' },
+ { n: '0', t: 'Anfragen nach außen aus dem Druckstudio. Es ist Rechnen, sharp und pdf-lib' },
+ { n: '124', t: 'Tests über Layout-Engine, Namensschema und Pfadprüfungen' },
+ { n: '1', t: 'Container und eine Datenbank für die Standardinstallation' },
+ ],
+ },
+ pg: {
+ title: 'Bogen setzen',
+ fine: 'Rechnet mit dem Layout-Code der Anwendung, hier in deinem Browser. Nichts wird hochgeladen.',
+ paper: 'Papier', orientation: 'Bogenlage', portrait: 'Hochformat', landscape: 'Querformat',
+ size: 'Bildmaß', count: 'Wie viele', marks: 'Schnitthilfe',
+ marksNone: 'Keine', marksCorner: 'Eckmarken', marksGrid: 'Linien',
+ bleed: 'Beschnittzugabe', margin: 'Druckerrand', rotate: 'Bilder drehen, um Platz zu sparen',
+ outFit: 'Passen', outSheets: 'Bögen', outCell: 'Zelle', outPaper: 'Papier', outGap: 'Abstand',
+ download: 'Diesen Bogen als PDF laden', downloadBusy: 'PDF wird gebaut …',
+ footnote: 'Die Demo zeichnet Umrisse statt Fotos — im Original stehen deine Bilder in diesen Kästen, in der Auflösung, die du wählst. Miss das PDF nach: Seite und jeder Kasten sind genau die Millimeter von oben.',
+ noteAllFit: 'Alle {n} passen auf einen Bogen. Nichts wird skaliert.',
+ noteAllFitMulti: 'Alle {n} passen auf {pages} Bögen. Nichts wird skaliert.',
+ notePartial: '{placed} von {n} passen auf {pages} Bogen.',
+ noteNone: '{size} passt bei 100 % nicht auf diesen Bogen. In der Anwendung wählst du hier Überstehen oder Kacheln.',
+ },
+ what: {
+ kicker: 'Das Druckstudio',
+ h2: 'Alles, was die Software im Fotoladen kann, ohne den Fotoladen.',
+ facts: [
+ { h: '86 Bildmaße', p: 'In zehn Gruppen — Passbild, kleine Abzüge, Fotoformate, US-Zoll, Sofortbild, Poster- und Rahmenmaße, Quadrate, DIN, Karten, reine Seitenverhältnisse. Oder selbst tippen: 25x35, 13x18, 10x7,5.' },
+ { h: '19 Papierformate', p: 'A6 bis A1 inklusive A3+, Fotopapier von 9 × 13 bis 20 × 30 cm, US Letter und Legal — oder ein freies Maß.' },
+ { h: 'Schnittmarken', p: 'Eckmarken mit 0,25 pt außerhalb des Endformats, oder durchgehende Linien, wenn alle Zellen gleich groß sind. Beschnittzugabe 0 bis 10 mm.' },
+ { h: 'Größer als der Bogen', p: 'Ein 20 × 30-Abzug auf A4 ist kein Fehler. „Fit" sagt dir, dass es nicht geht; „Überstehen" setzt ihn mittig aufs ganze Blatt und beziffert den Verlust vorher; „Kacheln" verteilt ihn auf mehrere Blätter, mit Klebefalz und Blattnummer.' },
+ { h: 'Zuschneiden oder Rand lassen', p: 'Passt ein Bild nicht zum Zielverhältnis, entscheidest du je Bild — zuschneiden mit Zoom-und-Schieben-Vorschau, oder ein Rand in einer Farbe deiner Wahl. Verzerrt wird nie.' },
+ { h: 'Ein Rechenkern', p: 'Browser-Vorschau und Server-PDF rechnen dasselbe. Die Datei heißt printlayout.ts, hat keine Abhängigkeiten und eigene Tests.' },
+ ],
+ },
+ shot1: {
+ kicker: 'In der Anwendung',
+ h2: 'Acht Passbilder, ein Bogen, je eine Schnittlinie.',
+ lead: 'Maß wählen, Anzahl sagen, der Bogen füllt sich selbst. Gleiche Größen rasten in ein exaktes Raster, gemischte laufen durch einen Packer. Jede Zelle trägt ihr Maß als Beschriftung — und die verschwindet von selbst, wenn an der Kante kein Platz ist.',
+ body: 'Dasselbe Bild darf zweimal in zwei Maßen vorkommen. Layouts, die sich wiederholen, werden Vorlagen. Die letzten hundert Läufe liegen als Anleitung statt als PDF — ein bis drei Kilobyte statt achtzig Megabyte —, damit „nochmal drucken" nichts kostet.',
+ caption: 'Das Druckstudio. Reine Druck-Installation — keine Bibliothek, keine Warteschlange, kein Modell.',
+ },
+ ai: {
+ kicker: 'Abschaltbares Modul: ai',
+ h2: 'Und wenn du ein Modell willst, ist es ein Schalter und dein eigener Schlüssel.',
+ lead: 'Bildgenerierung ist ein Modul. Es ist aus, bis du es anschaltest, es braucht einen Schlüssel, den du selbst mitbringst, und es kostet Geld je Bild — genau deshalb ist es nicht der Standard. Ist es an, bekommt das Studio vier Aufgaben, und jede Kombination davon lässt sich als Vorlage speichern.',
+ body: 'Die Anfragen gehen an {base}/images im OpenAI-Bildformat. Voreingestellt ist OpenRouter — ein Schlüssel, viele Modelle, und die Modellliste füllt sich selbst. Zeigt IMAGE_API_BASE_URL auf ein LiteLLM-Gateway, einen Firmen-Proxy oder einen eigenen Server, redet Klarbild mit dem. So oder so gehen die Bilder an genau einen Endpunkt, und den hast du ausgesucht.',
+ caption: 'Das Studio mit eingeschaltetem Modul: drei Modi, vier Aufgaben, gespeicherte Vorlagen, exaktes Zielmaß.',
+ builderTitle: 'Eine Vorlage bauen',
+ builderFine: 'Kein Generator — hier entsteht das Rezept, und es zeigt, was es ausführen würde. Kein Schlüssel, keine Anfrage, kein Bild verlässt diese Seite.',
+ presetLabel: 'Von einem Beispiel ausgehen',
+ tasksLabel: 'Was soll passieren?',
+ tasks: TASKS_DE,
+ formatLabel: 'Zielmaß',
+ orientationLabel: 'Ausrichtung',
+ extLabel: 'Dateiformat',
+ resultLabel: 'Ergebnis',
+ resultKeep: 'Originalmaß bleibt',
+ recipeLabel: 'Das Rezept, das dabei gespeichert wird',
+ examplesLabel: 'Wofür das gut ist',
+ presets: PRESETS_DE,
+ honest: 'Die Demo hört beim Rezept auf, und zwar mit Absicht. Hier zu generieren hieße: ein Schlüssel auf einer öffentlichen Seite und eine Rechnung für jeden, der neugierig ist — und es würde dir ein Bild aus einem Modell zeigen statt irgendetwas über Klarbild. Was Klarbild macht, ist der Teil darüber: die Aufgaben, das exakte Zielmaß und die Vorlage, die du einmal speicherst und dann wiederverwendest.',
+ },
+ lang: {
+ kicker: 'Zwei Sprachen',
+ h2: 'Englisch als Standard, Deutsch vollständig daneben.',
+ lead: 'Keine halb übersetzte Oberfläche: 1.067 Einträge, jeder übersetzbare String im Quelltext ist abgedeckt. Der englische Satz ist der Schlüssel — fehlt ein Eintrag, steht da lesbares Englisch und nicht print.sheet.title.',
+ body: 'Eine Umgebungsvariable setzt den Standard; Besucher schalten mit dem Knopf in der Ecke um, und die Wahl bleibt. Eine dritte Sprache heißt: eine JSON-Datei kopieren und ihre Werte übersetzen.',
+ caption: 'Derselbe Bildschirm, ?lang=de.',
+ },
+ modules: {
+ kicker: 'Module',
+ h2: 'Du entscheidest, was diese Installation ist.',
+ lead: 'Klarbild ist eine Codebasis, aber nicht ein Produkt. Das Druckstudio ist der Kern. Alles, was ein Konto woanders braucht, ist ein Modul — und jedes Modul ist aus, bis du es anschaltest.',
+ off: 'Ein abgeschaltetes Modul ist wirklich aus: kein Navigationseintrag, keine Einstellungskarte, die Seite leitet um, und seine API-Routen antworten 404 statt 403 — eine abgeschaltete Installation verrät nicht, was sie könnte.',
+ rows: MODULES_DE,
+ envComment1: '# der Standard: das Druckstudio und sonst nichts',
+ envComment2: '# nimm, was du willst',
+ envComment3: '# alles',
+ },
+ install: {
+ kicker: 'Installieren',
+ h2: 'Vier Zeilen, dann steht ein Passwort im Log.',
+ p1: 'Die Standard-Compose-Datei startet die Anwendung und ein PostgreSQL, Dateien liegen auf einem Volume. Kein Objektspeicher, kein API-Schlüssel, kein Konto irgendwo. Eine zweite Compose-Datei nimmt MinIO dazu und schaltet alle Module an — für die, die das wollen.',
+ p2: 'Es gibt keine eingebauten Konten. Der erste Start legt genau einen Administrator an — aus ADMIN_USERNAME und ADMIN_PASSWORD, wenn du sie setzt, sonst mit einem erzeugten Passwort, das genau einmal ins Log geschrieben wird.',
+ cta1: 'Quellcode auf GitHub', cta2: 'Releases',
+ comment1: '# vier Werte ganz oben; die Datei sagt, welche',
+ comment2: '# dein Admin-Passwort, einmalig ausgegeben',
+ stack: 'Astro auf Node, PostgreSQL, sharp und pdf-lib. Rund 13.700 Zeilen in 117 Dateien. Sechzehn vorwärts-nur-Migrationen, die beim Start selbst laufen. Typprüfung, Tests und ein Secret-Scan laufen in der CI bei jedem Push.',
+ },
+ privacy: {
+ kicker: 'Warum selbst gehostet',
+ h2: 'Das sind Passbilder und Familienfotos.',
+ lead: 'Das Druckstudio macht überhaupt keine Anfrage nach außen. Mit eingeschaltetem Bildmodul verlässt nur das Haus, was du dem Modell gibst — und der Schlüssel gehört dir.',
+ body: 'Teilen-Links sind auf Vorsicht gebaut, nicht auf Bequemlichkeit: Sie laufen standardmäßig nach dreißig Tagen ab, lassen sich widerrufen, können ein Zugangswort tragen und sind noindex. Der Zugriff hängt am Link, nicht am Bild — eine geratene Bildkennung ergibt 404. Der Zähler hält fest, wann und womit, nie wer: Die Adresse wird mit einem Server-Geheimnis und der Link-Kennung gehasht, eine Zeile je Besucher und Stunde, gelöscht nach neunzig Tagen.',
+ },
+ footer: { fine: 'Eine Demoseite. Der Layout-Kern ist echt, die Bilder sind Umrisse. Gebaut von' },
+};
+
+export const COPY: Record = { en: EN, de: DE };
diff --git a/src/lib/playground.ts b/src/lib/playground.ts
index 5b1b070..0272134 100644
--- a/src/lib/playground.ts
+++ b/src/lib/playground.ts
@@ -14,6 +14,11 @@
import { layout, cutMarks, effectiveGap, recommendedGap, type PlaceSpec, type SheetSpec, type MarkMode } from '../engine/printlayout';
import { PAPERS, PHOTO_SIZES, labelMm } from '../engine/paper';
+/** German writes 29,7 cm where English writes 29.7 cm. Only the separator differs. */
+function localiseNumbers(text: string, lang: string): string {
+ return lang === 'de' ? text.replace(/(\d),(?=\d)/g, '$1\u2009').replace(/(\d)\.(?=\d)/g, '$1,') : text;
+}
+
const MM_PT = 72 / 25.4;
type State = {
@@ -174,7 +179,17 @@ async function buildPdf(): Promise {
/* ---------------------------------------------------------------------- wire */
-export function mountPlayground(root: HTMLElement) {
+interface PgLabels {
+ downloadBusy: string;
+ noteAllFit: string;
+ noteAllFitMulti: string;
+ notePartial: string;
+ noteNone: string;
+}
+
+export function mountPlayground(root: HTMLElement, labels: PgLabels) {
+ const lang = document.documentElement.lang || 'en';
+ const mm = (w: number, h: number) => localiseNumbers(labelMm(w, h), lang);
const svg = root.querySelector('#sheet')!;
const out = {
fits: root.querySelector('[data-out=fits]')!,
@@ -193,7 +208,7 @@ export function mountPlayground(root: HTMLElement) {
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)}`;
+ o.textContent = `${p.label} · ${mm(p.w, p.h)}`;
og.appendChild(o);
}
paperSel.appendChild(og);
@@ -222,18 +237,24 @@ export function mountPlayground(root: HTMLElement) {
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`;
+ out.cell.textContent = mm(s.w, s.h);
+ out.paper.textContent = mm(sheet.wMm, sheet.hMm);
+ out.gap.textContent = localiseNumbers(`${effectiveGap(sheet).toFixed(1)} mm`, lang);
+
+ const fill = (t: string) => t
+ .replace('{n}', String(wanted))
+ .replace('{placed}', String(placed))
+ .replace('{pages}', String(result.pages.length))
+ .replace('{size}', s.label);
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.textContent = fill(labels.noteNone);
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.textContent = fill(labels.notePartial);
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.textContent = fill(result.pages.length > 1 ? labels.noteAllFitMulti : labels.noteAllFit);
out.note.dataset.tone = 'ok';
}
}
@@ -269,7 +290,7 @@ export function mountPlayground(root: HTMLElement) {
dl.addEventListener('click', async () => {
const label = dl.textContent;
dl.disabled = true;
- dl.textContent = 'Building the PDF …';
+ dl.textContent = labels.downloadBusy;
try {
const blob = await buildPdf();
const url = URL.createObjectURL(blob);
diff --git a/src/lib/recipe.ts b/src/lib/recipe.ts
new file mode 100644
index 0000000..4774565
--- /dev/null
+++ b/src/lib/recipe.ts
@@ -0,0 +1,148 @@
+/**
+ * The preset builder.
+ *
+ * This composes a Klarbild recipe and shows what it would run — it does not
+ * run anything. Generating on a public page would mean putting an API key on
+ * that page and paying for every curious visitor, and the picture it produced
+ * would tell you about the model rather than about Klarbild. What Klarbild
+ * decides is the part this does show: which tasks, which exact target size,
+ * and the preset you save once and reuse.
+ *
+ * The target pixels come from `engine/format.ts`, copied verbatim from the
+ * application, so the numbers here are the numbers it would produce.
+ */
+
+import { resolveDimensions } from '../engine/format';
+
+export interface PresetExample {
+ name: string;
+ tasks: string[];
+ format: string;
+ orientation: 'portrait' | 'landscape';
+ ext?: string;
+ note: string;
+}
+
+interface Labels {
+ resultKeep: string;
+ contourNeedsCutout: string;
+ dpiLine: string; // "{w} × {h} px at {dpi} dpi"
+}
+
+const ALL_TASKS = ['clean', 'cutout', 'format', 'contour'];
+
+export function mountRecipe(root: HTMLElement, presets: PresetExample[], labels: Labels) {
+ const state = {
+ tasks: new Set(presets[0]?.tasks ?? ['clean']),
+ format: presets[0]?.format ?? 'keep',
+ orientation: (presets[0]?.orientation ?? 'landscape') as 'portrait' | 'landscape',
+ ext: presets[0]?.ext ?? '',
+ name: presets[0]?.name ?? '',
+ note: presets[0]?.note ?? '',
+ };
+
+ const out = {
+ result: root.querySelector('[data-r=result]')!,
+ note: root.querySelector('[data-r=note]')!,
+ recipe: root.querySelector('[data-r=recipe]')!,
+ };
+
+ function dims() {
+ if (state.format === 'keep') return null;
+ const customCm: [number, number] | undefined = state.format === 'sticker' ? [5, 5] : undefined;
+ try {
+ return resolveDimensions({ format: state.format, orientation: state.orientation, dpi: 300, customCm });
+ } catch {
+ return null;
+ }
+ }
+
+ function render() {
+ // Contour without Cut out is refused by the application, so the builder
+ // refuses it too rather than composing a recipe that would be rejected.
+ if (state.tasks.has('contour') && !state.tasks.has('cutout')) state.tasks.delete('contour');
+
+ for (const b of root.querySelectorAll('button[data-task]')) {
+ const id = b.dataset.task!;
+ b.setAttribute('aria-pressed', String(state.tasks.has(id)));
+ b.disabled = id === 'contour' && !state.tasks.has('cutout');
+ }
+ for (const b of root.querySelectorAll('button[data-orient]')) {
+ b.setAttribute('aria-pressed', String(b.dataset.orient === state.orientation));
+ }
+ for (const b of root.querySelectorAll('button[data-ext]')) {
+ b.setAttribute('aria-pressed', String((b.dataset.ext || '') === state.ext));
+ }
+ const sel = root.querySelector('#recipe-format');
+ if (sel && sel.value !== state.format) sel.value = state.format;
+
+ const d = dims();
+ out.result.textContent = d
+ ? labels.dpiLine.replace('{w}', String(d.w)).replace('{h}', String(d.h)).replace('{dpi}', '300')
+ : labels.resultKeep;
+
+ out.note.textContent = state.note;
+
+ const recipe: Record = {
+ name: state.name || '…',
+ tasks: ALL_TASKS.filter((t) => state.tasks.has(t)),
+ output_format: state.format,
+ orientation: state.orientation,
+ crop_mode: 'crop',
+ dpi: 300,
+ };
+ if (state.ext) recipe.output_ext = state.ext;
+ if (state.tasks.has('contour')) recipe.contour_mm = 3;
+ out.recipe.textContent = JSON.stringify(recipe, null, 2);
+ }
+
+ root.addEventListener('click', (e) => {
+ const el = e.target as HTMLElement;
+ const task = el.closest('button[data-task]');
+ if (task && !task.disabled) {
+ const id = task.dataset.task!;
+ state.tasks.has(id) ? state.tasks.delete(id) : state.tasks.add(id);
+ state.name = '';
+ state.note = '';
+ return render();
+ }
+ const orient = el.closest('button[data-orient]');
+ if (orient) {
+ state.orientation = orient.dataset.orient as 'portrait' | 'landscape';
+ return render();
+ }
+ const ext = el.closest('button[data-ext]');
+ if (ext) {
+ state.ext = ext.dataset.ext || '';
+ return render();
+ }
+ const preset = el.closest('button[data-preset]');
+ if (preset) {
+ const p = presets[Number(preset.dataset.preset)];
+ state.tasks = new Set(p.tasks);
+ state.format = p.format;
+ state.orientation = p.orientation;
+ state.ext = p.ext ?? '';
+ state.name = p.name;
+ state.note = p.note;
+ for (const sib of root.querySelectorAll('button[data-preset]')) {
+ sib.setAttribute('aria-pressed', String(sib === preset));
+ }
+ return render();
+ }
+ });
+
+ root.addEventListener('change', (e) => {
+ const sel = e.target as HTMLSelectElement;
+ if (sel.id === 'recipe-format') {
+ state.format = sel.value;
+ state.name = '';
+ state.note = '';
+ render();
+ }
+ });
+
+ const first = root.querySelector('button[data-preset="0"]');
+ first?.setAttribute('aria-pressed', 'true');
+ render();
+}
diff --git a/src/pages/de/index.astro b/src/pages/de/index.astro
new file mode 100644
index 0000000..f9dc249
--- /dev/null
+++ b/src/pages/de/index.astro
@@ -0,0 +1,5 @@
+---
+import Page from '../../components/Page.astro';
+import { DE } from '../../lib/copy';
+---
+
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 9a34d8d..48db425 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,363 +1,5 @@
---
-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 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 A1 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.' },
-];
+import Page from '../components/Page.astro';
+import { EN } from '../lib/copy';
---
-
-
-
-
-
-
-
- Klarbild — print pictures at the size you actually asked for
-
-
-
-
-
-
-
-
-
-
-
-