fontist 3.0.27 → 3.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,68 @@
1
+ require "fileutils"
2
+
3
+ module Fontist
4
+ module FontOperations
5
+ # Keeps only the glyphs for the given characters.
6
+ module Subset
7
+ UNICODE_PART = /\A(u\+)?\h+(-(u\+)?\h+)?\z/i
8
+
9
+ module_function
10
+
11
+ # @param source [String] font file
12
+ # @param output [String] where to write the subset font
13
+ # @param unicodes [String, Array<Integer>, nil] "U+0020-007E,U+00E9"
14
+ # or codepoints
15
+ # @param text [String, nil] characters to keep
16
+ # @return [String] output path
17
+ def call(source, output, unicodes: nil, text: nil)
18
+ FontOperations.guard("Subsetting") do
19
+ raise Errors::SourceNotFoundError, source unless File.exist?(source)
20
+
21
+ codepoints = parse_unicodes(unicodes) + text.to_s.codepoints
22
+ if codepoints.empty?
23
+ raise Error, "No characters to keep: give unicodes or text"
24
+ end
25
+
26
+ write(source, output, codepoints)
27
+ end
28
+ end
29
+
30
+ def write(source, output, codepoints)
31
+ font = Fontisan::FontLoader.load(source)
32
+ gids = glyph_ids(font, codepoints, File.basename(source))
33
+ options = Fontisan::Subset::Options.new(profile: "web")
34
+ binary = Fontisan::Subset::Builder.new(font, gids, options).build
35
+ FileUtils.mkdir_p(File.dirname(output))
36
+ File.binwrite(output, binary)
37
+ output
38
+ end
39
+
40
+ def glyph_ids(font, codepoints, name)
41
+ mappings = font.table("cmap").unicode_mappings
42
+ gids = codepoints.uniq.filter_map { |cp| mappings[cp] }.uniq.sort
43
+ return gids if gids.any?
44
+
45
+ raise Error, "None of the requested characters exist in #{name}"
46
+ end
47
+
48
+ # "U+0020-007E,U+00E9" => [32..126, 233].flatten
49
+ def parse_unicodes(value)
50
+ return Array(value).map(&:to_i) unless value.nil? || value.is_a?(String)
51
+
52
+ value.to_s.split(/[,\s]+/).reject(&:empty?).flat_map do |part|
53
+ parse_part(part)
54
+ end
55
+ end
56
+
57
+ def parse_part(part)
58
+ unless part.match?(UNICODE_PART)
59
+ raise Error, "Invalid unicode '#{part}'. Use e.g. U+0020-007E,U+00E9"
60
+ end
61
+
62
+ first, last = part.split("-", 2)
63
+ .map { |h| h.sub(/\Au\+/i, "").to_i(16) }
64
+ last ? (first..last).to_a : [first]
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,25 @@
1
+ module Fontist
2
+ # Font file operations backed by the Fontisan gem: collection member
3
+ # extraction, collection bundling, format conversion, subsetting and
4
+ # variable-font instancing.
5
+ module FontOperations
6
+ class Error < Errors::GeneralError; end
7
+
8
+ autoload :Convert, "#{__dir__}/font_operations/convert"
9
+ autoload :Extract, "#{__dir__}/font_operations/extract"
10
+ autoload :Instance, "#{__dir__}/font_operations/instance"
11
+ autoload :Pack, "#{__dir__}/font_operations/pack"
12
+ autoload :Subset, "#{__dir__}/font_operations/subset"
13
+
14
+ # Runs the block, turning any Fontisan failure into FontOperations::Error
15
+ # that names the operation.
16
+ def self.guard(operation)
17
+ require "fontisan"
18
+ yield
19
+ rescue Error, Errors::GeneralError
20
+ raise
21
+ rescue StandardError => e
22
+ raise Error, "#{operation} failed: #{e.message}"
23
+ end
24
+ end
25
+ end
@@ -30,6 +30,16 @@ module Fontist
30
30
  # Collection handling
31
31
  attribute :collection_index, :integer
32
32
 
