feat: download and self-host Google Fonts woff2 files
- 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>
This commit is contained in:
@@ -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()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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');
|
||||
}
|
||||
@@ -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!');
|
||||
}
|
||||
Reference in New Issue
Block a user