Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71ffedfaa4 | ||
|
|
cfe0c91e0f |
No files matched your search
@@ -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,8 @@ 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
|
||||
```
|
||||
|
||||
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 168 KiB |
@@ -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';
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={c.htmlLang}>
|
||||
<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>{c.title}</title>
|
||||
<meta name="description" content={c.description} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
<link rel="alternate" hreflang="en" href={`${SITE}/`} />
|
||||
<link rel="alternate" hreflang="de" href={`${SITE}/de/`} />
|
||||
<link rel="alternate" hreflang="x-default" href={`${SITE}/`} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={c.title} />
|
||||
<meta property="og:description" content={c.description} />
|
||||
<meta property="og:image" content={`${SITE}/shots/print-studio.png`} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:locale" content={c.htmlLang === 'de' ? 'de_DE' : 'en_GB'} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<div class="shell top-in">
|
||||
<a class="brand" href={c.htmlLang === 'de' ? '/de/' : '/'}><span class="regmark"></span>klarbild</a>
|
||||
<nav>
|
||||
<a class="hide-s" href="#try">{c.nav.try}</a>
|
||||
<a class="hide-s" href="#ai">{c.nav.ai}</a>
|
||||
<a class="hide-m" href="#modules">{c.nav.modules}</a>
|
||||
<a class="hide-m" href="#install">{c.nav.install}</a>
|
||||
<a class="langswitch" href={c.otherHref} hreflang={c.otherLabel.toLowerCase()}
|
||||
title={c.otherLabel === 'DE' ? 'Diese Seite auf Deutsch' : 'This page in English'}>{c.otherLabel}</a>
|
||||
<a class="cta" href={GITHUB} rel="noopener">{c.nav.source}</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="hero shell">
|
||||
<div class="hero-grid">
|
||||
<div>
|
||||
<span class="kicker">{c.hero.kicker}</span>
|
||||
<h1>{c.hero.h1a}<span>{c.hero.h1accent}</span>.</h1>
|
||||
<p class="lead">{c.hero.lead}</p>
|
||||
<div class="btnrow">
|
||||
<a class="btn primary" href="#try">{c.hero.ctaPrimary}</a>
|
||||
<a class="btn" href={GITHUB} rel="noopener">{c.hero.ctaSecondary}</a>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="hero-side">
|
||||
<dl>
|
||||
{c.hero.stats.map((s) => (<><dt class="num">{s.n}</dt><dd>{s.t}</dd></>))}
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ----------------------------------------------------- sheet playground -->
|
||||
<div class="pg shell" id="try">
|
||||
<div class="pg-shell" id="playground"
|
||||
data-busy={c.pg.downloadBusy}
|
||||
data-all-fit={c.pg.noteAllFit}
|
||||
data-all-fit-multi={c.pg.noteAllFitMulti}
|
||||
data-partial={c.pg.notePartial}
|
||||
data-none={c.pg.noteNone}>
|
||||
<div class="pg-head">
|
||||
<h2>{c.pg.title}</h2>
|
||||
<p class="fine">{c.pg.fine}</p>
|
||||
</div>
|
||||
|
||||
<div class="pg-body">
|
||||
<div class="pg-controls">
|
||||
<div class="field">
|
||||
<label for="paper">{c.pg.paper}</label>
|
||||
<select id="paper"></select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>{c.pg.orientation}</label>
|
||||
<div class="seg">
|
||||
<button type="button" data-set="orientation:portrait" aria-pressed="true">{c.pg.portrait}</button>
|
||||
<button type="button" data-set="orientation:landscape" aria-pressed="false">{c.pg.landscape}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="size">{c.pg.size}</label>
|
||||
<select id="size"></select>
|
||||
</div>
|
||||
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label for="count">{c.pg.count}</label>
|
||||
<input id="count" type="number" min="1" max="200" value="8" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{c.pg.marks}</label>
|
||||
<div class="seg">
|
||||
<button type="button" data-set="marks:none" aria-pressed="false">{c.pg.marksNone}</button>
|
||||
<button type="button" data-set="marks:corner" aria-pressed="true">{c.pg.marksCorner}</button>
|
||||
<button type="button" data-set="marks:grid" aria-pressed="false">{c.pg.marksGrid}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="slider-head"><label for="bleed">{c.pg.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">{c.pg.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={c.pg.rotate}></button>
|
||||
<span>{c.pg.rotate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pg-stage">
|
||||
<div class="stage-sheet">
|
||||
<svg id="sheet" viewBox="0 0 210 297" role="img" aria-label={c.pg.title}></svg>
|
||||
</div>
|
||||
|
||||
<dl class="readout">
|
||||
<div><dt>{c.pg.outFit}</dt><dd data-out="fits">–</dd></div>
|
||||
<div><dt>{c.pg.outSheets}</dt><dd data-out="pages">–</dd></div>
|
||||
<div><dt>{c.pg.outCell}</dt><dd data-out="cell">–</dd></div>
|
||||
<div><dt>{c.pg.outPaper}</dt><dd data-out="paper">–</dd></div>
|
||||
<div><dt>{c.pg.outGap}</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">{c.pg.download}</button>
|
||||
<p>{c.pg.footnote}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ----------------------------------------------------------------- what -->
|
||||
<section id="what">
|
||||
<div class="shell">
|
||||
<span class="kicker">{c.what.kicker}</span>
|
||||
<h2>{c.what.h2}</h2>
|
||||
<div class="facts">
|
||||
{c.what.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">{c.shot1.kicker}</span>
|
||||
<h2>{c.shot1.h2}</h2>
|
||||
<p class="lead">{c.shot1.lead}</p>
|
||||
<p>{c.shot1.body}</p>
|
||||
</div>
|
||||
<figure>
|
||||
<img src="/shots/print-studio.png" width="2800" height="1800" loading="lazy" alt={c.shot1.h2} />
|
||||
<figcaption>{c.shot1.caption}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- --------------------------------------------------- image generation -->
|
||||
<section id="ai">
|
||||
<div class="shell">
|
||||
<span class="kicker">{c.ai.kicker}</span>
|
||||
<h2 class="prose">{c.ai.h2}</h2>
|
||||
<div class="zig" style="margin-top:26px;">
|
||||
<div class="prose">
|
||||
<p class="lead">{c.ai.lead}</p>
|
||||
<p>{c.ai.body}</p>
|
||||
</div>
|
||||
<figure>
|
||||
<img src="/shots/studio.png" width="2800" height="1800" loading="lazy" alt={c.ai.caption} />
|
||||
<figcaption>{c.ai.caption}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<div class="pg-shell recipe" id="recipe"
|
||||
data-keep={c.ai.resultKeep}
|
||||
data-dpi-line={dpiLine}
|
||||
data-presets={JSON.stringify(c.ai.presets)}>
|
||||
<div class="pg-head">
|
||||
<h2>{c.ai.builderTitle}</h2>
|
||||
<p class="fine">{c.ai.builderFine}</p>
|
||||
</div>
|
||||
|
||||
<div class="recipe-body">
|
||||
<div class="recipe-controls">
|
||||
<div class="field">
|
||||
<label>{c.ai.presetLabel}</label>
|
||||
<div class="chips">
|
||||
{c.ai.presets.map((p, i) => (
|
||||
<button type="button" data-preset={i} aria-pressed="false">{p.name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>{c.ai.tasksLabel}</label>
|
||||
<div class="tasklist">
|
||||
{c.ai.tasks.map((t) => (
|
||||
<button type="button" data-task={t.id} aria-pressed="false">
|
||||
<b>{t.name}</b><em>{t.hint}</em>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row2">
|
||||
<div class="field">
|
||||
<label for="recipe-format">{c.ai.formatLabel}</label>
|
||||
<select id="recipe-format">
|
||||
{FORMATS.map((f) => (<option value={f.id}>{f.label}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{c.ai.orientationLabel}</label>
|
||||
<div class="seg">
|
||||
<button type="button" data-orient="portrait" aria-pressed="false">{c.pg.portrait}</button>
|
||||
<button type="button" data-orient="landscape" aria-pressed="true">{c.pg.landscape}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>{c.ai.extLabel}</label>
|
||||
<div class="seg">
|
||||
<button type="button" data-ext="" aria-pressed="true">Auto</button>
|
||||
<button type="button" data-ext="png" aria-pressed="false">PNG</button>
|
||||
<button type="button" data-ext="jpg" aria-pressed="false">JPG</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recipe-out">
|
||||
<div class="readout single">
|
||||
<div><dt>{c.ai.resultLabel}</dt><dd data-r="result">–</dd></div>
|
||||
</div>
|
||||
<p class="note" data-r="note" data-tone="ok"></p>
|
||||
<div class="field">
|
||||
<label>{c.ai.recipeLabel}</label>
|
||||
<pre data-r="recipe"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pg-foot">
|
||||
<p>{c.ai.honest}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="shell zig flip">
|
||||
<div class="prose">
|
||||
<span class="kicker">{c.lang.kicker}</span>
|
||||
<h2>{c.lang.h2}</h2>
|
||||
<p class="lead">{c.lang.lead}</p>
|
||||
<p>{c.lang.body}</p>
|
||||
</div>
|
||||
<figure>
|
||||
<img src="/shots/print-de.png" width="2800" height="1800" loading="lazy" alt={c.lang.h2} />
|
||||
<figcaption>{c.lang.caption}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- -------------------------------------------------------------- modules -->
|
||||
<section id="modules">
|
||||
<div class="shell">
|
||||
<span class="kicker">{c.modules.kicker}</span>
|
||||
<h2>{c.modules.h2}</h2>
|
||||
<p class="lead prose">{c.modules.lead}</p>
|
||||
|
||||
<div class="mods">
|
||||
{c.modules.rows.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 class="mod-after">
|
||||
<p class="prose">{c.modules.off}</p>
|
||||
<pre><span class="c">{c.modules.envComment1}</span>
|
||||
KLARBILD_MODULES=print
|
||||
|
||||
<span class="c">{c.modules.envComment2}</span>
|
||||
KLARBILD_MODULES=print,ai,share
|
||||
|
||||
<span class="c">{c.modules.envComment3}</span>
|
||||
KLARBILD_MODULES=all</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- -------------------------------------------------------------- install -->
|
||||
<section id="install">
|
||||
<div class="shell split-code">
|
||||
<div class="prose">
|
||||
<span class="kicker">{c.install.kicker}</span>
|
||||
<h2>{c.install.h2}</h2>
|
||||
<p>{c.install.p1}</p>
|
||||
<p>{c.install.p2}</p>
|
||||
<div class="btnrow">
|
||||
<a class="btn primary" href={GITHUB} rel="noopener">{c.install.cta1}</a>
|
||||
<a class="btn" href={`${GITHUB}/releases`} rel="noopener">{c.install.cta2}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<pre>git clone {GITHUB}.git
|
||||
cd klarbild
|
||||
cp .env.example .env <span class="c">{c.install.comment1}</span>
|
||||
docker compose up -d
|
||||
|
||||
docker compose logs app <span class="c">{c.install.comment2}</span></pre>
|
||||
<p class="stackline">{c.install.stack}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="shell prose">
|
||||
<span class="kicker">{c.privacy.kicker}</span>
|
||||
<h2>{c.privacy.h2}</h2>
|
||||
<p class="lead">{c.privacy.lead}</p>
|
||||
<p>{c.privacy.body}</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="shell foot-in">
|
||||
<a class="brand" href={c.htmlLang === 'de' ? '/de/' : '/'}><span class="regmark"></span>klarbild</a>
|
||||
<a href={GITHUB} rel="noopener">GitHub</a>
|
||||
<a href={`${GITHUB}/blob/main/LICENSE`} rel="noopener">AGPL-3.0</a>
|
||||
<a href={c.otherHref}>{c.otherLabel === 'DE' ? 'Deutsch' : 'English'}</a>
|
||||
<span class="fine">
|
||||
{c.footer.fine} <a href="https://heidrich-digital.de" rel="noopener">Heidrich Digital</a>.
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
import { mountPlayground } from '../lib/playground';
|
||||
import { mountRecipe } from '../lib/recipe';
|
||||
|
||||
const pg = document.getElementById('playground');
|
||||
if (pg) {
|
||||
mountPlayground(pg, {
|
||||
downloadBusy: pg.dataset.busy!,
|
||||
noteAllFit: pg.dataset.allFit!,
|
||||
noteAllFitMulti: pg.dataset.allFitMulti!,
|
||||
notePartial: pg.dataset.partial!,
|
||||
noteNone: pg.dataset.none!,
|
||||
});
|
||||
}
|
||||
|
||||
const rc = document.getElementById('recipe');
|
||||
if (rc) {
|
||||
mountRecipe(rc, JSON.parse(rc.dataset.presets!), {
|
||||
resultKeep: rc.dataset.keep!,
|
||||
contourNeedsCutout: '',
|
||||
dpiLine: rc.dataset.dpiLine!,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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<string, [number, number]> = {
|
||||
'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<string, [number, number]> = {
|
||||
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;
|
||||
}
|
||||
@@ -128,7 +128,7 @@ export const PHOTO_SIZES: PhotoSize[] = [
|
||||
{ id: 'C70x100', label: 'Bookmark 7 × 21 cm', w: 70, h: 210, group: 'Cards' },
|
||||
|
||||
// --- Aspect ratios as a print size (worked out on 30 cm) ---
|
||||
{ id: 'W16x9', label: '16:9 "The Frame" (30 cm wide)', w: 300, h: 168.75, group: 'Aspect ratio' },
|
||||
{ id: 'W16x9', label: '16:9 TV 16:9 (30 cm wide)', w: 300, h: 168.75, group: 'Aspect ratio' },
|
||||
{ id: 'W9x16', label: '9:16 portrait (30 cm tall)', w: 168.75, h: 300, group: 'Aspect ratio' },
|
||||
{ id: 'W21x9', label: '21:9 cinema (30 cm wide)', w: 300, h: 128.57, group: 'Aspect ratio' },
|
||||
{ id: 'W2x1', label: '2:1 panorama (30 cm wide)', w: 300, h: 150, group: 'Aspect ratio' },
|
||||
|
||||
@@ -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<Lang, Copy> = { en: EN, de: DE };
|
||||
@@ -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<Blob> {
|
||||
|
||||
/* ---------------------------------------------------------------------- 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<SVGSVGElement>('#sheet')!;
|
||||
const out = {
|
||||
fits: root.querySelector<HTMLElement>('[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);
|
||||
|
||||
@@ -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<HTMLElement>('[data-r=result]')!,
|
||||
note: root.querySelector<HTMLElement>('[data-r=note]')!,
|
||||
recipe: root.querySelector<HTMLElement>('[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<HTMLButtonElement>('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<HTMLButtonElement>('button[data-orient]')) {
|
||||
b.setAttribute('aria-pressed', String(b.dataset.orient === state.orientation));
|
||||
}
|
||||
for (const b of root.querySelectorAll<HTMLButtonElement>('button[data-ext]')) {
|
||||
b.setAttribute('aria-pressed', String((b.dataset.ext || '') === state.ext));
|
||||
}
|
||||
const sel = root.querySelector<HTMLSelectElement>('#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<string, unknown> = {
|
||||
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<HTMLButtonElement>('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<HTMLButtonElement>('button[data-orient]');
|
||||
if (orient) {
|
||||
state.orientation = orient.dataset.orient as 'portrait' | 'landscape';
|
||||
return render();
|
||||
}
|
||||
const ext = el.closest<HTMLButtonElement>('button[data-ext]');
|
||||
if (ext) {
|
||||
state.ext = ext.dataset.ext || '';
|
||||
return render();
|
||||
}
|
||||
const preset = el.closest<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('button[data-preset="0"]');
|
||||
first?.setAttribute('aria-pressed', 'true');
|
||||
render();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import Page from '../../components/Page.astro';
|
||||
import { DE } from '../../lib/copy';
|
||||
---
|
||||
<Page copy={DE} canonical="https://klarbild-demo.heidrich-digital.de/de/" />
|
||||
@@ -1,365 +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 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.' },
|
||||
];
|
||||
import Page from '../components/Page.astro';
|
||||
import { EN } from '../lib/copy';
|
||||
---
|
||||
|
||||
<!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>
|
||||
<Page copy={EN} canonical="https://klarbild-demo.heidrich-digital.de/" />
|
||||
@@ -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; }
|
||||