Module: Vivlio::Starter::CLI::FontManager

Defined in:
lib/vivlio/starter/cli/font_manager.rb

Overview

Google Fonts ダウンロード・キャッシュ管理

Constant Summary collapse

USER_AGENT =
'VivlioStarter/FontManager (+https://github.com/Atelier-Mirai/vivlio-starter)'
GOOGLE_FONTS_ENDPOINT =
'https://fonts.googleapis.com/css2'
STANDARD_FONT_FAMILIES =

標準搭載フォント(ダウンロード不要)

Set.new([
  'Noto Serif JP',
  'Noto Sans JP',
  'Zen Maru Gothic',
  'hackgen35'
]).freeze

Class Method Summary collapse

Class Method Details

.build_block_entry(family_name, block) ⇒ Object



223
224
225
226
# File 'lib/vivlio/starter/cli/font_manager.rb', line 223

def build_block_entry(family_name, block)
  header = "/* Generated from Google Fonts: #{family_name} */\n"
  "#{header}#{block.strip}\n"
end

.cert_storeObject



260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
# File 'lib/vivlio/starter/cli/font_manager.rb', line 260

def cert_store
  return @cert_store if defined?(@cert_store)

  store = OpenSSL::X509::Store.new
  store.set_default_paths

  cert_file = ENV.fetch('SSL_CERT_FILE', nil)
  store.add_file(cert_file) if cert_file && File.file?(cert_file)

  cert_dir = ENV.fetch('SSL_CERT_DIR', nil)
  store.add_path(cert_dir) if cert_dir && Dir.exist?(cert_dir)

  @cert_store = store
rescue StandardError => e
  Common.log_warn("証明書ストアの構築に失敗しました: #{e.class}: #{e.message}")
  @cert_store = nil
end

.download_font_file(url, dest_path) ⇒ Object



134
135
136
137
138
139
140
141
142
143
# File 'lib/vivlio/starter/cli/font_manager.rb', line 134

def download_font_file(url, dest_path)
  return if File.exist?(dest_path)

  uri = URI.parse(url)
  response = perform_get(uri)

  raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)

  File.binwrite(dest_path, response.body)
end

.download_google_font(name) ⇒ Object



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/vivlio/starter/cli/font_manager.rb', line 88