33
+ # Subsetting: keep only these characters (U+0020-007E,U+00E9 / literal text)
34
+ attribute :subset_unicodes, :string
35
+ attribute :subset_text, :string
36
+
37
+ # Static instance of a variable font: "wght=300,wdth=90"
38
+ attribute :instance, :string
39
+
40
+ # Explicit opt-in: install the original format when conversion fails
41
+ attribute :fallback_to_original, :boolean, default: false
42
+
33
43
  # Convenience constructor for CLI
34
44
  def self.from_options(options = {})
35
45
  new(
@@ -40,9 +50,19 @@ module Fontist
40
50
  transcode_path: options[:transcode_path],
41
51
  keep_original: options.fetch(:keep_original, true),
42
52
  collection_index: options[:collection_index],
53
+ **operation_options(options),
43
54
  )
44
55
  end
45
56
 
57
+ def self.operation_options(options)
58
+ {
59
+ subset_unicodes: options[:subset_unicodes],
60
+ subset_text: options[:subset_text],
61
+ instance: options[:instance],
62
+ fallback_to_original: options[:fallback_to_original] || false,
63
+ }
64
+ end
65
+
46
66
  def self.parse_variable_axes(value)
47
67
  return nil if value.nil?
48
68
  return value if value.is_a?(Array)
@@ -72,6 +92,21 @@ module Fontist
72
92
  !available_formats.include?(format)
73
93
  end
74
94
 
95
+ # Check if a subset of characters was requested
96
+ def subset_requested?
97
+ !(subset_unicodes.to_s.strip.empty? && subset_text.to_s.empty?)
98
+ end
99
+
100
+ # Check if a static instance of a variable font was requested
101
+ def instance_requested?
102
+ !instance.to_s.strip.empty?
103
+ end
104
+
105
+ # Axis coordinates of the requested instance: {"wght" => 300.0}
106
+ def instance_coordinates
107
+ FontOperations::Instance.parse(instance)
108
+ end
109
+
75
110
  # Check if we need a specific collection index
76
111
  def specific_collection_index?
77
112
  !collection_index.nil?
@@ -218,6 +218,23 @@ module Fontist
218
218
  schema_version == 5
219
219
  end
220
220
 
221
+ # Formats of the style files, as recorded or read from file extensions.
222
+ # Archive resources often carry no format of their own.
223
+ def style_formats
224
+ fonts.flat_map(&:styles).flat_map do |style|
225
+ extension = File.extname(style.font.to_s).delete(".").downcase
226
+ Array(style.formats) + [extension]
227
+ end.reject(&:empty?).uniq
228
+ end
229
+
230
+ # Whether a resource offers a format, falling back to the style files
231
+ # when the resource records none.
232
+ def resource_offers_format?(resource, format)
233
+ return resource.format == format if resource.format
234
+
235
+ style_formats.include?(format)
236
+ end
237
+
221
238
  # Get the effective schema version (default to 4 if not set)
222
239
  def effective_schema_version
223
240
  schema_version || 4
@@ -21,10 +21,9 @@ module Fontist
21
21
  list = filter(formulas)
22
22
  return [] if list.empty?
23
23
 
24
- # Use FormatMatcher for format filtering
25
24
  if @format_spec&.has_constraints?
26
25
  filtered = filter_by_format_spec(list)
27
- raise_format_not_available_error(list) if filtered.empty? && !list.empty?
26
+ raise_format_not_available_error(list) if filtered.empty?
28
27
 
29
28
  list = filtered
30
29
  end
@@ -45,17 +44,37 @@ module Fontist
45
44
  ensure_fontist_version(list)
46
45
  end
47
46
 
47
+ # Formulas offering the requested format come first. When none does, a
48
+ # plain format request falls back to every formula so the installer can
49
+ # convert; a variable-font request cannot be met by conversion.
48
50
  def filter_by_format_spec(formulas)
49
51
  matcher = FormatMatcher.new(@format_spec)
52
+ offering = formulas.filter_map do |formula|
53
+ narrow_to_offered_format(formula, matcher)
54
+ end
55
+ return offering if offering.any?
56
+ return formulas if convertible_request?
50
57
 
51
- formulas.filter_map do |formula|
52
- next formula unless formula.v5?
58
+ []
59
+ end
53
60
 
