Google Fontsの&text=パラメータ使用時にローカルフォントが優先されない問題と解決策
以前、Astroでビルド時にGoogle Fontsを最適化するインテグレーションの作り方という記事を書きました。&text=パラメータを使ってフォントをサブセット化し、ファイルサイズを削減するというものです。
ところが運用していく中で、ローカルにフォントがインストールされている環境でも不要なWebフォントがダウンロードされていることに気づきました。本記事では、この問題の原因と解決方法を紹介します。
問題の詳細
発生条件
この問題は以下の条件が重なると発生します。
- Google Fonts APIで
&text=パラメータを使用している - ローカルに該当フォントがインストール済み
- CSS側で
local()によるローカルフォント優先を指定している
期待していた動作
以下のようにCSSでローカルフォントを優先指定していれば、ローカルにフォントが存在する場合はWebフォントがダウンロードされないはずです。
@font-face { font-family: 'Local Font Name'; src: local('Actual Font Name');}
/* ローカルフォントが優先されるはず */font-family: 'Local Font Name', 'Web Font Name', sans-serif;
期待: ローカルフォントが存在する場合、Webフォントのダウンロードが0KBになる。
実際の動作
| パターン | ダウンロードサイズ |
|---|---|
&text=パラメータなし | 0KB(ダウンロードされない) |
&text=パラメータあり | 約185KB(ダウンロードされる) |
&text=パラメータを使わなければローカルフォントが使われるのに、最適化のために&text=パラメータを付けると逆に不要なダウンロードが発生するという状況でした。
原因の調査
Google Fonts APIのレスポンス確認
curlでGoogle Fonts APIのレスポンスを確認してみました。
# &text=なしの場合curl 'https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400..700&display=swap'
# 結果: 完全版フォント(5MB程度)@font-face { font-family: 'Noto Sans JP'; src: url(https://fonts.gstatic.com/s/notosansjp/v55/....ttf); /* ❌ local()がない */}
# &text=ありの場合curl 'https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400..700&display=swap&text=サンプル'
# 結果: サブセットフォント(数十KB〜数百KB)@font-face { font-family: 'Noto Sans JP'; src: url(https://fonts.gstatic.com/l/font?kit=...); /* ❌ local()がない */}
重要な発見: Google Fonts APIが返すCSSにはlocal()フォールバックが含まれていません。
CSS仕様の確認
CSS @font-faceの仕様として、以下のような挙動になります。
/* local()がある場合 */src: local('Font Name'), url(https://...);→ ブラウザはまずローカルをチェックし、あればURLをスキップ ✅
/* local()がない場合 */src: url(https://...);→ ブラウザは常にURLからダウンロード ❌
Google Fonts APIが返すCSSにはlocal()が含まれていないため、ブラウザはローカルにフォントがあっても常にダウンロードを試みてしまいます。
なぜ&text=なしでは問題が起きなかったのか
これはフォントファイルのサイズとブラウザの挙動が関係しています。
- 完全版フォント(5MB程度): ファイルサイズが大きいため、ブラウザは「実際に使われるまでダウンロードを遅延させる」判断をすることがあります
- サブセットフォント(数十〜数百KB): 軽量なため、ブラウザは「軽いからすぐダウンロードしてしまおう」と投機的にダウンロードを行います
つまり、&text=パラメータでサブセット化したことでフォントファイルが軽量になり、ブラウザが投機的にダウンロードするようになったことが原因でした。
解決策
前回の記事で作成したインテグレーションを拡張し、Google Fonts CSSにlocal()を注入することにしました。
インテグレーションの変更点
前回のインテグレーションからの主な変更点は以下の通りです。
interface GoogleFontsOptimizerOptions { maxChars?: number logDetails?: boolean injectLocalFont?: boolean}
injectLocalFontオプションを追加し、local()注入機能を制御できるようにしました。
local()を注入する関数
Google Fonts CSSを取得してlocal()を注入する処理を追加しました。
// Google Fonts CSSを取得する関数(ブラウザUser-Agentを使用)async function fetchGoogleFontsCSS(url: string): Promise<string> { return new Promise((resolve, reject) => { const urlObj = new URL(url) const options = { hostname: urlObj.hostname, path: urlObj.pathname + urlObj.search, headers: { // モダンブラウザのUser-Agentを送信してWOFF2フォーマットを取得 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', Accept: 'text/css,*/*;q=0.1' } }
https .get(options, (res) => { let data = '' res.on('data', (chunk) => { data += chunk }) res.on('end', () => { resolve(data) }) }) .on('error', reject) })}
// CSSにlocal()を注入する関数(複数フォントファミリー対応)function injectLocalFontIntoCSS(css: string): string { // 各@font-faceブロックを個別に処理 return css.replace(/@font-face\s*\{[^}]*\}/gs, (fontFaceBlock) => { // font-familyプロパティを抽出 const fontFamilyMatch = fontFaceBlock.match(/font-family:\s*['"]([^'"]+)['"]/) if (!fontFamilyMatch) { return fontFaceBlock }
const fontFamily = fontFamilyMatch[1]
// srcプロパティにlocal()を注入(font-family名を使用) return fontFaceBlock.replace(/src:\s*url\(/, `src: local('${fontFamily}'), url(`) })}
User-Agentの罠
Google Fonts APIはUser-Agentによって返すフォーマットが変わります。
| User-Agent | フォーマット | ファイル数 | サイズ例 |
|---|---|---|---|
| モダンブラウザ | WOFF2 Variable Font | 1ファイル | 180KB |
| Node.js(デフォルト) | TrueType 個別ウェイト | 4ファイル | 684KB |
Node.jsからそのままリクエストすると、最適化されていないTrueType形式が返ってきます。ブラウザのUser-Agentを送信することでWOFF2形式を取得できます。
ビルド時にブラウザUser-Agentを送信しないと、非最適化版が埋め込まれてしまいます!
linkタグをstyleタグに置き換える
取得したCSSにlocal()を注入し、<link>タグを<style>タグに置き換えます。
if (injectLocalFont) { try { // Google Fonts CSSを取得 const fontsCss = await fetchGoogleFontsCSS( updatedHref.startsWith('http') ? updatedHref : `https://${updatedHref}` )
// local()を注入(各font-familyを自動検出) const modifiedCss = injectLocalFontIntoCSS(fontsCss)
// <link>タグを<style>タグに置き換え const styleElement = document.createElement('style') styleElement.textContent = modifiedCss
// linkタグの前に挿入してlinkタグを削除 link.parentNode?.insertBefore(styleElement, link) link.remove()
hasChanges = true } catch (error) { logger.warn(`CSS取得エラー、通常の方法にフォールバック: ${error}`) // エラー時は従来通りlinkタグを更新 link.setAttribute('href', updatedHref) hasChanges = true }}
改善効果
| 環境 | 修正前 | 修正後 |
|---|---|---|
| ローカルフォントあり | 185KB | 0KB |
| ローカルフォントなし | 684KB | 185KB |
ローカルにフォントがある環境では完全にダウンロードが抑制され、ない環境でもWOFF2形式で最適化されたフォントが配信されます。
完全なインテグレーションコード(差分)
前回の記事からの変更箇所を差分形式で掲載します。+が付いている行が今回追加したコードです。
import * as fs from 'node:fs'import * as https from 'node:https'
import type { AstroIntegration } from 'astro'import { glob } from 'glob'import { JSDOM } from 'jsdom'
interface GoogleFontsOptimizerOptions { maxChars?: number logDetails?: boolean injectLocalFont?: boolean}
// Google Fonts CSSを取得する関数(ブラウザUser-Agentを使用)async function fetchGoogleFontsCSS(url: string): Promise<string> { return new Promise((resolve, reject) => { const urlObj = new URL(url) const options = { hostname: urlObj.hostname, path: urlObj.pathname + urlObj.search, headers: { // モダンブラウザのUser-Agentを送信してWOFF2フォーマットを取得 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', Accept: 'text/css,*/*;q=0.1' } }
https .get(options, (res) => { let data = '' res.on('data', (chunk) => { data += chunk }) res.on('end', () => { resolve(data) }) }) .on('error', reject) })}
// CSSにlocal()を注入する関数(複数フォントファミリー対応)function injectLocalFontIntoCSS(css: string): string { // 各@font-faceブロックを個別に処理 return css.replace(/@font-face\s*\{[^}]*\}/gs, (fontFaceBlock) => { // font-familyプロパティを抽出 const fontFamilyMatch = fontFaceBlock.match(/font-family:\s*['"]([^'"]+)['"]/) if (!fontFamilyMatch) { // font-familyが見つからない場合はそのまま返す return fontFaceBlock }
const fontFamily = fontFamilyMatch[1]
// srcプロパティにlocal()を注入(font-family名を使用) return fontFaceBlock.replace(/src:\s*url\(/, `src: local('${fontFamily}'), url(`) })}
export default function googleFontsOptimizer(options: GoogleFontsOptimizerOptions = {}): AstroIntegration { const { maxChars = 1000, logDetails = true } = options const { maxChars = 1000, logDetails = true, injectLocalFont = true } = options
return { name: 'google-fonts-optimizer', hooks: { 'astro:build:done': async ({ dir, logger }) => { // ... テキスト抽出処理は前回と同じ ...
// 各HTMLファイルのGoogle Fontsリンクを更新 let processedFiles = 0 for (const htmlFile of htmlFiles) { try { // ... DOM取得処理は前回と同じ ...
googleFontsLinks.forEach((link) => { for (const link of Array.from(googleFontsLinks)) { const href = link.getAttribute('href') if (!href) return if (!href) continue
// 既にtextパラメータが含まれている場合はスキップ if (href.includes('&text=') || href.includes('?text=')) { return continue }
// textパラメータを追加 const encodedText = encodeURIComponent(optimizedText) const updatedHref = `${href}&text=${encodedText}` link.setAttribute('href', updatedHref) hasChanges = true })
if (injectLocalFont) { try { // Google Fonts CSSを取得 const fontsCss = await fetchGoogleFontsCSS( updatedHref.startsWith('http') ? updatedHref : `https://${updatedHref}` )
// local()を注入(各font-familyを自動検出) const modifiedCss = injectLocalFontIntoCSS(fontsCss)
// <link>タグを<style>タグに置き換え const styleElement = document.createElement('style') styleElement.textContent = modifiedCss
// linkタグの前に挿入してlinkタグを削除 link.parentNode?.insertBefore(styleElement, link) link.remove()
hasChanges = true
if (logDetails) { logger.info(`CSSに各font-familyのlocal()を注入しました`) } } catch (error) { logger.warn(`CSS取得エラー、通常の方法にフォールバック: ${error}`) // エラー時は従来通りlinkタグを更新 link.setAttribute('href', updatedHref) hasChanges = true } } else { // injectLocalFont無効時は従来通りlinkタグを更新 link.setAttribute('href', updatedHref) hasChanges = true } }
// ... ファイル保存処理は前回と同じ ... } } } } }}
使い方
前回の記事で紹介したインテグレーションを使っている場合は、上記のコードに差し替えるだけで動作します。新しいinjectLocalFontオプションはデフォルトでtrueになっています。
import { defineConfig } from 'astro/config'import googleFontsOptimizer from './integrations/googleFontsOptimizer'
export default defineConfig({ integrations: [ googleFontsOptimizer({ maxChars: 1000, logDetails: true, injectLocalFont: true // デフォルトtrue }) ]})
まとめ
問題の本質
- Google Fonts APIは
local()を含むCSSを返さない &text=パラメータでサブセット化すると、軽量なためブラウザが投機的にダウンロードしてしまう- Node.jsとブラウザでUser-Agentが異なり、返されるフォーマットも異なる
解決のポイント
- ビルド時にGoogle Fonts CSSを取得
- ブラウザUser-Agentを使用してWOFF2を取得
local()を注入してローカルフォントを優先<style>タグとしてHTMLに埋め込み
前回の記事と合わせて参照していただければ、Google Fontsの最適化をより深く理解できるかと思います。