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'; +--- + + + + + + + + {c.title} + + + + + + + + + + + + + + +
+
+ klarbild + +
+
+ +
+
+
+
+ {c.hero.kicker} +

{c.hero.h1a}{c.hero.h1accent}.

+

{c.hero.lead}

+ +
+ +
+
+ + +
+
+
+

{c.pg.title}

+

{c.pg.fine}

+
+ +
+
+
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ +
+ + + +
+
+
+ +
+
0 mm
+ +
+ +
+
5 mm
+ +
+ +
+ + {c.pg.rotate} +
+
+ +
+
+ +
+ +
+
{c.pg.outFit}
–
+
{c.pg.outSheets}
–
+
{c.pg.outCell}
–
+
{c.pg.outPaper}
–
+
{c.pg.outGap}
–
+
+ +

+
+
+ +
+ +

{c.pg.footnote}

+
+
+
+ + +
+
+ {c.what.kicker} +

{c.what.h2}

+
+ {c.what.facts.map((f) => (

{f.h}

{f.p}

))} +
+
+
+ +
+
+
+ {c.shot1.kicker} +

{c.shot1.h2}

+

{c.shot1.lead}

+

{c.shot1.body}

+
+
+ {c.shot1.h2} +
{c.shot1.caption}
+
+
+
+ + +
+
+ {c.ai.kicker} +

{c.ai.h2}

+
+
+

{c.ai.lead}

+

{c.ai.body}

+
+
+ {c.ai.caption} +
{c.ai.caption}
+
+
+ +
+
+

{c.ai.builderTitle}

+

{c.ai.builderFine}

+
+ +
+
+
+ +
+ {c.ai.presets.map((p, i) => ( + + ))} +
+
+ +
+ +
+ {c.ai.tasks.map((t) => ( + + ))} +
+
+ +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ +
+ + + +
+
+
+ +
+
+
{c.ai.resultLabel}
–
+
+

+
+ +

+                
+
+
+ +
+

{c.ai.honest}

+
+
+
+
+ +
+
+
+ {c.lang.kicker} +

{c.lang.h2}

+

{c.lang.lead}

+

{c.lang.body}

+
+
+ {c.lang.h2} +
{c.lang.caption}
+
+
+
+ + +
+
+ {c.modules.kicker} +

{c.modules.h2}

+

{c.modules.lead}

+ +
+ {c.modules.rows.map((m) => ( +
+ {m.id} +

{m.title} — {m.what}

+ {m.need} +
+ ))} +
+ +
+

{c.modules.off}

+
{c.modules.envComment1}
+KLARBILD_MODULES=print
+
+{c.modules.envComment2}
+KLARBILD_MODULES=print,ai,share
+
+{c.modules.envComment3}
+KLARBILD_MODULES=all
+
+
+
+ + +
+
+
+ {c.install.kicker} +

{c.install.h2}

+

{c.install.p1}

+

{c.install.p2}

+ +
+
+
git clone {GITHUB}.git
+cd klarbild
+cp .env.example .env    {c.install.comment1}
+docker compose up -d
+
+docker compose logs app {c.install.comment2}
+

{c.install.stack}

+
+
+
+ +
+
+ {c.privacy.kicker} +

{c.privacy.h2}

+

{c.privacy.lead}

+

{c.privacy.body}

+
+
+
+ + + + + + 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 - - - - - - - - - - -
- -
- -
-
-
-
- Self-hosted · AGPL-3.0 -

Print pictures at the size you actually asked for.

-

- 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. -

- -
- -
-
- - -
-
-
-

Lay out a sheet

-

Runs the application's own layout code, here in your browser. Nothing is uploaded.

-
- -
-
-
- - -
- -
- -
- - -
-
- -
- - -
- -
-
- - -
-
- -
- - - -
-
-
- -
-
0 mm
- -
- -
-
5 mm
- -
- -
- - Rotate pictures to save space -
-
- -
-
- -
- -
-
Fit
–
-
Sheets
–
-
Cell
–
-
Paper
–
-
Gap
–
-
- -

-
-
- -
- -

- 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. -

-
-
-
- - -
-
- The print studio -

Everything a photo shop's software does, minus the photo shop.

-
- {facts.map((f) => ( -

{f.h}

{f.p}

- ))} -
-
-
- -
-
-
- In the application -

Eight passport photos, one sheet, one cut line each.

-

- 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. -

-

- 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. -

