/** * Fetches the app's font files into assets/fonts/. * * ── WHY THIS SCRIPT IS THE WAY IT IS ──────────────────────────────────────── * An earlier version pointed at a Nunito URL that had died and piped whatever * came back straight into the .ttf without looking at the HTTP status. So it * wrote GitHub's 404 page into every font file — 300KB HTML documents with a * .ttf extension. * * Nothing complained. React Native does not validate a font asset: Android * silently falls back to Roboto, so the app rendered in the system font while * appearing to have a typeface of its own. That is the failure this script now * refuses to repeat: * * - a non-2xx response aborts instead of being written to disk; * - every extracted file is checked for the TrueType magic number * (0x00010000) before it is kept; * - a file that fails either check is deleted, and the script exits non-zero. * * ── WHERE INTER COMES FROM ────────────────────────────────────────────────── * Inter's static TTFs are not committed to its repository; they ship only in * the release zip. So this downloads the release once and extracts the five * weights from `extras/ttf/`. The zip is read with Node's own zlib — there is * no unzip dependency to install. * * ── WHY THE FILENAMES MATTER ──────────────────────────────────────────────── * iOS resolves `fontFamily` against the PostScript name while Android resolves * it against the asset filename, so the two only agree if the file is named * after the PostScript name. Inter's release files already are — * `Inter-SemiBold.ttf` has the PostScript name `Inter-SemiBold` — so they are * kept exactly as shipped. Do not rename them. * * After running this, re-link and rebuild — the files have to be inside the * binary, so a Metro reload alone will not pick them up: * * node download-fonts.js * npx react-native-asset * npx react-native run-android */ /* eslint-disable no-undef -- A Node build script, not app code: the project's ESLint environment is React Native and does not declare Node globals. */ const fs = require('fs'); const https = require('https'); const path = require('path'); const zlib = require('zlib'); const INTER_ZIP = 'https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip'; const fonts = ['Inter-Regular.ttf', 'Inter-Medium.ttf', 'Inter-SemiBold.ttf', 'Inter-Bold.ttf', 'Inter-ExtraBold.ttf']; const dir = path.join(__dirname, 'assets', 'fonts'); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } /** Buffers the body so nothing reaches disk until the status has been checked. */ const fetch = (url, redirects = 0) => new Promise((resolve, reject) => { if (redirects > 5) { reject(new Error('too many redirects')); return; } https .get(url, (response) => { const { statusCode, headers } = response; if (statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308) { response.resume(); fetch(headers.location, redirects + 1).then(resolve, reject); return; } if (statusCode !== 200) { response.resume(); reject(new Error(`HTTP ${statusCode}`)); return; } const chunks = []; response.on('data', (chunk) => chunks.push(chunk)); response.on('end', () => resolve(Buffer.concat(chunks))); response.on('error', reject); }) .on('error', reject); }); /** * Pulls one file out of a zip by its basename, via the central directory. * Handles the two methods a release zip uses: stored (0) and deflate (8). */ const extract = (zip, basename) => { const eocd = zip.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); if (eocd < 0) { throw new Error('not a zip archive'); } const entries = zip.readUInt16LE(eocd + 10); let cursor = zip.readUInt32LE(eocd + 16); for (let i = 0; i < entries; i += 1) { const method = zip.readUInt16LE(cursor + 10); const compressedSize = zip.readUInt32LE(cursor + 20); const nameLength = zip.readUInt16LE(cursor + 28); const extraLength = zip.readUInt16LE(cursor + 30); const commentLength = zip.readUInt16LE(cursor + 32); const localHeader = zip.readUInt32LE(cursor + 42); const name = zip.toString('utf8', cursor + 46, cursor + 46 + nameLength); if (name.endsWith(`extras/ttf/${basename}`)) { const dataStart = localHeader + 30 + zip.readUInt16LE(localHeader + 26) + zip.readUInt16LE(localHeader + 28); const data = zip.subarray(dataStart, dataStart + compressedSize); if (method === 0) { return data; } if (method === 8) { return zlib.inflateRawSync(data); } throw new Error(`unsupported zip method ${method}`); } cursor += 46 + nameLength + extraLength + commentLength; } throw new Error('not found in the release zip'); }; /** * A TrueType file opens with sfntVersion 0x00010000. `OTTO` would be an * OpenType/CFF font, which React Native on Android will not load, and anything * else — an HTML error page, most likely — is not a font at all. */ const isTrueType = (buffer) => buffer.length > 4 && buffer.readUInt32BE(0) === 0x00010000; const run = async () => { let zip; try { zip = await fetch(INTER_ZIP); } catch (error) { console.error(` FAILED ${INTER_ZIP} — ${error.message}`); process.exitCode = 1; return; } let failed = 0; for (const font of fonts) { const dest = path.join(dir, font); try { const body = extract(zip, font); if (!isTrueType(body)) { const magic = body.length > 4 ? body.readUInt32BE(0).toString(16) : 'empty'; throw new Error(`not a TrueType file (magic 0x${magic}, ${body.length} bytes)`); } fs.writeFileSync(dest, body); console.log(` ok ${font} (${body.length} bytes)`); } catch (error) { failed += 1; // Leave nothing half-written behind: a stale or truncated file here is // exactly what caused the silent fallback this script exists to prevent. fs.rmSync(dest, { force: true }); console.error(` FAILED ${font} — ${error.message}`); } } if (failed > 0) { console.error(`\n${failed} font(s) could not be extracted. Do not build with fonts missing.`); process.exitCode = 1; return; } console.log('\nAll fonts downloaded and verified.'); console.log('Next: npx react-native-asset && npx react-native run-android'); }; void run();