diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md
index d3aa281..3cc2af0 100644
--- a/src/lib/components/CLAUDE.md
+++ b/src/lib/components/CLAUDE.md
@@ -206,7 +206,9 @@ size alone makes a short word far too small and a long one overflow the page.
then the template's content, margins, styles and fold marks. `documentName` becomes the
localized template name and no file handle is bound, so the first Save asks where. The
DIN letter's mm positions were verified headlessly (45mm address field, 125mm info
-column); its module documents the arithmetic.
+column); its module documents the arithmetic. The letter-family templates share that
+geometry (and the JSON builders) via `templates/builders.ts`; `tests/unit/templates.test.ts`
+schema-checks every entry in both locales.
## Fold marks (`FoldMarkLayer.svelte`, `storage/foldMarks.ts`)
diff --git a/src/lib/components/TemplateGalleryDialog.svelte b/src/lib/components/TemplateGalleryDialog.svelte
index 87be2a7..21fd11a 100644
--- a/src/lib/components/TemplateGalleryDialog.svelte
+++ b/src/lib/components/TemplateGalleryDialog.svelte
@@ -42,19 +42,43 @@
{#each TEMPLATES as entry (entry.id)}
pick(entry)}>
-
+
-
- {#if entry.id === 'din5008b'}
+ {#if entry.id === 'cv'}
+
+
+
+
+
+
+ {:else if entry.id === 'minutes'}
+
+
+
+
+ {:else if entry.id === 'invoice'}
+
+
+
+
{:else}
-
+
+ {#if entry.id === 'din5008b'}
+
+ {:else}
+
+ {/if}
+
+
+
+ {#if entry.id === 'coverLetter'}
+
+ {/if}
+
+
{/if}
-
-
-
-
-
{entry.name()}
{entry.description()}
@@ -164,9 +188,27 @@
.l1 { top: 56px; width: 40px; background: #8a94a0; }
.l2 { top: 66px; width: 60px; }
.l3 { top: 73px; width: 55px; }
+ .l4 { top: 96px; width: 30px; }
.fold { left: 2px; width: 5px; height: 1px; background: #8a94a0; }
.f1 { top: 36px; }
.f2 { top: 80px; }
+ .h1 { top: 10px; left: 12px; width: 44px; height: 5px; background: #8a94a0; }
+ .sect { left: 12px; width: 30px; height: 4px; background: #8a94a0; }
+ .s1 { top: 32px; }
+ .s2 { top: 72px; }
+ .pair { height: 3px; }
+ .pa { left: 12px; width: 14px; }
+ .pb { left: 32px; width: 40px; }
+ .r1 { top: 42px; }
+ .r2 { top: 52px; }
+ .r3 { top: 82px; }
+ /* A gridded box: repeated hairlines inside a border stand in for table rows. */
+ .thumb > .tbl {
+ top: 26px; left: 12px; width: 60px; height: 27px;
+ border: 1px solid #8a94a0;
+ background: repeating-linear-gradient(to bottom, transparent 0 7px, #b9c2cc 7px 8px);
+ }
+ .thumb > .tbl.low { top: 56px; height: 31px; }
.name {
font-size: 0.85rem;
diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts
index e8159be..a1a1d93 100644
--- a/src/lib/i18n/locales/de.ts
+++ b/src/lib/i18n/locales/de.ts
@@ -709,6 +709,94 @@ const de: Messages = {
closing: 'Mit freundlichen Grüßen',
signature: 'Vorname Name',
},
+ coverLetter: {
+ name: 'Bewerbungsanschreiben',
+ description: 'Bewerbung auf der Briefgeometrie: Absenderblock, Betreff mit Stellenbezeichnung, drei Textabsätze, Anlagenvermerk.',
+ phone: 'Telefonnummer',
+ email: 'E-Mail-Adresse',
+ subject: 'Bewerbung als ',
+ position: 'Stellenbezeichnung',
+ subjectRef: 'Ihre Stellenanzeige vom ',
+ intro: 'Einleitung: warum Sie sich bewerben',
+ main: 'Hauptteil: Erfahrung und Stärken',
+ final: 'Schluss: Verfügbarkeit und Gesprächswunsch',
+ enclosuresLabel: 'Anlagen: ',
+ enclosures: 'Lebenslauf, Zeugnisse',
+ },
+ cv: {
+ name: 'Lebenslauf',
+ description: 'Tabellarischer Lebenslauf: Kontaktzeile, Berufserfahrung, Ausbildung und Kenntnisse im zweispaltigen Tab-Layout, Ort, Datum und Unterschrift.',
+ phone: 'Telefonnummer',
+ email: 'E-Mail-Adresse',
+ experience: 'Berufserfahrung',
+ education: 'Ausbildung',
+ skills: 'Kenntnisse',
+ period: 'MM/JJJJ – MM/JJJJ',
+ role: 'Position',
+ employer: 'Unternehmen, Ort',
+ roleDetail: 'Aufgaben und Erfolge',
+ degree: 'Abschluss',
+ school: 'Hochschule/Schule, Ort',
+ languages: 'Sprachen',
+ languagesValue: 'Sprache (Niveau)',
+ it: 'EDV',
+ itValue: 'Programme und Kenntnisse',
+ },
+ invoice: {
+ name: 'Rechnung',
+ description: 'Rechnung auf der Geschäftsbrief-Geometrie: Positionstabelle mit Summenzeilen, Zahlungsziel, Bankverbindung.',
+ number: 'Nummer',
+ invoiceNo: 'Rechnungs-Nr.: ',
+ customerNo: 'Kunden-Nr.: ',
+ subject: 'Rechnung Nr. ',
+ intro: 'wir stellen Ihnen folgende Leistungen in Rechnung:',
+ colPos: 'Pos.',
+ colDescription: 'Beschreibung',
+ colQty: 'Menge',
+ colUnitPrice: 'Einzelpreis',
+ colAmount: 'Betrag',
+ itemDesc: 'Leistung oder Artikel',
+ qty: 'Anzahl',
+ amount: '0,00 €',
+ subtotal: 'Zwischensumme (netto)',
+ vat: 'zzgl. 19 % USt.',
+ total: 'Gesamtbetrag',
+ paymentBefore: 'Zahlbar ohne Abzug bis zum ',
+ paymentAfter: ' auf das unten genannte Konto.',
+ bankLabel: 'Bankverbindung: ',
+ bank: 'Bank',
+ ibanLabel: 'IBAN: ',
+ iban: 'IBAN',
+ bicLabel: 'BIC: ',
+ bic: 'BIC',
+ vatIdLabel: 'USt-IdNr.: ',
+ },
+ minutes: {
+ name: 'Besprechungsprotokoll',
+ description: 'Protokoll mit Kopftabelle (Datum, Ort, Teilnehmer) und nummerierten TOPs mit Beschluss- und Aufgabenzeile.',
+ title: 'Protokoll: ',
+ topic: 'Thema der Besprechung',
+ dateLabel: 'Datum',
+ place: 'Ort',
+ time: 'Beginn / Ende',
+ timeValue: 'Uhrzeit von – bis',
+ participants: 'Teilnehmer',
+ participantNames: 'Namen',
+ recorder: 'Protokollführung',
+ recorderName: 'Name',
+ item: 'TOP',
+ itemTopic: 'Thema',
+ discussion: 'Ergebnis der Diskussion',
+ decision: 'Beschluss: ',
+ decisionText: 'Beschlusstext',
+ action: 'Aufgabe: ',
+ actionTask: 'Aufgabe',
+ actionOwner: ' — verantwortlich: ',
+ ownerName: 'Name',
+ actionDue: ', bis ',
+ dueDate: 'Datum',
+ next: 'Nächster Termin: ',
+ },
},
lineNumbers: {
title: 'Zeilennummerierung',
diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts
index e76d9a9..0fbb5c3 100644
--- a/src/lib/i18n/locales/en.ts
+++ b/src/lib/i18n/locales/en.ts
@@ -715,6 +715,94 @@ const en = {
closing: 'Kind regards',
signature: 'First name Last name',
},
+ coverLetter: {
+ name: 'Cover letter',
+ description: 'Job application on the letter geometry: sender block, subject with the position, three body paragraphs, enclosures line.',
+ phone: 'Phone number',
+ email: 'Email address',
+ subject: 'Application for the position of ',
+ position: 'Job title',
+ subjectRef: 'Your job advertisement of ',
+ intro: 'Opening: why you are applying',
+ main: 'Main part: experience and strengths',
+ final: 'Closing: availability and interview request',
+ enclosuresLabel: 'Enclosures: ',
+ enclosures: 'CV, certificates',
+ },
+ cv: {
+ name: 'CV',
+ description: 'Tabular CV: contact line, experience, education and skills in a two-column tab layout, place, date and signature.',
+ phone: 'Phone number',
+ email: 'Email address',
+ experience: 'Work experience',
+ education: 'Education',
+ skills: 'Skills',
+ period: 'MM/YYYY – MM/YYYY',
+ role: 'Position',
+ employer: 'Company, city',
+ roleDetail: 'Responsibilities and achievements',
+ degree: 'Degree',
+ school: 'Institution, city',
+ languages: 'Languages',
+ languagesValue: 'Language (level)',
+ it: 'IT skills',
+ itValue: 'Programs and proficiency',
+ },
+ invoice: {
+ name: 'Invoice',
+ description: 'Invoice on the business-letter geometry: item table with totals rows, payment terms, bank details.',
+ number: 'Number',
+ invoiceNo: 'Invoice no.: ',
+ customerNo: 'Customer no.: ',
+ subject: 'Invoice no. ',
+ intro: 'we invoice you for the following services:',
+ colPos: 'Item',
+ colDescription: 'Description',
+ colQty: 'Qty',
+ colUnitPrice: 'Unit price',
+ colAmount: 'Amount',
+ itemDesc: 'Service or article',
+ qty: 'Qty',
+ amount: '0.00',
+ subtotal: 'Subtotal (net)',
+ vat: 'VAT 19 %',
+ total: 'Total',
+ paymentBefore: 'Payable without deduction by ',
+ paymentAfter: ' to the account below.',
+ bankLabel: 'Bank details: ',
+ bank: 'Bank',
+ ibanLabel: 'IBAN: ',
+ iban: 'IBAN',
+ bicLabel: 'BIC: ',
+ bic: 'BIC',
+ vatIdLabel: 'VAT ID: ',
+ },
+ minutes: {
+ name: 'Meeting minutes',
+ description: 'Minutes with a facts table (date, place, participants) and numbered agenda items with decision and action lines.',
+ title: 'Minutes: ',
+ topic: 'Meeting topic',
+ dateLabel: 'Date',
+ place: 'Place',
+ time: 'Start / end',
+ timeValue: 'Time from – to',
+ participants: 'Participants',
+ participantNames: 'Names',
+ recorder: 'Minutes taken by',
+ recorderName: 'Name',
+ item: 'Item',
+ itemTopic: 'Topic',
+ discussion: 'Discussion summary',
+ decision: 'Decision: ',
+ decisionText: 'Decision wording',
+ action: 'Action: ',
+ actionTask: 'Task',
+ actionOwner: ' — responsible: ',
+ ownerName: 'Name',
+ actionDue: ', due ',
+ dueDate: 'Date',
+ next: 'Next meeting: ',
+ },
},
lineNumbers: {
title: 'Line numbering',
diff --git a/src/lib/templates/builders.ts b/src/lib/templates/builders.ts
new file mode 100644
index 0000000..8cd1457
--- /dev/null
+++ b/src/lib/templates/builders.ts
@@ -0,0 +1,35 @@
+import { toDateValue, DEFAULT_DATE_FORMAT } from '../utils/dateTime';
+import { headingStyleName } from '../styles/styleSheet';
+
+// Loose editor-JSON node builders shared by the template modules.
+export type N = Record;
+
+export const T = (text: string, ...marks: N[]): N =>
+ ({ type: 'text', text, ...(marks.length ? { marks } : {}) });
+export const PLH = (text: string, ...marks: N[]): N =>
+ ({ type: 'placeholderField', attrs: { text }, ...(marks.length ? { marks } : {}) });
+export const P = (attrs: N | null, ...content: N[]): N =>
+ ({ type: 'paragraph', ...(attrs ? { attrs } : {}), ...(content.length ? { content } : {}) });
+export const H = (level: number, ...content: N[]): N =>
+ ({ type: 'heading', attrs: { level, styleName: headingStyleName(level) }, content });
+
+// Table cells carry proportional column weights (percent × 100, see tableColumnResize).
+export const TBL = (attrs: N | null, ...rows: N[]): N =>
+ ({ type: 'table', ...(attrs ? { attrs } : {}), content: rows });
+export const ROW = (...cells: N[]): N => ({ type: 'tableRow', content: cells });
+export const TC = (attrs: N | null, ...content: N[]): N =>
+ ({ type: 'tableCell', ...(attrs ? { attrs } : {}), content });
+export const THC = (attrs: N | null, ...content: N[]): N =>
+ ({ type: 'tableHeader', ...(attrs ? { attrs } : {}), content });
+
+export const SMALL = { type: 'textStyle', attrs: { fontSize: '8pt' } };
+export const BOLD = { type: 'bold' };
+
+// 12pt body at Liberation Serif's natural ~1.15em line height.
+export const LINE_PT = 12 * 1.15;
+export const MM_TO_PT = 72 / 25.4;
+// The return-address zone is 5mm tall; its 8pt line fills ~3.25mm of it.
+export const RETURN_SPACE_AFTER_PT = Math.round((5 * MM_TO_PT - 8 * 1.15) * 10) / 10;
+
+export const dateField = (): N =>
+ ({ type: 'dateTimeField', attrs: { kind: 'date', format: DEFAULT_DATE_FORMAT, fixed: true, value: toDateValue(new Date()) } });
diff --git a/src/lib/templates/coverLetter.ts b/src/lib/templates/coverLetter.ts
new file mode 100644
index 0000000..0d37ffa
--- /dev/null
+++ b/src/lib/templates/coverLetter.ts
@@ -0,0 +1,64 @@
+import { t } from '../i18n/i18n.svelte';
+import type { TemplateData, TemplateEntry } from './types';
+import { BOLD, LINE_PT, MM_TO_PT, P, PLH, RETURN_SPACE_AFTER_PT, SMALL, T, dateField } from './builders';
+
+// Job-application cover letter on the personal letter's window-envelope geometry:
+// a five-line sender block, the 45mm address field, then the application body.
+
+// 45mm target − 20mm margin − the five sender lines.
+const RETURN_SPACE_BEFORE_PT = Math.round((45 - 20) * MM_TO_PT - 5 * LINE_PT);
+
+function buildLetter(): TemplateData {
+ const L = t().templates.letter;
+ const C = t().templates.coverLetter;
+ return {
+ margins: { top: 2, bottom: 2, left: 2.5, right: 2 },
+ foldMarks: true,
+ content: {
+ type: 'doc',
+ content: [
+ P(null, PLH(L.recipientName)),
+ P(null, PLH(L.recipientStreet)),
+ P(null, PLH(L.recipientCity)),
+ P(null, PLH(C.phone)),
+ P(null, PLH(C.email)),
+ // The one-line return address opens the address field at 45mm from the top.
+ // fontSize also shrinks the paragraph mark, so the line box is 8pt tall.
+ P({ spaceBefore: RETURN_SPACE_BEFORE_PT, spaceAfter: RETURN_SPACE_AFTER_PT, fontSize: '8pt' }, PLH(L.returnAddress, SMALL)),
+ P(null, PLH(L.recipientCompany)),
+ P(null, PLH(L.recipientName)),
+ P(null, PLH(L.recipientStreet)),
+ P(null, PLH(L.recipientCity)),
+ P(null),
+ P({ textAlign: 'right' }, PLH(L.place), T(', '), dateField()),
+ P(null),
+ P(null),
+ P(null, T(C.subject, BOLD), PLH(C.position, BOLD)),
+ P(null, T(C.subjectRef), PLH(L.date)),
+ P(null),
+ P(null, PLH(L.salutation)),
+ P(null),
+ P(null, PLH(C.intro)),
+ P(null),
+ P(null, PLH(C.main)),
+ P(null),
+ P(null, PLH(C.final)),
+ P(null),
+ P(null, T(L.closing)),
+ P(null),
+ P(null),
+ P(null),
+ P(null, PLH(L.signature)),
+ P(null),
+ P(null, T(C.enclosuresLabel), PLH(C.enclosures)),
+ ],
+ },
+ };
+}
+
+export const coverLetter: TemplateEntry = {
+ id: 'coverLetter',
+ name: () => t().templates.coverLetter.name,
+ description: () => t().templates.coverLetter.description,
+ build: buildLetter,
+};
diff --git a/src/lib/templates/cv.ts b/src/lib/templates/cv.ts
new file mode 100644
index 0000000..4e21cac
--- /dev/null
+++ b/src/lib/templates/cv.ts
@@ -0,0 +1,52 @@
+import { t } from '../i18n/i18n.svelte';
+import type { TemplateData, TemplateEntry } from './types';
+import { BOLD, H, P, PLH, T, dateField } from './builders';
+
+// Tabular CV: period column left, station right at a 4.5cm tab; the sections use
+// the built-in heading styles, so they land in the outline and a TOC.
+const COL = '4.5l';
+
+function buildCv(): TemplateData {
+ const L = t().templates.letter;
+ const V = t().templates.cv;
+ const entry = (role: string, where: string) =>
+ P({ tabStops: COL }, PLH(V.period), T('\t'), PLH(role, BOLD), T(', '), PLH(where));
+ return {
+ margins: { top: 2, bottom: 2, left: 2, right: 2 },
+ content: {
+ type: 'doc',
+ content: [
+ H(1, PLH(L.recipientName)),
+ P(null, PLH(L.recipientStreet), T(' · '), PLH(L.recipientCity), T(' · '), PLH(V.phone), T(' · '), PLH(V.email)),
+ P(null),
+ H(2, T(V.experience)),
+ entry(V.role, V.employer),
+ P({ indent: 4.5 }, PLH(V.roleDetail)),
+ P(null),
+ entry(V.role, V.employer),
+ P({ indent: 4.5 }, PLH(V.roleDetail)),
+ H(2, T(V.education)),
+ entry(V.degree, V.school),
+ P(null),
+ entry(V.degree, V.school),
+ H(2, T(V.skills)),
+ P({ tabStops: COL }, T(V.languages), T('\t'), PLH(V.languagesValue)),
+ P({ tabStops: COL }, T(V.it), T('\t'), PLH(V.itValue)),
+ P(null),
+ P(null),
+ P(null, PLH(L.place), T(', '), dateField()),
+ P(null),
+ P(null),
+ P(null),
+ P(null, PLH(L.signature)),
+ ],
+ },
+ };
+}
+
+export const cv: TemplateEntry = {
+ id: 'cv',
+ name: () => t().templates.cv.name,
+ description: () => t().templates.cv.description,
+ build: buildCv,
+};
diff --git a/src/lib/templates/din5008b.ts b/src/lib/templates/din5008b.ts
index f80914a..19f2040 100644
--- a/src/lib/templates/din5008b.ts
+++ b/src/lib/templates/din5008b.ts
@@ -1,28 +1,16 @@
import { t } from '../i18n/i18n.svelte';
-import { toDateValue, DEFAULT_DATE_FORMAT } from '../utils/dateTime';
import type { TemplateData, TemplateEntry } from './types';
+import { BOLD, LINE_PT, MM_TO_PT, P, PLH, RETURN_SPACE_AFTER_PT, SMALL, T, dateField, type N } from './builders';
// DIN 5008 Form B business letter. All vertical positions are paragraph rhythm from a
-// 20mm top margin at 12pt single spacing (one line ≈ 4.87mm — Liberation Serif's
-// natural line height is ~1.15em); the mm targets below are the norm's.
+// 20mm top margin at 12pt single spacing (one line ≈ 4.87mm); the mm targets below
+// are the norm's.
-// Letterhead line height in pt; the space that lands the return-address line at 45mm.
-const LINE_PT = 12 * 1.15;
-const MM_TO_PT = 72 / 25.4;
// 45mm target − 20mm margin − one letterhead line.
const RETURN_SPACE_BEFORE_PT = Math.round((45 - 20) * MM_TO_PT - LINE_PT);
-// The return-address zone is 5mm tall; its 8pt line fills ~3.25mm of it.
-const RETURN_SPACE_AFTER_PT = Math.round((5 * MM_TO_PT - 8 * 1.15) * 10) / 10;
// Info block at 125mm from the paper edge = 10cm from the 25mm text margin.
const INFO_TAB = '10l';
-type N = Record;
-const T = (text: string, ...marks: N[]): N => ({ type: 'text', text, ...(marks.length ? { marks } : {}) });
-const PLH = (text: string, ...marks: N[]): N => ({ type: 'placeholderField', attrs: { text }, ...(marks.length ? { marks } : {}) });
-const P = (attrs: N | null, ...content: N[]): N => ({ type: 'paragraph', ...(attrs ? { attrs } : {}), ...(content.length ? { content } : {}) });
-const SMALL = { type: 'textStyle', attrs: { fontSize: '8pt' } };
-const BOLD = { type: 'bold' };
-
function buildLetter(): TemplateData {
const L = t().templates.letter;
// An info line with no address half indents to the column instead of leading with
@@ -31,7 +19,6 @@ function buildLetter(): TemplateData {
addr.length
? P({ ...(attrs ?? {}), tabStops: INFO_TAB }, ...addr, T('\t'), ...info)
: P({ ...(attrs ?? {}), indent: 10 }, ...info);
- const date: N = { type: 'dateTimeField', attrs: { kind: 'date', format: DEFAULT_DATE_FORMAT, fixed: true, value: toDateValue(new Date()) } };
return {
margins: { top: 2, bottom: 2, left: 2.5, right: 2 },
foldMarks: true,
@@ -49,7 +36,7 @@ function buildLetter(): TemplateData {
addressRow(null, [PLH(L.recipientCompany)], [T(L.ourRef), PLH(L.reference)]),
addressRow(null, [PLH(L.recipientName)], [T(L.phone), PLH(L.phoneNumber)]),
addressRow(null, [PLH(L.recipientStreet)], [T(L.email), PLH(L.emailAddress)]),
- addressRow(null, [PLH(L.recipientCity)], [T(L.dateLabel), date]),
+ addressRow(null, [PLH(L.recipientCity)], [T(L.dateLabel), dateField()]),
P(null),
P(null),
P(null, PLH(L.subject, BOLD)),
diff --git a/src/lib/templates/invoice.ts b/src/lib/templates/invoice.ts
new file mode 100644
index 0000000..aa6a816
--- /dev/null
+++ b/src/lib/templates/invoice.ts
@@ -0,0 +1,89 @@
+import { t } from '../i18n/i18n.svelte';
+import type { TemplateData, TemplateEntry } from './types';
+import { BOLD, LINE_PT, MM_TO_PT, P, PLH, RETURN_SPACE_AFTER_PT, ROW, SMALL, T, TBL, TC, THC, dateField, type N } from './builders';
+
+// Invoice on the Form B letterhead geometry: 45mm address field, invoice numbers in
+// the 125mm info column, then an item table with totals and the payment terms.
+
+// 45mm target − 20mm margin − one letterhead line.
+const RETURN_SPACE_BEFORE_PT = Math.round((45 - 20) * MM_TO_PT - LINE_PT);
+const INFO_TAB = '10l';
+// Proportional column weights: item no., description, quantity, unit price, amount.
+const COLS = [900, 4500, 1000, 1800, 1800];
+const RIGHT = { textAlign: 'right' };
+
+function buildInvoice(): TemplateData {
+ const L = t().templates.letter;
+ const I = t().templates.invoice;
+ const itemRow = (no: string, filled: boolean): N =>
+ ROW(
+ TC({ colwidth: [COLS[0]] }, P(null, T(no))),
+ TC({ colwidth: [COLS[1]] }, filled ? P(null, PLH(I.itemDesc)) : P(null)),
+ TC({ colwidth: [COLS[2]] }, filled ? P(RIGHT, PLH(I.qty)) : P(RIGHT)),
+ TC({ colwidth: [COLS[3]] }, filled ? P(RIGHT, PLH(I.amount)) : P(RIGHT)),
+ TC({ colwidth: [COLS[4]] }, filled ? P(RIGHT, PLH(I.amount)) : P(RIGHT)),
+ );
+ const sumRow = (label: string, bold: boolean): N =>
+ ROW(
+ TC({ colspan: 4, colwidth: COLS.slice(0, 4) }, P(RIGHT, T(label, ...(bold ? [BOLD] : [])))),
+ TC({ colwidth: [COLS[4]] }, P(RIGHT, PLH(I.amount, ...(bold ? [BOLD] : [])))),
+ );
+ return {
+ margins: { top: 2, bottom: 2, left: 2.5, right: 2 },
+ foldMarks: true,
+ content: {
+ type: 'doc',
+ content: [
+ P(null, PLH(L.companyName, BOLD)),
+ // The one-line return address opens the address field at 45mm from the top.
+ // fontSize also shrinks the paragraph mark, so the line box is 8pt tall.
+ P({ spaceBefore: RETURN_SPACE_BEFORE_PT, spaceAfter: RETURN_SPACE_AFTER_PT, fontSize: '8pt' }, PLH(L.returnAddress, SMALL)),
+ P({ tabStops: INFO_TAB }, PLH(L.recipientCompany), T('\t'), T(I.invoiceNo), PLH(I.number)),
+ P({ tabStops: INFO_TAB }, PLH(L.recipientName), T('\t'), T(I.customerNo), PLH(I.number)),
+ P({ tabStops: INFO_TAB }, PLH(L.recipientStreet), T('\t'), T(L.dateLabel), dateField()),
+ P(null, PLH(L.recipientCity)),
+ P(null),
+ P(null),
+ P(null, T(I.subject, BOLD), PLH(I.number, BOLD)),
+ P(null),
+ P(null, PLH(L.salutation)),
+ P(null),
+ P(null, T(I.intro)),
+ P(null),
+ TBL({ tableStyle: 'Simple Grid' },
+ ROW(
+ THC({ colwidth: [COLS[0]] }, P(null, T(I.colPos))),
+ THC({ colwidth: [COLS[1]] }, P(null, T(I.colDescription))),
+ THC({ colwidth: [COLS[2]] }, P(RIGHT, T(I.colQty))),
+ THC({ colwidth: [COLS[3]] }, P(RIGHT, T(I.colUnitPrice))),
+ THC({ colwidth: [COLS[4]] }, P(RIGHT, T(I.colAmount))),
+ ),
+ itemRow('1', true),
+ itemRow('2', false),
+ itemRow('3', false),
+ sumRow(I.subtotal, false),
+ sumRow(I.vat, false),
+ sumRow(I.total, true),
+ ),
+ P(null),
+ P(null, T(I.paymentBefore), PLH(L.date), T(I.paymentAfter)),
+ P(null),
+ P(null, T(L.closing)),
+ P(null),
+ P(null),
+ P(null),
+ P(null, PLH(L.signature)),
+ P(null),
+ P(null, T(I.bankLabel), PLH(I.bank), T(' · '), T(I.ibanLabel), PLH(I.iban), T(' · '), T(I.bicLabel), PLH(I.bic)),
+ P(null, T(I.vatIdLabel), PLH(I.number)),
+ ],
+ },
+ };
+}
+
+export const invoice: TemplateEntry = {
+ id: 'invoice',
+ name: () => t().templates.invoice.name,
+ description: () => t().templates.invoice.description,
+ build: buildInvoice,
+};
diff --git a/src/lib/templates/minutes.ts b/src/lib/templates/minutes.ts
new file mode 100644
index 0000000..8b6114a
--- /dev/null
+++ b/src/lib/templates/minutes.ts
@@ -0,0 +1,54 @@
+import { t } from '../i18n/i18n.svelte';
+import type { TemplateData, TemplateEntry } from './types';
+import { BOLD, H, P, PLH, ROW, T, TBL, TC, dateField, type N } from './builders';
+
+// Meeting minutes: a facts table (date, place, time, participants, recorder) under
+// the title, then numbered agenda items with a decision and an action line each.
+const LABEL_W = [2400];
+const VALUE_W = [7600];
+
+function buildMinutes(): TemplateData {
+ const L = t().templates.letter;
+ const M = t().templates.minutes;
+ const factsRow = (label: string, ...value: N[]): N =>
+ ROW(
+ TC({ colwidth: LABEL_W }, P(null, T(label, BOLD))),
+ TC({ colwidth: VALUE_W }, P(null, ...value)),
+ );
+ const item = (no: number): N[] => [
+ H(2, T(`${M.item} ${no}: `), PLH(M.itemTopic)),
+ P(null, PLH(M.discussion)),
+ P(null, T(M.decision, BOLD), PLH(M.decisionText)),
+ P(null, T(M.action, BOLD), PLH(M.actionTask), T(M.actionOwner), PLH(M.ownerName), T(M.actionDue), PLH(M.dueDate)),
+ ];
+ return {
+ margins: { top: 2, bottom: 2, left: 2, right: 2 },
+ content: {
+ type: 'doc',
+ content: [
+ H(1, T(M.title), PLH(M.topic)),
+ P(null),
+ TBL({ tableStyle: 'Simple Grid' },
+ factsRow(M.dateLabel, dateField()),
+ factsRow(M.place, PLH(L.place)),
+ factsRow(M.time, PLH(M.timeValue)),
+ factsRow(M.participants, PLH(M.participantNames)),
+ factsRow(M.recorder, PLH(M.recorderName)),
+ ),
+ P(null),
+ ...item(1),
+ P(null),
+ ...item(2),
+ P(null),
+ P(null, T(M.next, BOLD), PLH(M.dueDate)),
+ ],
+ },
+ };
+}
+
+export const minutes: TemplateEntry = {
+ id: 'minutes',
+ name: () => t().templates.minutes.name,
+ description: () => t().templates.minutes.description,
+ build: buildMinutes,
+};
diff --git a/src/lib/templates/privateLetter.ts b/src/lib/templates/privateLetter.ts
index d66dc7c..e6b7ce0 100644
--- a/src/lib/templates/privateLetter.ts
+++ b/src/lib/templates/privateLetter.ts
@@ -1,28 +1,15 @@
import { t } from '../i18n/i18n.svelte';
-import { toDateValue, DEFAULT_DATE_FORMAT } from '../utils/dateTime';
import type { TemplateData, TemplateEntry } from './types';
+import { BOLD, LINE_PT, MM_TO_PT, P, PLH, RETURN_SPACE_AFTER_PT, SMALL, T, dateField } from './builders';
// Personal letter on the DIN 5008 window-envelope geometry: same 45mm address field
// and fold marks as Form B, but no info block — just a right-aligned place/date line.
-// Sender block height in pt; the space that lands the return-address line at 45mm.
-const LINE_PT = 12 * 1.15;
-const MM_TO_PT = 72 / 25.4;
// 45mm target − 20mm margin − the three sender-address lines.
const RETURN_SPACE_BEFORE_PT = Math.round((45 - 20) * MM_TO_PT - 3 * LINE_PT);
-// The return-address zone is 5mm tall; its 8pt line fills ~3.25mm of it.
-const RETURN_SPACE_AFTER_PT = Math.round((5 * MM_TO_PT - 8 * 1.15) * 10) / 10;
-
-type N = Record;
-const T = (text: string, ...marks: N[]): N => ({ type: 'text', text, ...(marks.length ? { marks } : {}) });
-const PLH = (text: string, ...marks: N[]): N => ({ type: 'placeholderField', attrs: { text }, ...(marks.length ? { marks } : {}) });
-const P = (attrs: N | null, ...content: N[]): N => ({ type: 'paragraph', ...(attrs ? { attrs } : {}), ...(content.length ? { content } : {}) });
-const SMALL = { type: 'textStyle', attrs: { fontSize: '8pt' } };
-const BOLD = { type: 'bold' };
function buildLetter(): TemplateData {
const L = t().templates.letter;
- const date: N = { type: 'dateTimeField', attrs: { kind: 'date', format: DEFAULT_DATE_FORMAT, fixed: true, value: toDateValue(new Date()) } };
return {
margins: { top: 2, bottom: 2, left: 2.5, right: 2 },
foldMarks: true,
@@ -39,7 +26,7 @@ function buildLetter(): TemplateData {
P(null, PLH(L.recipientStreet)),
P(null, PLH(L.recipientCity)),
P(null),
- P({ textAlign: 'right' }, PLH(L.place), T(', '), date),
+ P({ textAlign: 'right' }, PLH(L.place), T(', '), dateField()),
P(null),
P(null),
P(null, PLH(L.subject, BOLD)),
diff --git a/src/lib/templates/registry.ts b/src/lib/templates/registry.ts
index cee23dc..7f889a2 100644
--- a/src/lib/templates/registry.ts
+++ b/src/lib/templates/registry.ts
@@ -1,5 +1,13 @@
import { din5008b } from './din5008b';
import { privateLetter } from './privateLetter';
+import { coverLetter } from './coverLetter';
+import { cv } from './cv';
+import { invoice } from './invoice';
+import { minutes } from './minutes';
import type { TemplateEntry } from './types';
+// What the gallery offers.
export const TEMPLATES: TemplateEntry[] = [din5008b, privateLetter];
+
+// Drafts kept compiling and tested but not selectable; move up once settled.
+export const SHELVED: TemplateEntry[] = [coverLetter, cv, invoice, minutes];
diff --git a/tests/unit/templates.test.ts b/tests/unit/templates.test.ts
new file mode 100644
index 0000000..6541a1b
--- /dev/null
+++ b/tests/unit/templates.test.ts
@@ -0,0 +1,35 @@
+// Every built-in template must build schema-valid content in both locales.
+import { describe, it, expect } from 'vitest';
+import { getSchema } from '@tiptap/core';
+import { Node as PMNode } from '@tiptap/pm/model';
+import { extensions } from '../../src/lib/editor/extensions';
+import { TEMPLATES, SHELVED } from '../../src/lib/templates/registry';
+import { setLocale } from '../../src/lib/i18n/i18n.svelte';
+import type { Locale } from '../../src/lib/i18n/config';
+
+const schema = getSchema(extensions);
+// Shelved drafts stay validated so they still work when they enter the gallery.
+const ALL = [...TEMPLATES, ...SHELVED];
+
+describe('built-in templates', () => {
+ it('has unique ids', () => {
+ expect(new Set(ALL.map((e) => e.id)).size).toBe(ALL.length);
+ });
+
+ for (const loc of ['en', 'de'] as Locale[]) {
+ it(`builds valid documents (${loc})`, () => {
+ setLocale(loc);
+ for (const entry of ALL) {
+ expect(entry.name(), entry.id).toBeTruthy();
+ expect(entry.description(), entry.id).toBeTruthy();
+ const data = entry.build();
+ const doc = PMNode.fromJSON(schema, data.content);
+ expect(() => doc.check(), entry.id).not.toThrow();
+ // A template's point is its placeholder fields; an empty one is a build bug.
+ let fields = 0;
+ doc.descendants((n) => { if (n.type.name === 'placeholderField') fields++; });
+ expect(fields, entry.id).toBeGreaterThan(0);
+ }
+ });
+ }
+});