diff --git a/.gitignore b/.gitignore
index e69de29..33c9089 100644
--- a/.gitignore
+++ b/.gitignore
@@ -0,0 +1,6 @@
+# Pristine Montserrat originals, re-downloadable by tools/rescale-aspect.py.
+tools/_originals/
+
+# Python bytecode
+__pycache__/
+*.pyc
diff --git a/site/fonts/montserrat-latin-400-aspect.woff2 b/site/fonts/montserrat-latin-400-aspect.woff2
new file mode 100644
index 0000000..2a073ec
Binary files /dev/null and b/site/fonts/montserrat-latin-400-aspect.woff2 differ
diff --git a/site/fonts/montserrat-latin-700-aspect.woff2 b/site/fonts/montserrat-latin-700-aspect.woff2
new file mode 100644
index 0000000..9c95a9c
Binary files /dev/null and b/site/fonts/montserrat-latin-700-aspect.woff2 differ
diff --git a/site/index.html b/site/index.html
index 1fd8921..b0cf963 100644
--- a/site/index.html
+++ b/site/index.html
@@ -6,13 +6,10 @@
Raum und Linie
+
+
-
@@ -21,9 +18,9 @@
- Hier entsteht eine neue Seite.
+ 🚧 Hier entsteht eine neue Seite.
- Der schnellste Weg zu mir:
+ 📬 Der schnellste Weg zu mir:
mail@raum-und-linie.de.
diff --git a/site/style/base.css b/site/style/base.css
index 96ef032..33a82ff 100644
--- a/site/style/base.css
+++ b/site/style/base.css
@@ -1,5 +1,48 @@
-:root{
- --color-background: #faf9f7;
- --font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+@font-face {
+ font-family: "Montserrat";
+ font-style: normal;
+ font-weight: 400; /* 400 = Regular */
+ font-display: swap; /* show the fallback first, swap in later */
+ src: url("../fonts/montserrat-latin-400-aspect.woff2") format("woff2");
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
+ U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
+ U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+@font-face {
+ font-family: "Montserrat";
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url("../fonts/montserrat-latin-700-aspect.woff2") format("woff2");
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
+ U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
+ U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+:root{
+ --color-canvas: #faf9f7;
+ --color-banner-background: #435245;
+ --color-banner-text: orange;
+ --font-body: "Montserrat", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+}
+
+body {
+ background-color: var(--color-canvas);
+ font-family: var(--font-body);
+ margin: 0px;
+}
+
+header {
+ color: var(--color-banner-text);
+ background-color: var(--color-banner-background);
+ padding-left: 32px;
+ display: flex;
+ justify-content: center;
+}
+
+main {
+ max-width: 42rem;
+ margin-inline: auto;
+ padding-inline: 1rem;
+}
diff --git a/tools/rescale-aspect.py b/tools/rescale-aspect.py
new file mode 100644
index 0000000..aee86d0
--- /dev/null
+++ b/tools/rescale-aspect.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""Generate the aspect-adjusted Montserrat webfonts used by the site.
+
+The design calls for the typeface to be 110% as tall and 85% as wide as
+the original Montserrat. CSS cannot do this: `font-stretch` only *selects*
+a width variant from a family, it never scales, and Montserrat has no
+width axis (its variable build only has `wght`). So the adjustment is baked
+into the font files themselves.
+
+Scaling a font's aspect ratio means changing the relationship between the
+glyph *outlines* and the *advance widths*. If you scale the shapes but not
+the advances, the browser still reserves the original width and every line
+looks loosely spaced and wraps too early. So this script scales, in order:
+
+ 1. every glyph outline coordinate (x * 0.85, y * 1.10)
+ 2. every composite glyph component offset (e.g. the accent on "a-umlaut")
+ 3. every advance width and side bearing (x * 0.85) <- affects layout
+ 4. every vertical metric (ascent, descent, capHeight, xHeight) (y * 1.10)
+
+Non-uniform scaling invalidates TrueType hinting, so the hinting programs
+and the pixel-metric tables that reference the old coordinates are dropped.
+
+Usage:
+ pip install "fonttools[woff]" # or: python3 -m venv .venv && ...
+ python3 tools/rescale-aspect.py
+
+Pristine originals are cached in tools/_originals/ (git-ignored) and
+downloaded from a PINNED Fontsource version, so re-running always yields
+byte-identical output. The cache is disposable: delete it any time.
+"""
+
+import sys
+import urllib.request
+from pathlib import Path
+
+try:
+ from fontTools.ttLib import TTFont
+ from fontTools.ttLib.tables.ttProgram import Program
+except ImportError:
+ sys.exit('fontTools is missing. Install it with: pip install "fonttools[woff]"')
+
+# ---- design parameters ----------------------------------------------------
+SCALE_X = 0.85 # width -> 85%
+SCALE_Y = 1.10 # height -> 110%
+
+# Only the weights the site actually uses. Each unused weight is a file
+# nobody downloads, so there is no reason to ship it.
+WEIGHTS = (400, 700)
+
+# Pinned so the output never silently changes under you.
+FONTSOURCE_VERSION = "5.3.0"
+CDN = f"https://cdn.jsdelivr.net/fontsource/fonts/montserrat@{FONTSOURCE_VERSION}"
+
+ROOT = Path(__file__).resolve().parent.parent
+CACHE = ROOT / "tools" / "_originals"
+OUT = ROOT / "site" / "fonts"
+
+# Tables whose contents are tied to the old coordinate space.
+STALE_TABLES = ("hdmx", "LTSH", "VDMX", "gasp")
+
+
+def fetch_original(weight: int) -> Path:
+ """Download the pristine woff2 into the cache if it isn't there yet."""
+ CACHE.mkdir(parents=True, exist_ok=True)
+ dest = CACHE / f"montserrat-latin-{weight}-normal.woff2"
+ if not dest.exists():
+ url = f"{CDN}/latin-{weight}-normal.woff2"
+ print(f" downloading {url}")
+ with urllib.request.urlopen(url) as response:
+ dest.write_bytes(response.read())
+ return dest
+
+
+def transform(src: Path, dst: Path) -> None:
+ font = TTFont(src)
+ glyf = font["glyf"]
+ order = font.getGlyphOrder()
+
+ # 1 + 2. outlines and composite offsets
+ for name in order:
+ g = glyf[name]
+ if g.isComposite():
+ # Every level's offset is scaled; nesting composes correctly
+ # because (0.85*a + 0.85*b) == 0.85 * (a + b).
+ for comp in g.components:
+ comp.x = round(comp.x * SCALE_X)
+ comp.y = round(comp.y * SCALE_Y)
+ elif g.numberOfContours > 0:
+ # Affine-scaling every point of a quadratic outline keeps it a
+ # valid quadratic, so no curve conversion is needed.
+ coords = g.coordinates
+ for i in range(len(coords)):
+ x, y = coords[i]
+ coords[i] = (x * SCALE_X, y * SCALE_Y)
+ g.program = Program() # hinting no longer matches the coordinates
+
+ # 3. horizontal metrics -- this is the part that makes layout reflow
+ hmtx = font["hmtx"]
+ for name in order:
+ advance, lsb = hmtx[name]
+ hmtx[name] = (round(advance * SCALE_X), round(lsb * SCALE_X))
+
+ # 4. vertical metrics, so the line box grows to match the taller glyphs
+ hhea, os2 = font["hhea"], font["OS/2"]
+ for table, attr in ((hhea, "ascent"), (hhea, "descent"), (hhea, "lineGap"),
+ (os2, "sTypoAscender"), (os2, "sTypoDescender"),
+ (os2, "sTypoLineGap"), (os2, "usWinAscent"),
+ (os2, "usWinDescent"),
+ (os2, "sCapHeight"), (os2, "sxHeight")):
+ value = getattr(table, attr, None)
+ if value is not None:
+ setattr(table, attr, round(value * SCALE_Y))
+
+ for table in STALE_TABLES:
+ if table in font:
+ del font[table]
+
+ font.recalcBBoxes = True
+ font.save(dst)
+ font.close()
+
+
+def report(path: Path) -> None:
+ f = TTFont(path)
+ cmap = f.getBestCmap()
+ r = f["glyf"][cmap[ord("R")]]
+ umlaut = f["glyf"][cmap[ord("ä")]]
+ print(f" {path.name} ({path.stat().st_size:,} bytes)")
+ print(f" 'R' advance={f['hmtx']['R'][0]:4d} capHeight={f['OS/2'].sCapHeight}"
+ f" xHeight={f['OS/2'].sxHeight}")
+ print(f" umlauts still composite: {umlaut.isComposite()}"
+ f" glyphs: {f['maxp'].numGlyphs}")
+ f.close()
+
+
+if __name__ == "__main__":
+ print(f"Montserrat {FONTSOURCE_VERSION} -> height {SCALE_Y:.0%}, width {SCALE_X:.0%}\n")
+ OUT.mkdir(parents=True, exist_ok=True)
+ for weight in WEIGHTS:
+ original = fetch_original(weight)
+ target = OUT / f"montserrat-latin-{weight}-aspect.woff2"
+ transform(original, target)
+ report(target)
+ print("\nDone. Point your @font-face src: at the *-aspect.woff2 files.")