54
- matching = matcher.filter_resources(formula.resources)
55
- if matching.any?
56
- formula.dup.tap { |f| f.resources = matching }
57
- end
61
+ def narrow_to_offered_format(formula, matcher)
62
+ return formula unless formula.v5?
63
+
64
+ matching = formula.resources.select do |resource|
65
+ offers_format?(formula, resource) && matcher.matches_resource?(resource)
58
66
  end
67
+ formula.dup.tap { |f| f.resources = matching } if matching.any?
68
+ end
69
+
70
+ def offers_format?(formula, resource)
71
+ return true unless @format_spec.format
72
+
73
+ formula.resource_offers_format?(resource, @format_spec.format)
74
+ end
75
+
76
+ def convertible_request?
77
+ !@format_spec.format.nil? && !@format_spec.variable_requested?
59
78
  end
60
79
 
61
80
  def ensure_fontist_version(formulas)
@@ -51,6 +51,23 @@ module Fontist
51
51
 
52
52
  private
53
53
 
54
+ # HEAD commit of the google/fonts clone, used to pin raw file URLs
55
+ def repository_commit
56
+ return @repository_commit if defined?(@repository_commit)
57
+
58
+ @repository_commit = git_head_commit
59
+ end
60
+
61
+ # No shell involved, so this also works on Windows, where
62
+ # `2>/dev/null` is not a valid redirect.
63
+ def git_head_commit
64
+ sha = IO.popen(%w[git rev-parse HEAD], chdir: @source_path.to_s,
65
+ err: File::NULL, &:read)
66
+ sha.strip[/\A\h{40}\z/]
67
+ rescue SystemCallError
68
+ nil
69
+ end
70
+
54
71
  # Validate that the source path exists and contains fonts
55
72
  def validate_source_path!
56
73
  unless @source_path.exist?
@@ -155,6 +172,17 @@ module Fontist
155
172
  description: description,
156
173
  homepage: license_info[:homepage],
157
174
  font_file_data: font_files_data,
175
+ ).tap do |family|
176
+ family.variable_source = variable_source_for(metadata, family_dir)
177
+ end
178
+ end
179
+
180
+ # Real variable font files of the family, pinned to the clone's commit
181
+ def variable_source_for(metadata, family_dir)
182
+ VariableFontSource.from_metadata(
183
+ metadata,
184
+ directory: "#{family_dir.parent.basename}/#{family_dir.basename}",
185
+ commit: repository_commit,
158
186
  )
159
187
  end
160
188
 
@@ -50,48 +50,32 @@ module Fontist
50
50
  def build_resources
51
51
  resources = {}
52
52
 
53
- # Check if this is a variable font (has axes)
54
- is_variable = family.variable_font?
55
-
56
- if is_variable
57
- # Variable fonts: the single file IS the variable font
58
- # Google Fonts variable fonts are WOFF2 only
59
- if woff2_files[family.family]&.any?
60
- resources["woff2_variable"] = build_resource_entry(
61
- woff2_files[family.family],
62
- format: "woff2",
63
- variable: true,
64
- axes: family.axes,
65
- )
66
- end
53
+ # The only genuine variable files are the ones in google/fonts.
54
+ # The API serves static per-weight instances for every family,
55
+ # including variable ones, so those always go to *_static.
56
+ if variable_source
57
+ resources["ttf_variable"] =
58
+ variable_source.resource(family.family)
59
+ end
67
60
 
68
- # Also include TTF if available (for desktop use)
69
- if ttf_files[family.family]&.any?
70
- resources["ttf_variable"] = build_resource_entry(
71
- ttf_files[family.family],
72
- format: "ttf",
73
- variable: true,
74
- axes: family.axes,
75
- )
76
- end
77
- else
78
- # Static fonts: separate TTF and WOFF2
79
- static_ttf = filter_static_files(ttf_files[family.family])
80
- if static_ttf.any?
81
- resources["ttf_static"] = build_resource_entry(
82
- static_ttf, format: "ttf", variable: false
83
- )
84
- end
61
+ resources.merge(static_resources)
62
+ end
85
63
 
86
- static_woff2 = filter_static_files(woff2_files[family.family])
87
- if static_woff2.any?
88
- resources["woff2_static"] = build_resource_entry(
89
- static_woff2, format: "woff2", variable: false
90
- )
91
- end
92
- end
64
+ def static_resources
65
+ static_resource("ttf_static", "ttf", ttf_files)
66
+ .merge(static_resource("woff2_static", "woff2", woff2_files))
67
+ end
93
68
 
