feat: four draft templates, shelved out of the gallery

Cover letter, CV, invoice and meeting minutes as builder modules on a shared
builders.ts (JSON helpers + the 45mm window-envelope arithmetic), localized
en/de. They sit in the registry's SHELVED list: compiled and schema-checked
per locale by tests/unit/templates.test.ts, but not offered in the gallery
until their content is settled. Gallery gains per-template thumbnails.
This commit is contained in:
Steffen Becker committed 2026-08-25 16:03:56 +00:00
1 parent f60a07580c
commit 7dd997cbfa
13 files changed
+573 -42

No files matched your search

+3 -1
View File
@@ -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`)
@@ -42,19 +42,43 @@
<div class="grid">
{#each TEMPLATES as entry (entry.id)}
<button class="tpl" onclick={() => pick(entry)}>
<!-- A stylized letter page: address block, info column or date line, body lines. -->
<!-- A stylized page per template: letters get an address block, the CV and
minutes a heading, the invoice and minutes a small table sketch. -->
<span class="thumb" aria-hidden="true">
<span class="addr"></span>
{#if entry.id === 'din5008b'}
{#if entry.id === 'cv'}
<span class="h1"></span>
<span class="sect s1"></span>
<span class="pair pa r1"></span><span class="pair pb r1"></span>
<span class="pair pa r2"></span><span class="pair pb r2"></span>
<span class="sect s2"></span>
<span class="pair pa r3"></span><span class="pair pb r3"></span>
{:else if entry.id === 'minutes'}
<span class="h1"></span>
<span class="tbl"></span>
<span class="line l2"></span>
<span class="line l3"></span>
{:else if entry.id === 'invoice'}
<span class="addr"></span>
<span class="info"></span>
<span class="tbl low"></span>
<span class="fold f1"></span>
<span class="fold f2"></span>
{:else}
<span class="date"></span>
<span class="addr"></span>
{#if entry.id === 'din5008b'}
<span class="info"></span>
{:else}
<span class="date"></span>
{/if}
<span class="line l1"></span>
<span class="line l2"></span>
<span class="line l3"></span>
{#if entry.id === 'coverLetter'}
<span class="line l4"></span>
{/if}
<span class="fold f1"></span>
<span class="fold f2"></span>
{/if}
<span class="line l1"></span>
<span class="line l2"></span>
<span class="line l3"></span>
<span class="fold f1"></span>
<span class="fold f2"></span>
</span>
<span class="name">{entry.name()}</span>
<span class="desc">{entry.description()}</span>
@@ -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;
+88
View File
@@ -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',
+88
View File
@@ -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',
+35
View File
@@ -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<string, unknown>;
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()) } });
+64
View File
@@ -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,
};
+52
View File
@@ -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,
};
+4 -17
View File
@@ -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<string, unknown>;
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)),
+89
View File
@@ -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,
};
+54
View File
@@ -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,
};
+2 -15
View File
@@ -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<string, unknown>;
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)),
+8
View File
@@ -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];
+35
View File
@@ -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);
}
});
}
});