e100119e3b
- Created scripts/download-fonts.py: downloads Google Fonts woff2 files (latin subset only) using urllib.request with Chrome User-Agent header
- Parses CSS response for latin @font-face blocks, extracts family/weight/style/URL metadata
- Downloads to src/fonts/ with naming convention {family}-{weight}{-italic}.woff2
- Created src/styles/fonts.css: 8 @font-face declarations for:
* Source Serif 4 (400, 600, 400-italic) — prose voice
* Inter (400, 500, 600, 700) — structure voice
* JetBrains Mono (400) — evidence voice
- All fonts use format('woff2'), font-display: swap, and relative URLs
- Fonts placed in src/fonts/ for Vite processing at build time
Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
162 lines
4.7 KiB
Python
162 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Download Google Fonts woff2 files (latin subset only) for self-hosting.
|
|
|
|
Fonts downloaded:
|
|
- Source Serif 4: 400, 600, 400-italic
|
|
- Inter: 400, 500, 600, 700
|
|
- JetBrains Mono: 400
|
|
|
|
Files are saved to src/fonts/ with naming: {family}-{weight}{-italic}.woff2
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import urllib.request
|
|
|
|
# Chrome User-Agent to get woff2 format from Google Fonts CSS API
|
|
USER_AGENT = (
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
"Chrome/120.0.0.0 Safari/537.36"
|
|
)
|
|
|
|
# Google Fonts CSS2 API URLs
|
|
FONT_REQUESTS = [
|
|
# Source Serif 4: 400 normal, 600 normal, 400 italic
|
|
(
|
|
"https://fonts.googleapis.com/css2?"
|
|
"family=Source+Serif+4:ital,wght@0,400;0,600;1,400"
|
|
"&display=swap"
|
|
),
|
|
# Inter: 400, 500, 600, 700
|
|
(
|
|
"https://fonts.googleapis.com/css2?"
|
|
"family=Inter:wght@400;500;600;700"
|
|
"&display=swap"
|
|
),
|
|
# JetBrains Mono: 400
|
|
(
|
|
"https://fonts.googleapis.com/css2?"
|
|
"family=JetBrains+Mono:wght@400"
|
|
"&display=swap"
|
|
),
|
|
]
|
|
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
|
|
FONTS_DIR = os.path.join(PROJECT_ROOT, "src", "fonts")
|
|
|
|
|
|
def fetch_css(url):
|
|
"""Fetch CSS from Google Fonts with Chrome User-Agent to get woff2."""
|
|
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
with urllib.request.urlopen(req) as resp:
|
|
return resp.read().decode("utf-8")
|
|
|
|
|
|
def parse_latin_font_faces(css_text):
|
|
"""Parse CSS for latin @font-face blocks and extract metadata + URL.
|
|
|
|
Returns list of dicts with keys: family, weight, style, url
|
|
"""
|
|
results = []
|
|
|
|
# Split into blocks by looking for /* latin */ comment followed by @font-face
|
|
# Google Fonts CSS2 uses comments like /* latin */ before each @font-face block
|
|
parts = re.split(r'/\*\s*([^*]+?)\s*\*/', css_text)
|
|
|
|
# parts alternates: [text_before, comment1, text_after1, comment2, text_after2, ...]
|
|
i = 1
|
|
while i < len(parts):
|
|
subset_label = parts[i].strip()
|
|
block_text = parts[i + 1] if i + 1 < len(parts) else ""
|
|
i += 2
|
|
|
|
if subset_label != "latin":
|
|
continue
|
|
|
|
# Extract the @font-face block
|
|
face_match = re.search(r'@font-face\s*\{([^}]+)\}', block_text)
|
|
if not face_match:
|
|
continue
|
|
|
|
face_body = face_match.group(1)
|
|
|
|
# Extract font-family
|
|
family_match = re.search(r"font-family:\s*'([^']+)'", face_body)
|
|
if not family_match:
|
|
continue
|
|
family = family_match.group(1)
|
|
|
|
# Extract font-weight
|
|
weight_match = re.search(r'font-weight:\s*(\d+)', face_body)
|
|
weight = weight_match.group(1) if weight_match else "400"
|
|
|
|
# Extract font-style
|
|
style_match = re.search(r'font-style:\s*(\w+)', face_body)
|
|
style = style_match.group(1) if style_match else "normal"
|
|
|
|
# Extract woff2 URL
|
|
url_match = re.search(r"url\(([^)]+\.woff2[^)]*)\)", face_body)
|
|
if not url_match:
|
|
continue
|
|
font_url = url_match.group(1)
|
|
|
|
results.append({
|
|
"family": family,
|
|
"weight": weight,
|
|
"style": style,
|
|
"url": font_url,
|
|
})
|
|
|
|
return results
|
|
|
|
|
|
def font_filename(family, weight, style):
|
|
"""Generate filename: {family}-{weight}{-italic}.woff2"""
|
|
# Normalize family name: "Source Serif 4" -> "source-serif-4"
|
|
slug = family.lower().replace(" ", "-")
|
|
suffix = "-italic" if style == "italic" else ""
|
|
return f"{slug}-{weight}{suffix}.woff2"
|
|
|
|
|
|
def download_font(url, dest_path):
|
|
"""Download a font file."""
|
|
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
with urllib.request.urlopen(req) as resp:
|
|
data = resp.read()
|
|
with open(dest_path, "wb") as f:
|
|
f.write(data)
|
|
return len(data)
|
|
|
|
|
|
def main():
|
|
os.makedirs(FONTS_DIR, exist_ok=True)
|
|
|
|
downloaded = []
|
|
|
|
for css_url in FONT_REQUESTS:
|
|
print(f"\nFetching CSS: {css_url[:80]}...")
|
|
css_text = fetch_css(css_url)
|
|
|
|
faces = parse_latin_font_faces(css_text)
|
|
print(f" Found {len(faces)} latin @font-face block(s)")
|
|
|
|
for face in faces:
|
|
filename = font_filename(face["family"], face["weight"], face["style"])
|
|
dest = os.path.join(FONTS_DIR, filename)
|
|
|
|
print(f" Downloading {filename}...", end=" ")
|
|
size = download_font(face["url"], dest)
|
|
print(f"({size:,} bytes)")
|
|
downloaded.append(filename)
|
|
|
|
print(f"\nDone! Downloaded {len(downloaded)} font files to src/fonts/:")
|
|
for f in sorted(downloaded):
|
|
print(f" {f}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|