94
- resources
69
+ def static_resource(key, format, files_by_family)
70
+ files = filter_static_files(files_by_family[family.family])
71
+ return {} if files.empty?
72
+
73
+ { key => build_resource_entry(files, format: format,
74
+ variable: false) }
75
+ end
76
+
77
+ def variable_source
78
+ github_family&.variable_source
95
79
  end
96
80
 
97
81
  def build_resource_entry(files, format:, variable:, axes: nil)
@@ -132,17 +116,13 @@ module Fontist
132
116
 
133
117
  def is_variable_variant?(variant)
134
118
  # Variable fonts often have axes in the filename like "VariableFont_wght"
135
- return true if variant.include?("Variable")
136
-
137
- return false unless family.variable_font?
138
-
139
- family.axes.any? { |axis| variant.include?(axis.tag) }
119
+ variant.include?("Variable")
140
120
  end
141
121
 
142
122
  def build_fonts
143
123
  parsed_fonts = []
144
124
 
145
- # V5: Download and parse ALL TTF files (including variable fonts)
125
+ # V5: Download and parse the API's static TTF files
146
126
  ttf_files[family.family]&.each do |variant, url|
147
127
  sleep(0.05) # Throttle API requests
148
128
 
@@ -150,7 +130,6 @@ module Fontist
150
130
  downloaded = Fontist::Utils::Downloader.download(url)
151
131
  metadata = Fontist::Import::FontMetadataExtractor.new(downloaded.path).extract
152
132
 
153
- # V5: Include both static and variable fonts
154
133
  filename = url.split("/").last
155
134
 
156
135
  style_data = build_style_data(metadata, filename)
@@ -164,6 +143,7 @@ module Fontist
164
143
  end
165
144
  end
166
145
 
146
+ parsed_fonts.concat(variable_source.styles) if variable_source
167
147
  return [] if parsed_fonts.empty?
168
148
 
169
149
  group_fonts_by_subfamily(parsed_fonts)
@@ -173,17 +153,10 @@ module Fontist
173
153
  # Add formats available for this style
174
154
  style["formats"] = determine_formats_for_style(variant)
175
155
 
176
- # Add variable font info
177
- # For variable fonts, ALL styles should be marked as variable
178
- if family.variable_font?
179
- style["variable_font"] = true
180
- if family.axes&.any?
181
- style["variable_axes"] =
182
- family.axes.map(&:tag)
183
- end
184
- else
185
- style["variable_font"] = false
186
- end
156
+ # Styles built from API files are static instances; the real
157
+ # variable styles come from variable_source.
158
+ style["variable_font"] = false
159
+ style["source_resource"] = "ttf_static" if variable_source
187
160
  end
188
161
 
189
162
  def determine_formats_for_style(variant)
@@ -24,6 +24,9 @@ module Fontist
24
24
  attribute :homepage, :string
25
25
  attribute :font_file_data, :string
26
26
 
27
+ # Real variable font files from google/fonts (VariableFontSource)
28
+ attr_accessor :variable_source
29
+
27
30
  json do
28
31
  map "family", to: :family
