diff --git a/scripts/download-fonts.py b/scripts/download-fonts.py new file mode 100644 index 0000000..ad3f123 --- /dev/null +++ b/scripts/download-fonts.py @@ -0,0 +1,161 @@ +#!/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() diff --git a/src/fonts/inter-400.woff2 b/src/fonts/inter-400.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/src/fonts/inter-400.woff2 differ diff --git a/src/fonts/inter-500.woff2 b/src/fonts/inter-500.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/src/fonts/inter-500.woff2 differ diff --git a/src/fonts/inter-600.woff2 b/src/fonts/inter-600.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/src/fonts/inter-600.woff2 differ diff --git a/src/fonts/inter-700.woff2 b/src/fonts/inter-700.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/src/fonts/inter-700.woff2 differ diff --git a/src/fonts/jetbrains-mono-400.woff2 b/src/fonts/jetbrains-mono-400.woff2 new file mode 100644 index 0000000..5858873 Binary files /dev/null and b/src/fonts/jetbrains-mono-400.woff2 differ diff --git a/src/fonts/source-serif-4-400-italic.woff2 b/src/fonts/source-serif-4-400-italic.woff2 new file mode 100644 index 0000000..3595a72 Binary files /dev/null and b/src/fonts/source-serif-4-400-italic.woff2 differ diff --git a/src/fonts/source-serif-4-400.woff2 b/src/fonts/source-serif-4-400.woff2 new file mode 100644 index 0000000..30dbe2d Binary files /dev/null and b/src/fonts/source-serif-4-400.woff2 differ diff --git a/src/fonts/source-serif-4-600.woff2 b/src/fonts/source-serif-4-600.woff2 new file mode 100644 index 0000000..30dbe2d Binary files /dev/null and b/src/fonts/source-serif-4-600.woff2 differ diff --git a/src/styles/fonts.css b/src/styles/fonts.css new file mode 100644 index 0000000..6e6253a --- /dev/null +++ b/src/styles/fonts.css @@ -0,0 +1,71 @@ +/* Self-hosted Google Fonts — woff2, latin subset only */ + +/* === Source Serif 4 (prose voice) === */ + +@font-face { + font-family: 'Source Serif 4'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('../fonts/source-serif-4-400.woff2') format('woff2'); +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('../fonts/source-serif-4-600.woff2') format('woff2'); +} + +@font-face { + font-family: 'Source Serif 4'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('../fonts/source-serif-4-400-italic.woff2') format('woff2'); +} + +/* === Inter (structure voice) === */ + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('../fonts/inter-400.woff2') format('woff2'); +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('../fonts/inter-500.woff2') format('woff2'); +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('../fonts/inter-600.woff2') format('woff2'); +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('../fonts/inter-700.woff2') format('woff2'); +} + +/* === JetBrains Mono (evidence voice) === */ + +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('../fonts/jetbrains-mono-400.woff2') format('woff2'); +} diff --git a/tests/verify-fonts.mjs b/tests/verify-fonts.mjs new file mode 100644 index 0000000..b2008e1 --- /dev/null +++ b/tests/verify-fonts.mjs @@ -0,0 +1,171 @@ +/** + * Verification test for Self-Hosted Fonts (Task 3) + * + * Checks that: + * 1. 8 woff2 font files exist in src/fonts/ + * 2. src/styles/fonts.css exists with 8 @font-face declarations + * 3. Each @font-face has correct font-family, weight, style, src, font-display + * 4. src URLs use relative paths to ../fonts/ + */ +import { readFileSync, existsSync, statSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); + +let passed = 0; +let failed = 0; + +function assert(condition, message) { + if (condition) { + console.log(` ✓ ${message}`); + passed++; + } else { + console.error(` ✗ ${message}`); + failed++; + } +} + +// === 1. Font files exist in src/fonts/ === +console.log('\n1. Font files exist in src/fonts/'); +const expectedFonts = [ + 'source-serif-4-400.woff2', + 'source-serif-4-600.woff2', + 'source-serif-4-400-italic.woff2', + 'inter-400.woff2', + 'inter-500.woff2', + 'inter-600.woff2', + 'inter-700.woff2', + 'jetbrains-mono-400.woff2', +]; + +for (const font of expectedFonts) { + const fontPath = join(root, 'src/fonts', font); + const exists = existsSync(fontPath); + assert(exists, `src/fonts/${font} exists`); + if (exists) { + const stat = statSync(fontPath); + assert(stat.size > 1000, `src/fonts/${font} is a real font file (${stat.size} bytes)`); + } +} + +// === 2. fonts.css exists === +console.log('\n2. fonts.css exists'); +const fontsCssPath = join(root, 'src/styles/fonts.css'); +assert(existsSync(fontsCssPath), 'src/styles/fonts.css exists'); + +if (!existsSync(fontsCssPath)) { + console.log('\nfonts.css not found, skipping remaining tests.'); + console.log(`\n${'='.repeat(40)}`); + console.log(`Results: ${passed} passed, ${failed} failed out of ${passed + failed}`); + process.exit(1); +} + +const css = readFileSync(fontsCssPath, 'utf8'); + +// === 3. Has 8 @font-face declarations === +console.log('\n3. @font-face count'); +const fontFaceBlocks = css.match(/@font-face\s*\{[^}]+\}/g); +const fontFaceCount = fontFaceBlocks ? fontFaceBlocks.length : 0; +assert(fontFaceCount === 8, `has exactly 8 @font-face declarations (found ${fontFaceCount})`); + +// === 4. Source Serif 4 declarations (prose voice) === +console.log('\n4. Source Serif 4 declarations (prose voice)'); +// Regular 400 +assert( + css.includes("font-family: 'Source Serif 4'") || css.includes('font-family: "Source Serif 4"'), + "has font-family: 'Source Serif 4'" +); +// Check for 400 normal +const ss4_400_block = fontFaceBlocks?.find( + (b) => + (b.includes("'Source Serif 4'") || b.includes('"Source Serif 4"')) && + b.includes('font-weight: 400') && + b.includes('font-style: normal') +); +assert(!!ss4_400_block, 'Source Serif 4 400 normal declaration exists'); +// Check for 600 normal +const ss4_600_block = fontFaceBlocks?.find( + (b) => + (b.includes("'Source Serif 4'") || b.includes('"Source Serif 4"')) && + b.includes('font-weight: 600') && + b.includes('font-style: normal') +); +assert(!!ss4_600_block, 'Source Serif 4 600 normal declaration exists'); +// Check for 400 italic +const ss4_400i_block = fontFaceBlocks?.find( + (b) => + (b.includes("'Source Serif 4'") || b.includes('"Source Serif 4"')) && + b.includes('font-weight: 400') && + b.includes('font-style: italic') +); +assert(!!ss4_400i_block, 'Source Serif 4 400 italic declaration exists'); + +// === 5. Inter declarations (structure voice) === +console.log('\n5. Inter declarations (structure voice)'); +const interWeights = [400, 500, 600, 700]; +for (const weight of interWeights) { + const interBlock = fontFaceBlocks?.find( + (b) => + (b.includes("'Inter'") || b.includes('"Inter"')) && + b.includes(`font-weight: ${weight}`) && + b.includes('font-style: normal') + ); + assert(!!interBlock, `Inter ${weight} normal declaration exists`); +} + +// === 6. JetBrains Mono declaration (evidence voice) === +console.log('\n6. JetBrains Mono declaration (evidence voice)'); +const jbBlock = fontFaceBlocks?.find( + (b) => + (b.includes("'JetBrains Mono'") || b.includes('"JetBrains Mono"')) && + b.includes('font-weight: 400') && + b.includes('font-style: normal') +); +assert(!!jbBlock, 'JetBrains Mono 400 normal declaration exists'); + +// === 7. All declarations use format('woff2') === +console.log("\n7. All use format('woff2')"); +if (fontFaceBlocks) { + for (const block of fontFaceBlocks) { + assert(block.includes("format('woff2')"), `@font-face block uses format('woff2')`); + } +} + +// === 8. All declarations use font-display: swap === +console.log('\n8. All use font-display: swap'); +if (fontFaceBlocks) { + for (const block of fontFaceBlocks) { + assert(block.includes('font-display: swap'), '@font-face block uses font-display: swap'); + } +} + +// === 9. All src URLs use relative paths to ../fonts/ === +console.log('\n9. src URLs use relative ../fonts/ paths'); +if (fontFaceBlocks) { + for (const block of fontFaceBlocks) { + assert(block.includes("url('../fonts/"), "@font-face src uses url('../fonts/...')"); + } +} + +// === 10. download-fonts.py script exists === +console.log('\n10. download-fonts.py script exists'); +const scriptPath = join(root, 'scripts/download-fonts.py'); +assert(existsSync(scriptPath), 'scripts/download-fonts.py exists'); + +if (existsSync(scriptPath)) { + const script = readFileSync(scriptPath, 'utf8'); + assert(script.includes('urllib.request'), 'script uses urllib.request'); + assert(script.includes('User-Agent') || script.includes('user-agent'), 'script sets User-Agent header'); + assert(script.includes('woff2'), 'script references woff2 format'); +} + +// === Summary === +console.log(`\n${'='.repeat(40)}`); +console.log(`Results: ${passed} passed, ${failed} failed out of ${passed + failed}`); +if (failed > 0) { + process.exit(1); +} else { + console.log('All font verification tests passed!'); +}