Files
klarbild/src/pages/vorstellung.astro
T
till 5ebe8026e0 feat: oversize sheets, image duplication, free labelling and a new layout
Prints larger than the sheet no longer just fail. Each cell can now spill over
the whole sheet (20x30 on A4 loses exactly 3 mm, and says so beforehand) or be
tiled across several sheets with a dashed glue fold and a sheet number. The
maths lives in printoversize.ts, dependency-free, so the browser preview and the
server PDF compute the same thing -- including the note strip that keeps the
sheet number off the picture.

The same image can now appear as several cells, so a portrait can be printed at
two sizes on one sheet without copying and re-uploading the file. The footer
line can be switched off, given custom text and placed at one of six positions;
it is only drawn where that edge is free.

Layout reworked for all three widths: work left, sticky preview right on the
desktop, collapsed setting groups paired in a two-column grid that expands full
width; preview first and a fixed action bar on the phone. Verified at 390, 820,
1440 and 1680 px -- no horizontal overflow, no dead white space.

Also a public presentation page at /vorstellung (no login, nothing operable),
whose sample sheet is computed by the real packer rather than drawn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
2026-08-20 10:55:11 +00:00

316 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
// Öffentliche Vorstellungsseite für das Druckmodul.
// Bewusst ohne Login und ohne Bedienelemente: Sie zeigt, was das Werkzeug kann,
// man kann es hier aber nicht benutzen. Gedacht als Landeseite für ein
// Open-Source-Release (Gitea/GitHub) und zum Herzeigen.
import Base from '../layouts/Base.astro';
import { PHOTO_SIZES, PAPERS } from '../lib/paper';
import { layout, cutMarks, type SheetSpec } from '../lib/printlayout';
const anzahlFormate = PHOTO_SIZES.length;
const anzahlPapiere = PAPERS.length;
const anzahlGruppen = new Set(PHOTO_SIZES.map((p) => p.group)).size;
const koennen = [
{
kicker: 'Maßhaltig',
titel: 'Millimeter statt „ungefähr“',
text: 'Jedes Endmaß wird in Millimetern gerechnet und erst beim Rendern in Pixel umgesetzt. ' +
'Das PDF hat exakt das Bogenmaß, jedes Bild darin exakt sein Zielmaß. Nachgemessen wird nicht geschätzt, ' +
'sondern gemessen: Wir rastern die fertigen PDFs mit 254 dpi und zählen die Pixelkanten nach.',
beleg: '13 × 18 cm → gemessen 130,1 × 180,1 mm',
},
{
kicker: 'Platz',
titel: 'Mehrere Bilder auf einen Bogen',
text: 'Gleich große Bilder kommen in ein exaktes Raster. Bei gemischten Formaten sucht ein Packer ' +
'(MaxRects, „Best Short Side Fit“) die Anordnung mit den wenigsten Bogen — und probiert dafür jede ' +
'Kombination aus Hoch- und Querlage durch. Ein Passbild-Satz und ein 13 × 18 passen so auf dasselbe Blatt.',
beleg: 'Kita-Satz: 9 Teile statt 3 auf einem A4-Bogen',
},
{
kicker: 'Schneiden',
titel: 'Schnittmarken wie in der Druckvorstufe',
text: 'Feine Eckmarken außerhalb jedes Bildes oder durchgehende Rasterlinien über den ganzen Bogen. ' +
'Die Linien werden dort unterbrochen, wo ein anderes Motiv liegt — es wird also nie quer über ein ' +
'Bild gezeichnet. Länge und Abstand sind einstellbar, Beschnittzugabe ebenso.',
beleg: 'Eckmarken · durchgehendes Raster · Beschnittzugabe 010 mm',
},
{
kicker: 'Zu groß',
titel: '20 × 30 auf A4 — trotzdem',
text: 'Der Alltagsfall: Es soll ein 20 × 30 werden, im Haus liegt aber nur A4-Fotopapier. ' +
'Statt „geht nicht“ gibt es zwei Wege. Überstehen legt das Bild mittig aufs ganze Blatt; ' +
'was hinausragt, fehlt hinterher — und wie viel das ist, steht vorher da.',
beleg: '20 × 30 cm auf A4: es fehlen genau 3 mm in der Höhe',
},
{
kicker: 'Poster',
titel: 'Ein Bild, mehrere Blätter',
text: 'Der zweite Weg für große Maße: Das Bild wird auf mehrere Blätter verteilt. Die Kacheln ' +
'überlappen sich um einen Klebefalz, der auf beiden Nachbarblättern gedruckt und gestrichelt ' +
'markiert wird. Jedes Blatt trägt seine Nummer samt Reihe und Spalte.',
beleg: '30 × 40 cm auf A4 → 2 × 2 Blätter, 10 mm Klebefalz',
},
{
kicker: 'Zuschnitt',
titel: 'Der Ausschnitt bleibt deiner',
text: 'Passt das Seitenverhältnis nicht, entscheidest du: zuschneiden oder Rand lassen. ' +
'Im Zuschnitt-Fenster verschiebst und zoomst du das Original im Zielrahmen — mit Maus, Finger, ' +
'Doppeltipp oder Pfeiltasten. Der Ausschnitt wird relativ gespeichert und überlebt jede Auflösung.',
beleg: 'EXIF-Drehung wird vorab eingerechnet — Handyfotos stehen richtig',
},
];
const daten = [
{ zahl: String(anzahlFormate), label: 'Bildformate', fein: `in ${anzahlGruppen} Gruppen — Passbild bis 100 × 140 cm` },
{ zahl: String(anzahlPapiere), label: 'Papierformate', fein: 'DIN A7A0, Fotopapier, US-Maße, freies Maß' },
{ zahl: '0', label: 'KI im Spiel', fein: 'reine Geometrie, kein Modell, kein Upload nach außen' },
{ zahl: '1:1', label: 'Ausgabe', fein: 'PDF in Originalgröße, 721200 dpi' },
];
// Der Beispielbogen unten wird nicht gezeichnet, sondern gerechnet — mit genau
// demselben Packer, den das Werkzeug benutzt. Ändert sich der Algorithmus,
// ändert sich das Bild auf dieser Seite mit.
const musterBogen: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 8, bleedMm: 0, center: true };
const musterPlan = layout([
{ id: 'gross', wMm: 130, hMm: 180, count: 1, allowRotate: true },
{ id: 'klein', wMm: 90, hMm: 130, count: 1, allowRotate: true },
{ id: 'pass', wMm: 35, hMm: 45, count: 6, allowRotate: true },
], musterBogen);
const musterSeite = musterPlan.pages[0];
const musterMarken = musterSeite ? cutMarks(musterSeite, musterBogen, { mode: 'corner', lengthMm: 3, offsetMm: 1 }) : [];
const musterFarbe: Record<string, string> = {
gross: 'oklch(45% 0.23 267 / 0.09)', klein: 'oklch(45% 0.23 267 / 0.20)', pass: 'oklch(45% 0.23 267 / 0.34)',
};
const musterAnzahl = musterSeite ? musterSeite.placements.length : 0;
/** Kompaktes Maß fürs Kästchen: unter 10 cm in mm, sonst in cm. */
const kurzMass = (w: number, h: number) => w < 100 && h < 100
? `${Math.round(w)}×${Math.round(h)}`
: `${Math.round(w / 10)}×${Math.round(h / 10)} cm`;
const schritte = [
{ n: '01', t: 'Bilder wählen', d: 'Hochladen, aus der Bibliothek holen oder ein vorhandenes Bild duplizieren, um es in zwei Maßen zu drucken.' },
{ n: '02', t: 'Maße setzen', d: 'Je Bild ein Zielmaß, Anzahl, Hoch oder Quer, zuschneiden oder Rand lassen.' },
{ n: '03', t: 'Bogen einstellen', d: 'Papier, Schnittmarken, Ränder, Auflösung. Die Vorschau zeigt sofort, wie viele Bogen es werden.' },
{ n: '04', t: 'Drucken', d: 'PDF erzeugen und im Druckdialog „Tatsächliche Größe / 100 %“ wählen. Fertig.' },
];
---
<Base title="Klarbild Drucken — Bilder auf exakte Maße bringen">
<main class="vs">
<header class="held">
<p class="mono-label">Klarbild · Druckmodul</p>
<h1>Bilder auf exakte Maße bringen.<br /><span class="ohne">Ohne KI.</span></h1>
<p class="lead">
Ein Passbild ist 35 × 45 Millimeter. Ein Rahmen ist 13 × 18 Zentimeter. Beides sind Maße,
keine Meinungen — und trotzdem ist es erstaunlich mühsam, ein Foto genau so aus dem Drucker
zu bekommen. Dieses Werkzeug macht daraus einen Handgriff: Zielmaß eintragen, Ausschnitt
wählen, PDF drucken. Millimetergenau, mit Schnittmarken, mehrere Bilder auf einem Blatt.
</p>
<div class="held-marken" aria-hidden="true">
<svg viewBox="0 0 300 120" class="held-svg">
<rect x="8" y="8" width="284" height="104" fill="#FBFBF7" stroke="#D7D6CC" />
<g stroke="#1B3BE0" stroke-width="1">
<path d="M26 22h-10M22 26v-10" /><path d="M120 22h10M124 26v-10" />
<path d="M26 98h-10M22 94v10" /><path d="M120 98h10M124 94v10" />
<path d="M156 22h-10M152 26v-10" /><path d="M282 22h-8M278 26v-10" />
<path d="M156 98h-10M152 94v10" /><path d="M282 98h-8M278 94v10" />
</g>
<rect x="26" y="22" width="94" height="76" fill="#EAEAE3" stroke="#D7D6CC" stroke-width="0.5" />
<rect x="156" y="22" width="120" height="76" fill="#EAEAE3" stroke="#D7D6CC" stroke-width="0.5" />
<text x="30" y="93" font-family="monospace" font-size="7" fill="#6A685D">35 × 45 mm</text>
<text x="160" y="93" font-family="monospace" font-size="7" fill="#6A685D">13 × 18 cm</text>
</svg>
</div>
</header>
<section class="zahlen" aria-label="Kennzahlen">
{daten.map((d) => (
<div class="zahl">
<b>{d.zahl}</b>
<span class="mono-label">{d.label}</span>
<p>{d.fein}</p>
</div>
))}
</section>
<section class="problem">
<h2>Das Problem</h2>
<p>
Der übliche Weg führt über ein Layoutprogramm oder über den Druckertreiber, und beide meinen
es zu gut: „an Seitengröße anpassen“, „einpassen“, „randlos skalieren“. Am Ende liegt ein
Ausdruck vor, der fast stimmt. Für ein Passbild ist „fast“ zu wenig, und für ein
4 × 3-Bildchen ein ganzes DIN-A4-Blatt zu verbrauchen, nur weil es sonst nicht in
100 % Größe auf die Seite passt, ist schlicht Verschwendung.
</p>
<p>
Klarbild Drucken rechnet stattdessen in Millimetern, packt so viele Bilder auf einen Bogen,
wie hineingehen, und legt Schnittmarken dorthin, wo geschnitten wird. Was aus dem Drucker
kommt, hat das Maß, das du eingetragen hast.
</p>
</section>
<section class="muster">
<h2>Ein Bogen, gerechnet</h2>
<p class="muster-lead">
Ein 13 × 18, ein 9 × 13 und sechs Passbilder — auf einem einzigen DIN-A4-Bogen,
alle in 100 % Größe, mit Eckmarken zum Schneiden. Diese Zeichnung ist kein Entwurf:
Sie stammt aus demselben Packer, der auch das PDF erzeugt.
</p>
<div class="muster-halter">
<svg class="muster-svg" viewBox={`-6 -6 ${musterBogen.wMm + 12} ${musterBogen.hMm + 12}`} role="img"
aria-label={`DIN-A4-Bogen mit ${musterAnzahl} Bildern in drei Formaten`}>
<rect x="0" y="0" width={musterBogen.wMm} height={musterBogen.hMm} fill="#FBFBF7" stroke="#D7D6CC" stroke-width="0.4" />
{musterSeite?.placements.map((pl) => (
<g>
<rect x={pl.x} y={pl.y} width={pl.w} height={pl.h}
fill={musterFarbe[pl.specId] || 'oklch(45% 0.23 267 / 0.14)'}
stroke="oklch(45% 0.23 267 / 0.45)" stroke-width="0.3" />
<text x={pl.x + pl.w / 2} y={pl.y + pl.h / 2 + 1.6} text-anchor="middle"
font-family="monospace" font-size="4" fill="#16150F" opacity="0.62">
{kurzMass(pl.w, pl.h)}
</text>
</g>
))}
{musterMarken.map((l) => (
<line x1={l.x1} y1={l.y1} x2={l.x2} y2={l.y2} stroke="#1B3BE0" stroke-width="0.25" />
))}
</svg>
<ul class="muster-liste">
<li><i style="background: oklch(45% 0.23 267 / 0.09)"></i> 1 × 13 × 18 cm</li>
<li><i style="background: oklch(45% 0.23 267 / 0.20)"></i> 1 × 9 × 13 cm</li>
<li><i style="background: oklch(45% 0.23 267 / 0.34)"></i> 6 × 35 × 45 mm</li>
<li class="muster-summe">{musterAnzahl} Teile auf {musterPlan.pages.length} Bogen · 5 mm Rand · 8 mm Abstand</li>
</ul>
</div>
</section>
<section class="koennen">
<h2>Was es kann</h2>
<div class="karten">
{koennen.map((k) => (
<article class="kk">
<p class="mono-label">{k.kicker}</p>
<h3>{k.titel}</h3>
<p class="kk-text">{k.text}</p>
<p class="kk-beleg">{k.beleg}</p>
</article>
))}
</div>
</section>
<section class="ablauf">
<h2>In vier Schritten</h2>
<ol class="schritte">
{schritte.map((s) => (
<li><span class="mono-label">{s.n}</span><b>{s.t}</b><p>{s.d}</p></li>
))}
</ol>
</section>
<section class="technik">
<h2>Unter der Haube</h2>
<dl>
<div><dt>Einheit</dt><dd>Millimeter. Pixel entstehen erst beim Rendern (<code>px = mm / 25,4 × dpi</code>), PDF-Punkte über <code>mm / 25,4 × 72</code>.</dd></div>
<div><dt>Rechenkern</dt><dd>Abhängigkeitsfreies TypeScript. Browser-Vorschau und Server-PDF benutzen buchstäblich denselben Code — was du siehst, wird gedruckt.</dd></div>
<div><dt>Packen</dt><dd>Exaktes Raster bei gleichen Maßen, MaxRects (Best Short Side Fit) bei gemischten. Bewertet nach wenigsten Bogen, vollster erster Seite, wenigsten Drehungen.</dd></div>
<div><dt>Bild</dt><dd>sharp in einer einzigen Kette: EXIF-Drehung, Ausschnitt, Skalierung, Rand. Der Zuschnitt wird relativ (0…1) gespeichert und ist damit auflösungsunabhängig.</dd></div>
<div><dt>PDF</dt><dd>pdf-lib. Seitengröße exakt das Bogenmaß, Bilder 1 : 1 platziert, Schnittmarken als Vektorlinien, Klebefalze gestrichelt.</dd></div>
<div><dt>Nachweis</dt><dd>Automatische Tests für die Geometrie plus Rasterprobe der fertigen PDFs bei 254 dpi (= 10 px/mm) mit Kantenmessung.</dd></div>
</dl>
</section>
<section class="offen">
<h2>Selbst betreiben</h2>
<p>
Klarbild läuft auf einem eigenen Server: Astro mit Node, PostgreSQL, eine Warteschlange im
selben Prozess, Dateien lokal oder auf S3. Das Druckmodul kommt ohne jeden externen Dienst
aus — kein Modell, kein Schlüssel, keine Bilder, die das Haus verlassen. Wer nur drucken
will, braucht die KI-Teile gar nicht erst zu konfigurieren.
</p>
<p class="fein">
Diese Seite ist eine Vorstellung, keine Bedienoberfläche — hier lässt sich nichts hochladen
und nichts erzeugen.
</p>
</section>
<footer class="fuss">
<span class="regmark"></span>
<span>Klarbild · Druckmodul</span>
</footer>
</main>
<style>
.vs { max-width: 980px; margin: 0 auto; padding: 0 20px 80px; }
.vs h2 { font-size: 1.5rem; letter-spacing: -0.01em; margin: 0 0 14px; }
.vs section { margin: 64px 0; }
.held { padding: 72px 0 8px; }
.held h1 { font-size: clamp(2.1rem, 6vw, 3.4rem); line-height: 1.08; letter-spacing: -0.03em; margin: 14px 0 20px; }
.ohne { color: var(--accent); }
.lead { font-size: clamp(1rem, 2.2vw, 1.15rem); color: var(--ink-soft); max-width: 62ch; margin: 0; line-height: 1.65; }
.held-marken { margin-top: 34px; }
.held-svg { width: 100%; max-width: 420px; height: auto; display: block; }
.zahlen { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px;
background: var(--line); border: 1px solid var(--line); }
.zahl { background: var(--card); padding: 20px 18px; }
.zahl b { display: block; font-size: 2rem; letter-spacing: -0.03em; line-height: 1.1; }
.zahl .mono-label { display: block; margin: 6px 0 8px; }
.zahl p { margin: 0; font-size: 0.82rem; color: var(--ink-soft); line-height: 1.5; }
.problem p { max-width: 68ch; color: var(--ink-soft); margin: 0 0 14px; }
.muster-lead { max-width: 62ch; color: var(--ink-soft); margin: 0 0 24px; }
.muster-halter { display: grid; grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 32px; align-items: start; }
.muster-svg { width: 100%; height: auto; display: block; }
.muster-liste { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px;
font-size: 0.9rem; color: var(--ink-soft); }
.muster-liste li { display: flex; align-items: center; gap: 10px; }
.muster-liste i { width: 22px; height: 22px; border: 1px solid oklch(45% 0.23 267 / 0.45); flex: none; }
.muster-summe { font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.08em;
color: var(--soft); padding-top: 12px; border-top: 1px solid var(--line); }
.muster-summe::before { content: none; }
.karten { display: grid; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); gap: 16px; }
.kk { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px; }
.kk h3 { font-size: 1.12rem; margin: 8px 0 10px; letter-spacing: -0.01em; }
.kk-text { margin: 0; font-size: 0.92rem; color: var(--ink-soft); line-height: 1.62; }
.kk-beleg { margin: 14px 0 0; padding-top: 12px; border-top: 1px solid var(--line);
font-family: var(--font-mono); font-size: 0.72rem; color: var(--accent); line-height: 1.5; }
.schritte { list-style: none; padding: 0; margin: 0; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
.schritte li { border-top: 2px solid var(--ink); padding-top: 12px; }
.schritte b { display: block; margin: 8px 0 6px; }
.schritte p { margin: 0; font-size: 0.88rem; color: var(--ink-soft); line-height: 1.6; }
.technik dl { margin: 0; display: grid; gap: 1px; background: var(--line); border: 1px solid var(--line); }
.technik dl > div { background: var(--card); padding: 14px 18px; display: grid;
grid-template-columns: 130px minmax(0, 1fr); gap: 16px; align-items: baseline; }
.technik dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.16em;
text-transform: uppercase; color: var(--soft); }
.technik dd { margin: 0; font-size: 0.9rem; color: var(--ink-soft); line-height: 1.6; }
.technik code { font-family: var(--font-mono); font-size: 0.82em; background: var(--paper);
border: 1px solid var(--line); border-radius: 3px; padding: 1px 5px; }
.offen p { max-width: 68ch; color: var(--ink-soft); margin: 0 0 14px; }
.offen .fein { font-size: 0.85rem; color: var(--soft); }
.fuss { display: flex; align-items: center; gap: 10px; padding-top: 28px;
border-top: 1px solid var(--line); font-family: var(--font-mono); font-size: 11px;
letter-spacing: 0.14em; text-transform: uppercase; color: var(--soft); }
@media (max-width: 860px) { .zahlen { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 640px) {
.vs { padding: 0 16px 64px; }
.held { padding: 40px 0 4px; }
.vs section { margin: 44px 0; }
.technik dl > div { grid-template-columns: 1fr; gap: 6px; }
.muster-halter { grid-template-columns: 1fr; gap: 20px; }
}
</style>
</Base>