Demo and product page for Klarbild

A static Astro page. The sheet playground imports the print engine
(printlayout.ts, paper.ts) verbatim from the Klarbild repository and runs it in
the browser, so the layout on screen is the application's own arithmetic rather
than a mock-up. The PDF it offers is drawn from the same placements with pdf-lib.

No backend, nothing uploaded, no third-party requests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
This commit is contained in:
tillandClaude Opus 5 committed 2026-09-15 14:59:59 +00:00
commit 181060c32d
19 files changed
+7309

No files matched your search

+365
View File
@@ -0,0 +1,365 @@
---
import '@fontsource/space-grotesk/400.css';
import '@fontsource/space-grotesk/500.css';
import '@fontsource/space-grotesk/700.css';
import '@fontsource/space-mono/400.css';
import '@fontsource/space-mono/700.css';
import '../styles/tokens.css';
import '../styles/site.css';
const GITHUB = 'https://github.com/tillheidrich/klarbild';
const GITEA = 'https://git.heidrich-digital.de/till/klarbild-oss';
const modules = [
{ id: 'print', title: 'Print studio', core: true,
what: 'Exact physical sizes, sheet layout with crop marks, bleed, tiling, 1:1 PDF.',
need: 'Nothing. Always on.' },
{ id: 'ai', title: 'Image generation',
what: 'Clean up a screenshot, convert a photo, combine several, generate from text.',
need: 'An OpenRouter key. Paid per image.' },
{ id: 'delivery', title: 'Delivery',
what: 'Push finished files to an SFTP or FTPS server, plus extra targets per folder.',
need: 'A server you already have.' },
{ id: 'mirror', title: 'Backup mirror',
what: 'A second copy of every result, filed by month.',
need: 'A second SFTP/FTPS target.' },
{ id: 'share', title: 'Share links',
what: 'Public links that expire, can be revoked, can carry a passphrase, and ZIP up a whole set.',
need: 'A publicly reachable address.' },
{ id: 'mail', title: 'Email',
what: 'Password reset, job notices, sending share links.',
need: 'Your own SMTP mailbox.' },
{ id: 'telegram', title: 'Telegram bot',
what: 'The whole tool from a chat window instead of a browser.',
need: 'A bot token from BotFather.' },
];
const facts = [
{ h: '86 image sizes', p: 'In ten groups — passport photo, small prints, photo formats, US inches, instant-camera, poster and frame sizes, squares, DIN, cards, plain aspect ratios. Or type your own: 25x35, 13x18, 10x7.5.' },
{ h: '19 paper formats', p: 'A6 to A2 including A3+, photo paper from 9 × 13 to 20 × 30 cm, US Letter and Legal — or a custom sheet.' },
{ h: 'Crop marks', p: 'Corner marks at 0.25 pt outside the trim, or continuous lines when every cell is the same size. Bleed 0 to 10 mm.' },
{ h: 'Oversize, handled', p: 'A 20 × 30 print on A4 is not an error. Fit tells you it will not go; overflow centres it over the whole sheet and quantifies the loss first; tile spreads it over several sheets with a glue flap and a page number.' },
{ h: 'Crop or letterbox', p: 'When a picture does not match the target ratio you choose per picture — crop with a zoom-and-pan preview, or a border in a colour you pick. Nothing is ever stretched.' },
{ h: 'One engine', p: 'The browser preview and the server-side PDF run the same arithmetic. That file is printlayout.ts, it has no dependencies, and it has its own tests.' },
];
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#EAEAE3" />
<title>Klarbild — print pictures at the size you actually asked for</title>
<meta name="description" content="Self-hosted print studio. Put pictures on paper at exact physical sizes, with crop marks, bleed and tiling, and a PDF page that measures what you asked for. Open source, AGPL-3.0. Image generation and delivery are optional modules." />
<link rel="canonical" href="https://klarbild-demo.heidrich-digital.de/" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Klarbild — print pictures at the size you actually asked for" />
<meta property="og:description" content="A self-hosted print studio with a sheet layout engine you can try in your browser. Open source, AGPL-3.0." />
<meta property="og:image" content="https://klarbild-demo.heidrich-digital.de/shots/print-studio.png" />
<meta property="og:url" content="https://klarbild-demo.heidrich-digital.de/" />
<meta name="twitter:card" content="summary_large_image" />
</head>
<body>
<header class="top">
<div class="shell top-in">
<a class="brand" href="/"><span class="regmark"></span>klarbild</a>
<nav>
<a class="hide-s" href="#try">Try it</a>
<a class="hide-s" href="#modules">Modules</a>
<a class="hide-s" href="#install">Install</a>
<a class="cta" href={GITHUB} rel="noopener">Source</a>
</nav>
</div>
</header>
<main>
<div class="hero shell">
<div class="hero-grid">
<div>
<span class="kicker">Self-hosted · AGPL-3.0</span>
<h1>Print pictures at the size you <span>actually asked for</span>.</h1>
<p class="lead">
Your printer driver has a checkbox called “fit to page”. Tick it and a
35 × 45 mm passport photo comes out 34 × 44. Untick it and you are guessing.
Klarbild lays the sheet out in millimetres and hands you a PDF that measures
what it says.
</p>
<div class="btnrow">
<a class="btn primary" href="#try">Lay out a sheet</a>
<a class="btn" href={GITHUB} rel="noopener">Read the source</a>
</div>
</div>
<aside class="hero-side">
<dl>
<dt class="num">210.00</dt><dd>× 297.00 mm on the page when you ask for A4 — measured, not assumed</dd>
<dt class="num">0</dt><dd>outbound requests from the print studio. It is arithmetic, sharp and pdf-lib</dd>
<dt class="num">124</dt><dd>tests over the layout engine, the naming scheme and the path checks</dd>
<dt class="num">1</dt><dd>container and a database for the default install</dd>
</dl>
</aside>
</div>
</div>
<!-- ------------------------------------------------------- playground -->
<div class="pg shell" id="try">
<div class="pg-shell" id="playground">
<div class="pg-head">
<h2>Lay out a sheet</h2>
<p class="fine">Runs the application's own layout code, here in your browser. Nothing is uploaded.</p>
</div>
<div class="pg-body">
<div class="pg-controls">
<div class="field">
<label for="paper">Paper</label>
<select id="paper"></select>
</div>
<div class="field">
<label>Sheet orientation</label>
<div class="seg">
<button type="button" data-set="orientation:portrait" aria-pressed="true">Portrait</button>
<button type="button" data-set="orientation:landscape" aria-pressed="false">Landscape</button>
</div>
</div>
<div class="field">
<label for="size">Picture size</label>
<select id="size"></select>
</div>
<div class="row2">
<div class="field">
<label for="count">How many</label>
<input id="count" type="number" min="1" max="200" value="8" />
</div>
<div class="field">
<label>Cutting aid</label>
<div class="seg">
<button type="button" data-set="marks:none" aria-pressed="false">None</button>
<button type="button" data-set="marks:corner" aria-pressed="true">Corner</button>
<button type="button" data-set="marks:grid" aria-pressed="false">Lines</button>
</div>
</div>
</div>
<div class="field">
<div class="slider-head"><label for="bleed">Bleed</label><span data-val="bleed">0 mm</span></div>
<input id="bleed" type="range" min="0" max="10" step="1" value="0" />
</div>
<div class="field">
<div class="slider-head"><label for="margin">Printer margin</label><span data-val="margin">5 mm</span></div>
<input id="margin" type="range" min="0" max="20" step="1" value="5" />
</div>
<div class="toggle">
<button type="button" data-set="rotate:toggle" aria-pressed="true" aria-label="Rotate pictures to save space"></button>
<span>Rotate pictures to save space</span>
</div>
</div>
<div class="pg-stage">
<div class="stage-sheet">
<svg id="sheet" viewBox="0 0 210 297" role="img" aria-label="Sheet preview"></svg>
</div>
<dl class="readout">
<div><dt>Fit</dt><dd data-out="fits">–</dd></div>
<div><dt>Sheets</dt><dd data-out="pages">–</dd></div>
<div><dt>Cell</dt><dd data-out="cell">–</dd></div>
<div><dt>Paper</dt><dd data-out="paper">–</dd></div>
<div><dt>Gap</dt><dd data-out="gap">–</dd></div>
</dl>
<p class="note" data-out="note" data-tone="ok"></p>
</div>
</div>
<div class="pg-foot">
<button class="btn primary" id="download" type="button">Download this sheet as a PDF</button>
<p>
The demo draws outlines instead of photographs — the real thing puts your
pictures in these boxes at the resolution you choose. Measure the PDF: the page
and every box are exactly the millimetres shown above.
</p>
</div>
</div>
</div>
<!-- ---------------------------------------------------------- what -->
<section id="what">
<div class="shell">
<span class="kicker">The print studio</span>
<h2>Everything a photo shop's software does, minus the photo shop.</h2>
<div class="facts">
{facts.map((f) => (
<div><h3>{f.h}</h3><p>{f.p}</p></div>
))}
</div>
</div>
</section>
<section>
<div class="shell zig">
<div class="prose">
<span class="kicker">In the application</span>
<h2>Eight passport photos, one sheet, one cut line each.</h2>
<p class="lead">
Pick the size, say how many, and the sheet fills itself. Equal sizes snap to an
exact grid; mixed sizes go through a packer. Every cell carries its measurement
as a caption, and the caption gets out of the way when there is no room at that edge.
</p>
<p>
The same picture may appear twice at two different sizes. Layouts you repeat become
presets. The last hundred runs are kept as <em>instructions</em> rather than as PDFs —
one to three kilobytes each instead of eighty megabytes — so “print that again” costs
nothing.
</p>
</div>
<figure>
<img src="/shots/print-studio.png" width="2800" height="1800"
alt="The Klarbild print studio with eight passport photos laid out on an A4 sheet, each with corner marks" />
<figcaption>The print studio. Print-only install — no library, no queue, no model.</figcaption>
</figure>
</div>
</section>
<section>
<div class="shell zig flip">
<div class="prose">
<span class="kicker">Two languages</span>
<h2>English by default, German all the way through.</h2>
<p class="lead">
Not a half-translated interface: 1,038 strings, both complete. The English
sentence is the translation key, so a missing entry falls back to readable
English rather than to <code>print.sheet.title</code>.
</p>
<p>
One environment variable sets the default; visitors switch with the button in the
corner and the choice sticks. Adding a third language means copying one JSON file
and translating its values.
</p>
</div>
<figure>
<img src="/shots/print-de.png" width="2800" height="1800"
alt="The same print studio screen rendered in German" />
<figcaption>The same screen, <code>?lang=de</code>.</figcaption>
</figure>
</div>
</section>
<!-- ------------------------------------------------------- modules -->
<section id="modules">
<div class="shell">
<span class="kicker">Modules</span>
<h2>You decide what this install is.</h2>
<p class="lead prose">
Klarbild is one codebase but not one product. The print studio is the core.
Everything that needs an account somewhere else is a module, and every module is
off until you switch it on. A module that is off has no navigation entry, no
settings card, and its API routes answer 404 rather than 403 — a disabled instance
does not advertise what it could have done.
</p>
<div class="mods">
{modules.map((m) => (
<div class:list={['mod', { core: m.core }]}>
<code>{m.id}</code>
<p><strong>{m.title}</strong> — {m.what}</p>
<span class="need">{m.need}</span>
</div>
))}
</div>
<div style="margin-top:28px; max-width:56ch;">
<pre><span class="c"># the default: the print studio and nothing else</span>
KLARBILD_MODULES=print
<span class="c"># pick what you want</span>
KLARBILD_MODULES=print,ai,share
<span class="c"># everything</span>
KLARBILD_MODULES=all</pre>
</div>
</div>
</section>
<!-- ------------------------------------------------------- install -->
<section id="install">
<div class="shell split-code">
<div class="prose">
<span class="kicker">Install</span>
<h2>Four lines, then a password in the log.</h2>
<p>
The default compose file runs the application and a PostgreSQL, with files on a
volume. No object store, no API key, no account anywhere. A second compose file
adds MinIO and turns every module on, for people who want that.
</p>
<p>
There are no built-in accounts. The first boot creates one administrator — from
<code>ADMIN_USERNAME</code> and <code>ADMIN_PASSWORD</code> if you set them, and
otherwise with a generated password printed to the log exactly once.
</p>
<div class="btnrow">
<a class="btn primary" href={GITHUB} rel="noopener">GitHub</a>
<a class="btn" href={GITEA} rel="noopener">Gitea</a>
</div>
</div>
<div>
<pre>git clone {GITHUB}.git
cd klarbild
cp .env.example .env <span class="c"># three secrets, the file says which</span>
docker compose up -d
docker compose logs app <span class="c"># your admin password, printed once</span></pre>
<p style="font-size:.84rem; color:var(--ink-soft); margin-top:14px;">
Astro 5 on Node, PostgreSQL, sharp and pdf-lib. About 13,600 lines across 116
files. Sixteen forward-only migrations that run themselves on boot. Type check,
tests and a secret scan run in CI on every push.
</p>
</div>
</div>
</section>
<section>
<div class="shell prose">
<span class="kicker">Why self-hosted</span>
<h2>These are passport photos and family pictures.</h2>
<p class="lead">
The print studio never makes an outbound request at all. With the AI module on, only
what you hand the model leaves the building, and the key is yours.
</p>
<p>
Share links are built to be careful rather than convenient: they expire after thirty
days by default, can be revoked, can carry a passphrase, and are <code>noindex</code>.
Access hangs on the link rather than on the picture, so guessing an image id returns
a 404. The view counter records <em>when</em> and <em>with what</em>, never <em>who</em> —
the address is hashed with a server secret and the link id, one row per visitor per
hour, deleted after ninety days.
</p>
</div>
</section>
</main>
<footer>
<div class="shell foot-in">
<a class="brand" href="/"><span class="regmark"></span>klarbild</a>
<a href={GITHUB} rel="noopener">GitHub</a>
<a href={GITEA} rel="noopener">Gitea</a>
<a href={`${GITHUB}/blob/main/LICENSE`} rel="noopener">AGPL-3.0</a>
<span class="fine">
A demo page. The layout engine is real; the pictures are outlines. Built by
<a href="https://heidrich-digital.de" rel="noopener">Heidrich Digital</a>.
</span>
</div>
</footer>
<script>
import { mountPlayground } from '../lib/playground';
const root = document.getElementById('playground');
if (root) mountPlayground(root);
</script>
</body>
</html>