29
32
  map "variants", to: :variants
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontist
4
+ module Import
5
+ module Google
6
+ # The real variable font files of a family as published in the
7
+ # google/fonts repository (METADATA.pb `fonts` entries whose filename
8
+ # carries an axis list, e.g. "Roboto[wdth,wght].ttf").
9
+ #
10
+ # The Google Fonts API only serves static per-weight instances, so these
11
+ # files are the only genuine variable sources. Google publishes them as
12
+ # TTF only.
13
+ class VariableFontSource
14
+ RAW_BASE = "https://raw.githubusercontent.com/google/fonts"
15
+ AXIS_LIST = /\[([^\]]+)\]/
16
+
17
+ attr_reader :directory, :commit, :files, :axes
18
+
19
+ # @param directory [String] repo-relative family dir, e.g. "ofl/roboto"
20
+ # @param commit [String] google/fonts commit SHA the URLs are pinned to
21
+ # @param files [Array<Models::FontFileMetadata>] METADATA.pb fonts
22
+ # @param axes [Array<Models::AxisMetadata>] METADATA.pb axes
23
+ def self.from_metadata(metadata, directory:, commit:)
24
+ files = Array(metadata.fonts).select do |font|
25
+ font.filename.to_s.match?(AXIS_LIST)
26
+ end
27
+ return nil if files.empty? || commit.to_s.empty?
28
+
29
+ new(directory: directory, commit: commit, files: files,
30
+ axes: Array(metadata.axes))
31
+ end
32
+
33
+ def initialize(directory:, commit:, files:, axes:)
34
+ @directory = directory
35
+ @commit = commit
36
+ @files = files
37
+ @axes = axes
38
+ end
39
+
40
+ def urls
41
+ files.map { |file| url_for(file.filename) }
42
+ end
43
+
44
+ # Axis tags in METADATA.pb order, limited to those the files carry.
45
+ def axis_tags
46
+ present = files.flat_map { |file| tags_in(file.filename) }.uniq
47
+ declared = axes.map(&:tag)
48
+ (declared & present) + (present - declared)
49
+ end
50
+
51
+ # Resource entry for a v5 formula (TTF only).
52
+ def resource(family_name)
53
+ {
54
+ "source" => "google",
55
+ "family" => family_name,
56
+ "files" => urls,
57
+ "urls" => urls,
58
+ "format" => "ttf",
59
+ "variable_axes" => axis_tags,
60
+ }
61
+ end
62
+
63
+ # One style per variable file, described by METADATA.pb.
64
+ def styles
65
+ files.map { |file| style_for(file) }
66
+ end
67
+
68
+ private
69
+
70
+ def style_for(file)
71
+ names_of(file).merge(
72
+ "font" => file.filename,
73
+ "formats" => ["ttf"],
74
+ "variable_font" => true,
75
+ "variable_axes" => tags_in(file.filename),
76
+ "source_resource" => "ttf_variable",
77
+ ).compact
78
+ end
79
+
80
+ def names_of(file)
81
+ {
82
+ "family_name" => file.name,
83
+ "type" => file.style.to_s == "italic" ? "Italic" : "Regular",
84
+ "full_name" => file.full_name,
85
+ "post_script_name" => file.post_script_name,
86
+ "copyright" => file.copyright,
87
+ }
88
+ end
89
+
90
+ def tags_in(filename)
91
+ filename[AXIS_LIST, 1].to_s.split(",")
92
+ end
93
+
94
+ def url_for(filename)
95
+ # Brackets stay literal: the installer matches style files against
96
+ # the URL basename, which must equal the downloaded file name.
97
+ encoded = filename.gsub(/[^A-Za-z0-9._,\-\[\]]/) do |char|
98
+ char.bytes.map { |b| format("%%%02X", b) }.join
99
+ end
100
+ "#{RAW_BASE}/#{commit}/#{directory}/#{encoded}"
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -7,6 +7,7 @@ module Fontist
7
7
  autoload :MetadataAdapter, "#{__dir__}/google/metadata_adapter"
8
8
  autoload :MetadataParser, "#{__dir__}/google/metadata_parser"
9
9
  autoload :Models, "#{__dir__}/google/models"
10
+ autoload :VariableFontSource, "#{__dir__}/google/variable_font_source"
10
11
 
11
12
  DEFAULT_MAX_COUNT = 100
12
13
 
@@ -5,6 +5,9 @@ module Fontist
5
5
  module Import
6
6
  class SilImporter
7
7
  ROOT = "https://software.sil.org/fonts/".freeze
8
+ CARD_LINK_SELECTOR = "a.inner-card".freeze
9
+ CARD_TITLE_SELECTOR = ".title-text".freeze
10
+ LEGACY_LINK_SELECTOR = "table.products div.title > a".freeze
8
11
 
9
12
  def initialize(options = {})
10
13
  @output_path = options[:output_path]
@@ -99,7 +102,7 @@ module Fontist
99
102
  end
100
103
 
101
104
  def process_single_font(link, current, total)
102
- family_name = link.content
105
+ family_name = link_name(link)
103
106
 
104
107
  # Display progress
105
108
  ImportDisplay.progress(current, total, family_name) if @verbose
@@ -257,7 +260,8 @@ module Fontist
257
260
  end
258
261
 
259
262
  def empty_result