-
-
- The Klarbild print studio with eight passport photos laid out on an A4 sheet, each with corner marks -
The print studio. Print-only install — no library, no queue, no model.
-
-
-
- -
-
-
- Two languages -

English by default, German all the way through.

-

- 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 print.sheet.title. -

-

- 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. -

-
-
- The same print studio screen rendered in German -
The same screen, ?lang=de.
-
-
-
- - -
-
- Modules -

You decide what this install is.

-

- 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. -

- -
- {modules.map((m) => ( -
- {m.id} -

{m.title} — {m.what}

- {m.need} -
- ))} -
- -
-
# the default: the print studio and nothing else
-KLARBILD_MODULES=print
-
-# pick what you want
-KLARBILD_MODULES=print,ai,share
-
-# everything
-KLARBILD_MODULES=all
-
-
-
- - -
-
-
- Install -

Four lines, then a password in the log.

-

- 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. -

-

- 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. -

- -
-
-
git clone {GITHUB}.git
-cd klarbild
-cp .env.example .env    # three secrets, the file says which
-docker compose up -d
-
-docker compose logs app # your admin password, printed once
-

- 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. -

-
-
-
- -
-
- Why self-hosted -

These are passport photos and family pictures.

-

- 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. -

-

- 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. -

-
-
-
- - - - - - + diff --git a/src/styles/site.css b/src/styles/site.css index 6bbd7c5..c8f7bef 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -202,3 +202,46 @@ 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%; } } + +/* ------------------------------------------------- language switch & nav bits */ + +.top nav a.langswitch { font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: .08em; + border: 1px solid var(--line); padding: 6px 9px; border-radius: 999px; line-height: 1; } +.top nav a.langswitch:hover { color: var(--accent); border-color: var(--accent); background: transparent; } +@media (max-width: 980px) { .top nav a.hide-m { display: none; } } + +/* ------------------------------------------------------------ preset builder */ + +.recipe { margin-top: clamp(28px, 4vw, 48px); } +.recipe-body { display: grid; grid-template-columns: minmax(0, 1fr); } +@media (min-width: 900px) { .recipe-body { grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr); } } +.recipe-controls { padding: 20px 18px; display: grid; gap: 18px; align-content: start; } +@media (min-width: 900px) { .recipe-controls { border-right: 1px solid var(--line); } } +.recipe-out { padding: 20px 18px; display: grid; gap: 14px; align-content: start; + background: repeating-linear-gradient(45deg, transparent 0 11px, oklch(21% 0.01 95 / 0.015) 11px 22px); } + +.chips { display: flex; flex-wrap: wrap; gap: 6px; } +.chips button { font-family: inherit; font-size: 0.82rem; padding: 7px 11px; cursor: pointer; + border: 1px solid var(--line); border-radius: 999px; background: var(--paper); color: var(--ink-soft); + transition: border-color .15s, color .15s, background .15s; } +.chips button:hover { color: var(--ink); border-color: var(--ink-soft); } +.chips button[aria-pressed=true] { background: var(--accent-bg); border-color: var(--accent); + color: var(--accent); font-weight: 600; } + +.tasklist { display: grid; gap: 6px; } +@media (min-width: 560px) { .tasklist { grid-template-columns: 1fr 1fr; } } +.tasklist button { display: grid; gap: 2px; text-align: left; font-family: inherit; cursor: pointer; + border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--paper); + padding: 10px 12px; transition: border-color .15s, background .15s; } +.tasklist button b { font-size: 0.92rem; color: var(--ink); font-weight: 600; } +.tasklist button em { font-style: normal; font-size: 0.78rem; color: var(--ink-soft); line-height: 1.4; } +.tasklist button[aria-pressed=true] { background: var(--accent-bg); border-color: var(--accent); } +.tasklist button[aria-pressed=true] b { color: var(--accent); } +.tasklist button[aria-pressed=true] b::after { content: ' ✓'; } +.tasklist button:disabled { opacity: .45; cursor: not-allowed; } + +.readout.single { grid-template-columns: minmax(0, 1fr); } +.recipe-out pre { font-size: 0.78rem; line-height: 1.65; max-height: 280px; } +.mod-after { display: grid; gap: 22px; margin-top: 26px; align-items: start; } +@media (min-width: 880px) { .mod-after { grid-template-columns: minmax(0, 1fr) minmax(0, 0.8fr); gap: 40px; } } +.stackline { font-size: .84rem; color: var(--ink-soft); margin-top: 14px; }