def download_google_font(name)
  css = fetch_google_css(name)
  unless css && !css.strip.empty?
    Common.log_warn("Google Fonts のCSSが取得できませんでした: #{name}")
    return []
  end

  slug = slug_for(name)
  family_dir = File.join(google_fonts_dir, slug)
  FileUtils.mkdir_p(family_dir)

  downloaded_files = {}
  processed_blocks = []
  FileUtils.rm_f(File.join(family_dir, 'font.json'))
  css.gsub(/@font-face\s*{[^}]+}/m) do |block|
    processed_block = block.gsub(/url\(([^)]+)\)/) do
      raw = Regexp.last_match(1).strip
      url = raw.gsub(/\A['"]|['"]\z/, '')
      if url.start_with?('https://fonts.gstatic.com/')
        begin
          filename = readable_filename_from(block, url, slug)
          dest = File.join(family_dir, filename)
          download_font_file(url, dest)
          downloaded_files[filename] = true
          %(url("google/#{slug}/#{filename}"))
        rescue StandardError => e
          Common.log_warn("フォントファイルの取得に失敗しました: #{url} (#{e.class}: #{e.message})")
          "url(#{raw})"
        end
      else
        "url(#{raw})"
      end
    end
    processed_blocks << processed_block.strip
    processed_block
  end

  Common.log_success("Google Fonts から #{name} を取得しました (#{downloaded_files.keys.size} ファイル)")
  return [] if processed_blocks.empty?

  [[name, build_block_entry(name, processed_blocks.join("\n\n"))]]
rescue StandardError => e
  Common.log_warn("Google Fonts の取得処理でエラーが発生しました: #{name} (#{e.class}: #{e.message})")
  []
end

.ensure_fonts_available(font_names) ⇒ void

This method returns an undefined value.

指定されたフォントが利用可能か確認し、不足分をダウンロードする

処理フロー:

1. 標準フォントはスキップ
2. 既にキャッシュ済みのフォントはスキップ
3. Google Fonts から CSS を取得しフォントファイルをダウンロード
4. @font-face バンドル CSS を更新

Parameters:

  • font_names (Array<String>, String)

    フォント名(複数可)



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/vivlio/starter/cli/font_manager.rb', line 59

def ensure_fonts_available(font_names)
  names = normalize_font_names(font_names)
  return if names.empty?

  downloaded_entries = []
  names.each do |name|
    next if standard_font?(name)
    next if google_font_installed?(name)

    entries = download_google_font(name)
    downloaded_entries.concat(Array(entries))
  end
rescue StandardError => e
  Common.log_warn("フォント準備中にエラーが発生しました: #{e.class}: #{e.message}")
ensure
  update_google_bundle!(downloaded_entries)
end

.fetch_google_css(name) ⇒ Object



179
180
181
182
183
184
185
186
187
# File 'lib/vivlio/starter/cli/font_manager.rb', line 179

def fetch_google_css(name)
  params = URI.encode_www_form('family' => name, 'display' => 'swap')
  uri = URI.parse("#{GOOGLE_FONTS_ENDPOINT}?#{params}")
  response = perform_get(uri, 'Accept' => 'text/css,*/*;q=0.1')
  return response.body if response.is_a?(Net::HTTPSuccess)

  Common.log_warn("Google Fonts CSS の取得に失敗しました: #{name} (HTTP #{response&.code})")
  nil
end

.format_to_extension(format) ⇒ Object



166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/vivlio/starter/cli/font_manager.rb', line 166

def format_to_extension(format)
  return nil if format.nil?

  case format.downcase
  when 'woff2' then 'woff2'
  when 'woff' then 'woff'
  when 'opentype' then 'otf'
  when 'truetype' then 'ttf'
  else
    format
  end
end

.google_bundle_pathObject



291
292
293
# File 'lib/vivlio/starter/cli/font_manager.rb', line 291

def google_bundle_path
  File.join(google_fonts_dir, '..', 'google-fonts.css')
end

.google_font_installed?(name) ⇒ Boolean

Returns:

  • (Boolean)


81
82
83
84
85
86
# File 'lib/vivlio/starter/cli/font_manager.rb', line 81

def google_font_installed?(name)
  dir = File.join(google_fonts_dir, slug_for(name))
  return false unless Dir.exist?(dir)

  !Dir.glob(File.join(dir, '*.{ttf,otf,woff,woff2}')).empty?
end

.google_fonts_dirObject



295
296
297
# File 'lib/vivlio/starter/cli/font_manager.rb', line 295

def google_fonts_dir
  File.join(Common::STYLESHEETS_DIR, 'fonts', 'google')
end

.normalize_font_names(font_names) ⇒ Object



278
279
280
281
282
283
284
285
286
287
288
289
# File 'lib/vivlio/starter/cli/font_manager.rb', line 278

def normalize_font_names(font_names)
  Array(font_names).flatten.compact.flat_map do |name|
    str = name.to_s.strip
    next [] if str.empty?

    str.split(',').map do |segment|
      cleaned = segment.to_s.strip
      cleaned = cleaned.gsub(/\A['"\s]+/, '').gsub(/['"\s]+\z/, '')
      cleaned
    end
  end.reject(&:empty?).uniq
end

.parse_bundle(content) ⇒ Object



228
229
230
231
232
233
234
235
236
237
# File 'lib/vivlio/starter/cli/font_manager.rb', line 228

def parse_bundle(content)
  entries = {}
  return entries if content.nil? || content.strip.empty?

  content.scan(%r|/\*\s*Generated from Google Fonts:\s*(.+?)\s*\*/\s*((?:@font-face\s*\{[^}]+\}\s*)+)|m) do |family, block|
    entries[family.strip] = build_block_entry(family.strip, block)
  end

  entries
end

.perform_get(uri, headers = {}) ⇒ Object



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/vivlio/starter/cli/font_manager.rb', line 239

def perform_get(uri, headers = {})
  response = nil
  opts = {
    use_ssl: uri.scheme == 'https',
    open_timeout: 10,
    read_timeout: 30,
    verify_mode: OpenSSL::SSL::VERIFY_PEER
  }
  store = cert_store
  opts[:cert_store] = store if store

  Net::HTTP.start(uri.host, uri.port, **opts) do |http|
    request = Net::HTTP::Get.new(uri)
    request['User-Agent'] = USER_AGENT
    headers.each { |k, v| request[k] = v }
    response = http.request(request)
  end

  response
end

.readable_filename_from(block, url, slug) ⇒ Object



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/vivlio/starter/cli/font_manager.rb', line 145

def readable_filename_from(block, url, slug)
  parsed = URI.parse(url)
  basename = File.basename(parsed.path)

  weight = block[/font-weight:\s*(\d{3})/, 1] || '400'
  style = block[/font-style:\s*(italic|normal)/, 1] || 'normal'
  format = block[/format\(['"](\w+)['"]\)/, 1]

  ext = File.extname(basename)
  ext = ".#{format_to_extension(format)}" if (ext.nil? || ext.empty?) && format
  ext = '.ttf' if ext.nil? || ext.empty?

  parts = [slug.tr('_', '-')]
  parts << weight unless weight == '400'
  parts << style if style != 'normal'

  "#{parts.join('-')}#{ext}"
rescue StandardError
  File.basename(url)
end

.slug_for(name) ⇒ Object



217
218
219
220
221
# File 'lib/vivlio/starter/cli/font_manager.rb', line 217

def slug_for(name)
  base = name.to_s.strip
  slug = base.gsub(/[^A-Za-z0-9]+/, '_').gsub(/_+/, '_').gsub(/\A_|_\z/, '')
  slug.empty? ? 'font_family' : slug
end

.standard_font?(name) ⇒ Boolean

Returns:

  • (Boolean)


77
78
79
# File 'lib/vivlio/starter/cli/font_manager.rb', line 77

def standard_font?(name)
  STANDARD_FONT_FAMILIES.include?(name)
end

.update_google_bundle!(new_entries) ⇒ Object



189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
# File 'lib/vivlio/starter/cli/font_manager.rb', line 189

def update_google_bundle!(new_entries)
  FileUtils.mkdir_p(google_fonts_dir)
  existing_entries = if File.exist?(google_bundle_path)
                       parse_bundle(File.read(google_bundle_path, encoding: 'utf-8'))
                     else
                       {}
                     end

  Array(new_entries).each do |family, block|
    next if family.nil? || family.strip.empty?
    next if block.nil? || block.strip.empty?

    existing_entries[family] = build_block_entry(family, block)
  end

  content = if existing_entries.empty?
              "/* No Google Fonts downloaded (generated by FontManager) */\n"
            else
              existing_entries.sort_by { |family, _| family.downcase }
                              .map { |_, entry| entry.rstrip }
                              .join("\n\n") + "\n"
            end

  File.write(google_bundle_path, content, encoding: 'utf-8')
rescue StandardError => e
  Common.log_warn("Google Fonts CSS の更新に失敗しました: #{e.class}: #{e.message}")
end