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:
1 parent
f60a07580c
commit
7dd997cbfa
13 files changed
+573
-42
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()) } });
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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)),
|
||||
|
||||
@@ -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];
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user