feat: add the DIN 5008 Form A letter template

The norm defines two business-letter forms and only Form B existed. Both
now come out of one builder parameterized by where the address field opens,
27mm for Form A against 45mm for Form B. Measured headlessly: 26.98 and
44.97mm, their info blocks at 31.99 and 49.98mm, the info column at
124.99mm. The gallery draws Form A's address field higher, or the two
cards would be indistinguishable.
This commit is contained in:
Steffen Becker committed 2026-08-30 11:51:56 +00:00
1 parent b892accdd5
commit fcdd490668
6 files changed
+45 -22

No files matched your search

+9 -4
View File
@@ -207,10 +207,15 @@ size alone makes a short word far too small and a long one overflow the page.
`App.svelte`'s `applyTemplate` — `resetDocumentState()` (the extracted body of New),
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. 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.
norm's two DIN letter forms come out of one builder in `din5008.ts`, parameterized by
where the address field opens; both were verified headlessly — Form B 44.97mm, Form A
26.98mm, their info blocks 49.98/31.99mm, the info column 124.99mm. 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.
A block's rect starts **above** its own first line — the spacing model puts space-before
inside the box — so a probe measuring these positions must range-select the text, not
read `getBoundingClientRect()` on the paragraph.
## Fold marks (`FoldMarkLayer.svelte`, `storage/foldMarks.ts`)
@@ -64,9 +64,11 @@
<span class="fold f1"></span>
<span class="fold f2"></span>
{:else}
<span class="addr"></span>
{#if entry.id === 'din5008b'}
<span class="info"></span>
<!-- Form A opens its address field 18mm higher, which is the only thing
telling the two DIN cards apart. -->
<span class="addr" class:high={entry.id === 'din5008a'}></span>
{#if entry.id.startsWith('din5008')}
<span class="info" class:high={entry.id === 'din5008a'}></span>
{:else}
<span class="date"></span>
{/if}
@@ -183,6 +185,7 @@
}
.addr { top: 26px; left: 12px; width: 28px; height: 16px; }
.info { top: 26px; left: 52px; width: 22px; height: 16px; }
.addr.high, .info.high { top: 15px; }
.date { top: 48px; right: 12px; width: 20px; height: 3px; }
.line { left: 12px; height: 3px; }
.l1 { top: 56px; width: 40px; background: #8a94a0; }
+6 -2
View File
@@ -689,8 +689,12 @@ const de: Messages = {
templates: {
title: 'Neu aus Vorlage',
din5008b: {
name: 'Brief nach DIN 5008',
description: 'Geschäftsbrief (Form B): Anschriftfeld fürs Fensterkuvert, Infoblock, Falzmarken.',
name: 'Brief nach DIN 5008 (Form B)',
description: 'Geschäftsbrief mit vollem Briefkopf: Anschriftfeld fürs Fensterkuvert ab 45 mm, Infoblock, Falzmarken.',
},
din5008a: {
name: 'Brief nach DIN 5008 (Form A)',
description: 'Geschäftsbrief mit einzeiligem Briefkopf: Anschriftfeld ab 27 mm, Infoblock, Falzmarken — 18 mm mehr Text auf Seite 1.',
},
privateLetter: {
name: 'Privatbrief',
+6 -2
View File
@@ -695,8 +695,12 @@ const en = {
templates: {
title: 'New from template',
din5008b: {
name: 'DIN 5008 letter',
description: 'Business letter (Form B): address field for a window envelope, info block, fold marks.',
name: 'DIN 5008 letter (Form B)',
description: 'Business letter with a full letterhead: address field for a window envelope from 45 mm, info block, fold marks.',
},
din5008a: {
name: 'DIN 5008 letter (Form A)',
description: 'Business letter with a one-line letterhead: address field from 27 mm, info block, fold marks — 18 mm more text on page 1.',
},
privateLetter: {
name: 'Personal letter',
@@ -2,17 +2,17 @@ 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, 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); the mm targets below
// are the norm's.
// The two DIN 5008 business-letter forms, differing only in where the address field
// opens: Form A at 27mm (a one-line letterhead), Form B at 45mm (a full one). Vertical
// positions are paragraph rhythm from a 20mm top margin at 12pt (one line ≈ 4.87mm).
// 45mm target − 20mm margin − one letterhead line.
const RETURN_SPACE_BEFORE_PT = Math.round((45 - 20) * MM_TO_PT - LINE_PT);
// Info block at 125mm from the paper edge = 10cm from the 25mm text margin.
const INFO_TAB = '10l';
function buildLetter(): TemplateData {
function buildLetter(headMm: number): TemplateData {
const L = t().templates.letter;
// The address field's mm target − the 20mm margin − one letterhead line.
const returnSpaceBefore = Math.round((headMm - 20) * MM_TO_PT - LINE_PT);
// An info line with no address half indents to the column instead of leading with
// a tab (a tab with nothing before it stays on the default grid).
const addressRow = (attrs: N | null, addr: N[], info: N[]): N =>
@@ -26,9 +26,9 @@ function buildLetter(): TemplateData {
type: 'doc',
content: [
P(null, PLH(L.companyName, BOLD)),
// The one-line return address opens the address field at 45mm from the top.
// The one-line return address opens the address field at its mm target.
// 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({ spaceBefore: returnSpaceBefore, spaceAfter: RETURN_SPACE_AFTER_PT, fontSize: '8pt' }, PLH(L.returnAddress, SMALL)),
// Two notation lines above the address (DIN's Zusatz- und Vermerkzone, filled
// bottom-up), sharing their rows with the top of the info block.
addressRow(null, [], [T(L.yourRef), PLH(L.reference)]),
@@ -59,5 +59,12 @@ export const din5008b: TemplateEntry = {
id: 'din5008b',
name: () => t().templates.din5008b.name,
description: () => t().templates.din5008b.description,
build: buildLetter,
build: () => buildLetter(45),
};
export const din5008a: TemplateEntry = {
id: 'din5008a',
name: () => t().templates.din5008a.name,
description: () => t().templates.din5008a.description,
build: () => buildLetter(27),
};
+2 -2
View File
@@ -1,4 +1,4 @@
import { din5008b } from './din5008b';
import { din5008a, din5008b } from './din5008';
import { privateLetter } from './privateLetter';
import { coverLetter } from './coverLetter';
import { cv } from './cv';
@@ -7,7 +7,7 @@ import { minutes } from './minutes';
import type { TemplateEntry } from './types';
// What the gallery offers.
export const TEMPLATES: TemplateEntry[] = [din5008b, privateLetter];
export const TEMPLATES: TemplateEntry[] = [din5008b, din5008a, privateLetter];
// Drafts kept compiling and tested but not selectable; move up once settled.
export const SHELVED: TemplateEntry[] = [coverLetter, cv, invoice, minutes];