260
- { successful: 0, failed: 0, errors: [], duration: 0 }
263
+ { successful: 0, failed: 0, errors: [], duration: 0,
264
+ no_fonts_found: true }
261
265
  end
262
266
 
263
267
  def build_results(duration)
@@ -274,12 +278,21 @@ module Fontist
274
278
  def font_links
275
279
  html = URI.parse(ROOT).open("User-Agent" => Fontist::Utils::UserAgent.random_user_agent).read
276
280
  document = Nokogiri::HTML.parse(html)
277
- document.css("table.products div.title > a")
281
+ links = document.css(CARD_LINK_SELECTOR)
282
+ links = document.css(LEGACY_LINK_SELECTOR) if links.empty?
283
+ links
284
+ end
285
+
286
+ # Font name of a product link. Cards on the current site also carry a
287
+ # description, so the name is taken from the card title only.
288
+ def link_name(link)
289
+ title = link.at_css(CARD_TITLE_SELECTOR)
290
+ (title || link).text.strip
278
291
  end
279
292
 
280
293
  def filter_by_name(links)
281
294
  links.select do |link|
282
- link.content.downcase.include?(@font_name.downcase)
295
+ link_name(link).downcase.include?(@font_name.downcase)
283
296
  end
284
297
  end
285
298
 
@@ -342,7 +355,7 @@ module Fontist
342
355
  end
343
356
 
344
357
  def find_archive_url_by_page_link(link)
345
- family_name = link.content
358
+ family_name = link_name(link)
346
359
 
347
360
  # Skip known index pages that just link to other fonts
348
361
  if index_page?(family_name)
@@ -53,7 +53,7 @@ module Fontist
53
53
  def base_attrs(description)
54
54
  {
55
55
  schema_version: 5,
56
- name: description,
56
+ name: formula_name(description),
57
57
  description: "#{description} for Windows",
58
58
  homepage: HOMEPAGE,
59
59
  platforms: ["windows"],
@@ -119,7 +119,7 @@ module Fontist
119
119
  end
120
120
 
121
121
  def formula_path(description)
122
- filename = "#{normalize_key(description)}.yml"
122
+ filename = "#{formula_key(description)}.yml"
123
123
  formula_dir.join(filename)
124
124
  end
125
125
 
@@ -135,6 +135,17 @@ module Fontist
135
135
  end
136
136
  end
137
137
 
138
+ # Formula names keep to letters, digits and spaces, so the file key the
139
+ # formulas repository derives from them (lowercase, spaces as
140
+ # underscores) needs no shell escaping in its CI.
141
+ def formula_name(description)
142
+ description.tr("-", " ").delete("^A-Za-z0-9 ").squeeze(" ").strip
143
+ end
144
+
145
+ def formula_key(description)
146
+ formula_name(description).downcase.tr(" ", "_")
147
+ end
148
+
138
149
  def normalize_key(name)
139
150
  name.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/^_|_$/, "")
140
151
  end
@@ -122,6 +122,24 @@ module Fontist
122
122
  CLI::STATUS_UNKNOWN_ERROR
123
123
  end
124
124
 
125
+ desc "windows", "Import Windows font extension packs (Features on Demand)"
126
+ option :output_path,
127
+ type: :string,
128
+ desc: "Output directory for generated formulas " \
129
+ "(default: formulas/windows)"
130
+
131
+ def windows
132
+ handle_class_options(options)
133
+ require_relative "import/windows"
134
+
135
+ Import::Windows.new(formulas_dir: options[:output_path]).call
136
+
137
+ CLI::STATUS_SUCCESS
138
+ rescue StandardError => e
139
+ Fontist.ui.error("Import error: #{e.message}")
140
+ CLI::STATUS_UNKNOWN_ERROR
141
+ end
142
+
125
143
  desc "sil", "Import formulas from SIL International"
126
144
  option :output_path,
127
145
  type: :string,
@@ -158,6 +176,7 @@ module Fontist
158
176
  )
159
177
 
160
178
  result = importer.call
179
+ return CLI::STATUS_UNKNOWN_ERROR if result[:no_fonts_found]
161
180
 
162
181
  # Report results only in non-verbose mode (verbose mode already has rich summary)
163
182
  unless options[:verbose]