fontist 3.0.28 → 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,110 @@
1
+ require "cgi"
2
+ require "fileutils"
3
+
4
+ module Fontist
5
+ # Web CSS for fonts.
6
+ #
7
+ # - {Css.url} / {Css.link}: Google Fonts CSS2 URL for a Google family.
8
+ # No network access, no font installation.
9
+ # - {Css.local}: copies fonts already installed by fontist into a directory
10
+ # and writes a fonts.css with one @font-face per style.
11
+ module Css
12
+ class Error < Errors::GeneralError; end
13
+
14
+ class UnknownFamilyError < Error
15
+ def initialize(family)
16
+ super("Font family '#{family}' was not found in the Fontist " \
17
+ "formulas. Check the name, or run `fontist update`.")
18
+ end
19
+ end
20
+
21
+ class AmbiguousFamilyError < Error
22
+ def initialize(family, keys)
23
+ super("Font family '#{family}' matches several Google formulas " \
24
+ "(#{keys.join(', ')}); cannot choose one.")
25
+ end
26
+ end
27
+
28
+ class NotGoogleFamilyError < Error
29
+ def initialize(family)
30
+ super("Font family '#{family}' is not a Google Fonts family, so " \
31
+ "it has no Google CSS URL. Use `--local` to build CSS from " \
32
+ "installed font files.")
33
+ end
34
+ end
35
+
36
+ class InvalidStyleError < Error
37
+ def initialize(style)
38
+ super("Invalid style '#{style}'. Use a weight 100-900 with an " \
39
+ "optional 'i' for italic, e.g. 400, 700i.")
40
+ end
41
+ end
42
+
43
+ class UnavailableStyleError < Error
44
+ def initialize(family, style, available)
45
+ super("Style '#{style}' is not available for '#{family}'. " \
46
+ "Available styles: #{available.join(', ')}.")
47
+ end
48
+ end
49
+
50
+ class InvalidDisplayError < Error
51
+ def initialize(display, allowed)
52
+ super("Invalid display '#{display}'. Allowed: #{allowed.join(', ')}.")
53
+ end
54
+ end
55
+
56
+ class InvalidOptionError < Error; end
57
+
58
+ DISPLAYS = %w[auto block swap fallback optional].freeze
59
+ GOOGLE_CSS2 = "https://fonts.googleapis.com/css2".freeze
60
+
61
+ class << self
62
+ # @param family [String] e.g. "Source Sans 3"
63
+ # @param styles [Array<String>, String, nil] e.g. %w[300 600i]
64
+ # @param display [String] auto|block|swap|fallback|optional
65
+ # @return [String] the Google CSS2 URL (raw `&`)
66
+ def url(family, styles: nil, display: "swap")
67
+ display = validate_display(display)
68
+ formula, canonical = GoogleFamily.resolve(family)
69
+ requested = Styles.parse(styles)
70
+ Styles.verify(canonical, Styles.faces(formula), requested)
71
+
72
+ "#{GOOGLE_CSS2}?family=#{CGI.escape(canonical)}" \
73
+ "#{axis_spec(requested)}&display=#{display}"
74
+ end
75
+
76
+ # @return [String] an HTML stylesheet link tag for {.url}
77
+ def link(family, styles: nil, display: "swap")
78
+ href = CGI.escapeHTML(url(family, styles: styles, display: display))
79
+ %(<link rel="stylesheet" href="#{href}">)
80
+ end
81
+
82
+ # Copy installed fonts into output_dir and write fonts.css.
83
+ # @return [String] path of the written fonts.css
84
+ def local(family, output_dir:, **options)
85
+ Local.new(family, output_dir: output_dir, **options).call
86
+ end
87
+
88
+ private
89
+
90
+ def validate_display(display)
91
+ value = display.to_s
92
+ return value if DISPLAYS.include?(value)
93
+
94
+ raise InvalidDisplayError.new(display, DISPLAYS)
95
+ end
96
+
97
+ def axis_spec(requested)
98
+ return "" if requested.empty?
99
+ return ":wght@#{requested.map(&:last).join(';')}" if
100
+ requested.none? { |ital, _| ital == 1 }
101
+
102
+ ":ital,wght@#{requested.map { |t| t.join(',') }.join(';')}"
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ require_relative "css/styles"
109
+ require_relative "css/google_family"
110
+ require_relative "css/local"
@@ -248,6 +248,9 @@ module Fontist
248
248
  end
249
249
  end
250
250
 
251
+ # Variable-font instancing errors
252
+ class InstanceError < GeneralError; end
253
+
251
254
  # Transcoding errors
252
255
  class UnsupportedTranscodeError < GeneralError
253
256
  def initialize(source_format, target_format)
@@ -63,7 +63,8 @@ module Fontist
63
63
  font = validate_and_load_single_font(path)
64
64
  end
65
65
  names = extract_names_from_font(font)
66
- font.close
66
+ # WOFF and WOFF2 fonts from Fontisan hold no open file to close.
67
+ font.close if font.respond_to?(:close)
67
68
  names
68
69
  rescue StandardError => e
69
70
  raise_font_file_error(e)
@@ -50,6 +50,19 @@ module Fontist
50
50
  end.flatten
51
51
  end
52
52
 
53
+ # Find fonts offered in a format (ttf, otf, woff, woff2, ttc, otc)
54
+ def by_format(format)
55
+ matching_formulas.flat_map do |formula|
56
+ next [] unless formula.v5?
57
+
58
+ each_resource(formula).select do |resource|
59
+ formula.resource_offers_format?(resource, format)
60
+ end.map do |resource|
61
+ build_font_match(formula, resource.name, resource, format: format)
62
+ end
63
+ end
64
+ end
65
+
53
66
  # Find fonts by category
54
67
  def by_category(category)
55
68
  matching_formulas.select do |formula|
@@ -78,12 +91,12 @@ module Fontist
78
91
  Array(formula.resources).filter_map(&:name)
79
92
  end
80
93
 
81
- def build_font_match(formula, name, resource)
94
+ def build_font_match(formula, name, resource, format: resource.format)
82
95
  FontMatch.new(
83
96
  name: formula.name,
84
97
  resource: name,
85
98
  axes: resource.axes_tags,
86
- format: resource.format,
99
+ format: format,
87
100
  category: extract_category(formula),
88
101
  )
89
102
  end
@@ -122,15 +122,22 @@ module Fontist
122
122
  source_files.include?(File.basename(path))
123
123
  end
124
124
 
125
+ # Styles already in the requested format. When none is, a plain format
126
+ # request keeps every style so the files can be converted.
127
+ def styles_in_requested_format(styles)
128
+ return styles unless @format_spec&.has_constraints?
129
+
130
+ matching = FormatMatcher.new(@format_spec).filter_styles(styles)
131
+ return matching if matching.any? || @format_spec.variable_requested?
132
+
133
+ @format_spec.format ? styles : matching
134
+ end
135
+
125
136
  def source_files
126
137
  @source_files ||= begin
127
138
  styles = filtered_styles
128
139
 
129
- # Use FormatMatcher for filtering
130
- if @format_spec&.has_constraints? && @formula.v5?
131
- matcher = FormatMatcher.new(@format_spec)
132
- styles = matcher.filter_styles(styles)
133
- end
140
+ styles = styles_in_requested_format(styles) if @formula.v5?
134
141
 
135
142
  file_names = styles.map { |s| s.source_font || s.font }
136
143
 
@@ -177,17 +184,93 @@ module Fontist
177
184
  end
178
185
 
179
186
  def install_font_file(source)
180
- source_basename = File.basename(source)
181
- target_name = target_filename(source_basename) || source_basename
182
- source_format = detect_font_format(source)
187
+ Dir.mktmpdir("fontist-ops") do |workdir|
188
+ original_name = target_filename(File.basename(source)) ||
189
+ File.basename(source)
190
+ prepared, target_name = prepare_font(source, original_name, workdir)
191
+ result = @location.install_font(prepared, target_name)
192
+ install_original_too(source, original_name) if
193
+ converted?(source, prepared)
194
+ result
195
+ end
196
+ end
183
197
 
184
- # Check if transcoding is needed (format requested but not available)
185
- if @format_spec&.format && @format_spec.format != source_format
186
- check_transcode_license_warning!
187
- install_with_conversion(source, target_name, source_format)
188
- else
189
- @location.install_font(source, target_name)
198
+ def converted?(source, prepared)
199
+ requested = @format_spec&.format
200
+ requested && detect_font_format(prepared) == requested &&
201
+ detect_font_format(source) != requested
202
+ end
203
+
204
+ # Keeps the untouched original alongside converted output when the user
205
+ # asked for it with a transcode_path (existing --keep-original behavior).
206
+ def install_original_too(source, name)
207
+ return unless @format_spec&.keep_original && @format_spec.transcode_path
208
+
209
+ @location.install_font(source, name)
210
+ end
211
+
212
+ # Applies the requested operations (collection member extraction,
213
+ # instancing, subsetting, conversion) and returns the file to install and
214
+ # its name.
215
+ def prepare_font(source, target_name, workdir)
216
+ if @format_spec&.specific_collection_index? &&
217
+ FontOperations::Extract.collection?(source)
218
+ source = FontOperations::Extract.call(
219
+ source, index: @format_spec.collection_index, output_dir: workdir
220
+ )
221
+ target_name = File.basename(source)
190
222
  end
223
+
224
+ source, target_name = apply_instance(source, target_name, workdir)
225
+ source, target_name = apply_subset(source, target_name, workdir)
226
+ apply_conversion(source, target_name, workdir)
227
+ end
228
+
229
+ def apply_instance(source, target_name, workdir)
230
+ return [source, target_name] unless @format_spec&.instance_requested?
231
+
232
+ check_transcode_license_warning!
233
+ coordinates = @format_spec.instance_coordinates
234
+ name = FontOperations::Instance.file_name(target_name, coordinates)
235
+ output = File.join(workdir, name)
236
+ FontOperations::Instance.call(source, output, coordinates: coordinates)
237
+ [output, name]
238
+ end
239
+
240
+ def apply_subset(source, target_name, workdir)
241
+ return [source, target_name] unless @format_spec&.subset_requested?
242
+
243
+ check_transcode_license_warning!
244
+ output = File.join(workdir, "subset-#{File.basename(source)}")
245
+ FontOperations::Subset.call(
246
+ source, output,
247
+ unicodes: @format_spec.subset_unicodes,
248
+ text: @format_spec.subset_text
249
+ )
250
+ [output, target_name]
251
+ end
252
+
253
+ def apply_conversion(source, target_name, workdir)
254
+ requested = @format_spec&.format
255
+ source_format = detect_font_format(source)
256
+ return [source, target_name] if [nil, source_format].include?(requested)
257
+
258
+ check_transcode_license_warning!
259
+ name = target_name.sub(/\.[^.]+\z/, ".#{requested}")
260
+ output = File.join(@format_spec.transcode_path || workdir, name)
261
+ convert_or_fallback(source, target_name, output, name)
262
+ end
263
+
264
+ def convert_or_fallback(source, original_name, output, name)
265
+ FontOperations::Convert.call(source, @format_spec.format, output)
266
+ Fontist.ui.success("Converted to #{@format_spec.format}: #{name}")
267
+ [output, name]
268
+ rescue Errors::GeneralError => e
269
+ raise unless @format_spec.fallback_to_original
270
+
271
+ Fontist.ui.warn("Could not convert: #{e.message}")
272
+ Fontist.ui.warn("Installing original format (--fallback-to-original)")
273
+ [source, original_name]
191
274
  end
192
275
 
193
276
  def detect_font_format(path)
@@ -228,86 +311,6 @@ module Fontist
228
311
  Fontist.ui.warn("#{'=' * 60}\n")
229
312
  end
230
313
 
231
- def install_with_conversion(source, target_name, source_format)
232
- target_format = @format_spec.format
233
-
234
- # Check if Fontisan can convert between formats
235
- matcher = FormatMatcher.new(@format_spec)
236
- unless matcher.can_convert?(source_format, target_format)
237
- Fontist.ui.warn(
238
- "Cannot convert from #{source_format} to #{target_format}",
239
- )
240
- Fontist.ui.warn("Installing original format instead")
241
- return @location.install_font(source, target_name)
242
- end
243
-
244
- begin
245
- converted_path = convert_with_fontisan(source, target_format)
246
-
247
- # Determine where to save converted font
248
- converted_name = target_name.sub(/\.[^.]+$/, ".#{target_format}")
249
-
250
- Fontist.ui.success(
251
- "Converted #{source_format} to #{target_format}: #{converted_name}",
252
- )
253
-
254
- # Install converted font
255
- result = @location.install_font(converted_path, converted_name)
256
-
257
- # Keep original if requested and transcode_path specified
258
- if @format_spec.keep_original && @format_spec.transcode_path
259
- @location.install_font(source, target_name)
260
- end
261
-
262
- # Clean up temp converted file if Fontisan created one
263
- cleanup_temp_file(converted_path) if converted_path != source
264
-
265
- result
266
- rescue StandardError => e
267
- Fontist.ui.warn("Could not convert to #{target_format}: #{e.message}")
268
- Fontist.ui.warn("Installing original format instead")
269
- @location.install_font(source, target_name)
270
- end
271
- end
272
-
273
- # Convert font using Fontisan library
274
- def convert_with_fontisan(source_path, target_format)
275
- require "fontisan"
276
-
277
- font = Fontisan::FontLoader.load(source_path)
278
-
279
- case target_format
280
- when "woff"
281
- font.to_woff(path: temp_path_for(source_path, "woff"))
282
- when "woff2"
283
- font.to_woff2(path: temp_path_for(source_path, "woff2"))
284
- else
285
- raise Errors::UnsupportedTranscodeError.new(
286
- File.extname(source_path),
287
- target_format,
288
- )
289
- end
290
- rescue LoadError
291
- Fontist.ui.error(
292
- "Fontisan gem not found. Transcoding requires the fontisan gem.",
293
- )
294
- Fontist.ui.error("Add it to your Gemfile or run: gem install fontisan")
295
- raise
296
- end
297
-
298
- def temp_path_for(source_path, format)
299
- base = File.basename(source_path, ".*")
300
- dir = @format_spec&.transcode_path || Dir.mktmpdir
301
- FileUtils.mkdir_p(dir)
302
- File.join(dir, "#{base}.#{format}")
303
- end
304
-
305
- def cleanup_temp_file(path)
306
- FileUtils.rm_f(path)
307
- rescue StandardError
308
- # Ignore cleanup errors
309
- end
310
-
311
314
  def macos_asset_directory
312
315
  # Install to: /System/Library/AssetsV2/com_apple_MobileAsset_Font*/
313
316
  # Generate unique asset identifier from formula key
@@ -0,0 +1,55 @@
1
+ require "fileutils"
2
+
3
+ module Fontist
4
+ module FontOperations
5
+ # Converts a font file to another format via Fontisan.
6
+ module Convert
7
+ TARGETS = %w[ttf otf woff woff2].freeze
8
+ MAGIC = {
9
+ "ttf" => ["\x00\x01\x00\x00".b, "true".b],
10
+ "otf" => ["OTTO".b],
11
+ "woff" => ["wOFF".b],
12
+ "woff2" => ["wOF2".b],
13
+ }.freeze
14
+
15
+ module_function
16
+
17
+ # @param source [String] font file
18
+ # @param target_format [String] one of TARGETS
19
+ # @param output [String] where to write the converted font
20
+ # @return [String] output path (verified to carry the target's magic)
21
+ def call(source, target_format, output)
22
+ target = target_format.to_s.downcase
23
+ FontOperations.guard("Converting to #{target}") do
24
+ check!(source, target)
25
+ run(source, target, output)
26
+ verify!(output, target)
27
+ output
28
+ end
29
+ end
30
+
31
+ def check!(source, target)
32
+ unless TARGETS.include?(target)
33
+ raise Errors::UnsupportedTranscodeError.new(
34
+ File.extname(source).delete("."), target
35
+ )
36
+ end
37
+ raise Errors::SourceNotFoundError, source unless File.exist?(source)
38
+ end
39
+
40
+ def run(source, target, output)
41
+ FileUtils.mkdir_p(File.dirname(output))
42
+ Fontisan::Commands::ConvertCommand.new(
43
+ source, to: target, output: output, quiet: true
44
+ ).run
45
+ end
46
+
47
+ def verify!(path, target)
48
+ head = File.file?(path) ? File.binread(path, 4) : nil
49
+ return if MAGIC.fetch(target).include?(head)
50
+
51
+ raise Error, "Fontisan did not produce a #{target} file at #{path}"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,53 @@
1
+ require "fileutils"
2
+
3
+ module Fontist
4
+ module FontOperations
5
+ # Extracts one member of a TTC/OTC collection as a standalone font.
6
+ module Extract
7
+ module_function
8
+
9
+ # @param collection_path [String] TTC/OTC file
10
+ # @param index [Integer] zero-based member index
11
+ # @param output_dir [String] directory to write into
12
+ # @return [String] path of the standalone font
13
+ def call(collection_path, index:, output_dir:)
14
+ FontOperations.guard("Extracting member #{index}") do
15
+ check_index!(collection_path, index)
16
+ unpack(collection_path, index, output_dir)
17
+ end
18
+ end
19
+
20
+ def unpack(collection_path, index, output_dir)
21
+ Fontisan::Commands::UnpackCommand.new(
22
+ collection_path, output_dir: output_dir, font_index: index
23
+ ).run[:extracted_files].first
24
+ end
25
+
26
+ def check_index!(path, index)
27
+ raise Errors::SourceNotFoundError, path unless File.exist?(path)
28
+
29
+ count = member_count(path)
30
+ return if index.is_a?(Integer) && index >= 0 && index < count
31
+
32
+ raise Errors::CollectionIndexError.new(index, count - 1)
33
+ end
34
+
35
+ def collection?(path)
36
+ File.open(path, "rb") { |io| io.read(4) } == "ttcf"
37
+ rescue SystemCallError
38
+ false
39
+ end
40
+
41
+ def member_count(path)
42
+ unless collection?(path)
43
+ raise Error, "#{path} is not a TTC/OTC collection"
44
+ end
45
+
46
+ File.open(path, "rb") do |io|
47
+ io.seek(8)
48
+ io.read(4).unpack1("N")
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,92 @@
1
+ require "fileutils"
2
+
3
+ module Fontist
4
+ module FontOperations
5
+ # Writes a static instance of a variable font at the given axis values,
6
+ # validated against the font's own fvar axes.
7
+ module Instance
8
+ PAIR = /\A([A-Za-z][A-Za-z0-9 ]{2,3})=(-?\d+(\.\d+)?)\z/
9
+
10
+ module_function
11
+
12
+ # @param source [String] variable font file
13
+ # @param output [String] where to write the static instance
14
+ # @param coordinates [Hash{String=>Numeric}, String] {"wght" => 300}
15
+ # or "wght=300,wdth=90"
16
+ # @return [String] output path
17
+ def call(source, output, coordinates:)
18
+ FontOperations.guard("Instancing") do
19
+ raise Errors::SourceNotFoundError, source unless File.exist?(source)
20
+
21
+ coords = coordinates.is_a?(String) ? parse(coordinates) : coordinates
22
+ raise Errors::InstanceError, "No axis values given" if coords.empty?
23
+
24
+ write(source, output, normalize(coords))
25
+ end
26
+ end
27
+
28
+ # "wght=300,wdth=90" => {"wght" => 300.0, "wdth" => 90.0}
29
+ def parse(value)
30
+ value.to_s.split(",").map(&:strip).reject(&:empty?).to_h do |pair|
31
+ match = PAIR.match(pair)
32
+ unless match
33
+ raise Errors::InstanceError,
34
+ "Invalid instance '#{pair}'. Use e.g. wght=300,wdth=90"
35
+ end
36
+
37
+ [match[1], match[2].to_f]
38
+ end
39
+ end
40
+
41
+ # File name for the instance: "Font[wght].ttf" => "Font-wght300.ttf"
42
+ def file_name(name, coordinates)
43
+ ext = File.extname(name)
44
+ base = File.basename(name, ext).sub(/\[[^\]]*\]\z/, "")
45
+ suffix = coordinates.map { |tag, val| "#{tag}#{number(val)}" }.join("-")
46
+ "#{base}-#{suffix}#{ext}"
47
+ end
48
+
49
+ def normalize(coords)
50
+ coords.to_h { |tag, value| [tag.to_s, Float(value)] }
51
+ rescue ArgumentError, TypeError
52
+ raise Errors::InstanceError, "Axis values must be numbers"
53
+ end
54
+
55
+ def write(source, output, coords)
56
+ font = Fontisan::FontLoader.load(source)
57
+ unless font.has_table?("fvar")
58
+ raise Errors::InstanceError,
59
+ "#{File.basename(source)} is not a variable font"
60
+ end
61
+
62
+ instancer = Fontisan::Variable::Instancer.new(font)
63
+ validate!(instancer, coords, File.basename(source))
64
+ FileUtils.mkdir_p(File.dirname(output))
65
+ File.binwrite(output, instancer.instance(coords))
66
+ output
67
+ end
68
+
69
+ def validate!(instancer, coords, name)
70
+ axes = instancer.axes.to_h { |axis| [axis.tag, axis] }
71
+ coords.each do |tag, value|
72
+ axis = axes[tag] || raise(Errors::InstanceError,
73
+ "#{name} has no axis '#{tag}' " \
74
+ "(axes: #{axes.keys.join(', ')})")
75
+ check_range!(axis, value, name)
76
+ end
77
+ end
78
+
79
+ def check_range!(axis, value, name)
80
+ return if value.between?(axis.minimum, axis.maximum)
81
+
82
+ raise Errors::InstanceError,
83
+ "#{axis.tag}=#{number(value)} is out of range for #{name}: " \
84
+ "#{number(axis.minimum)}-#{number(axis.maximum)}"
85
+ end
86
+
87
+ def number(value)
88
+ format("%g", value)
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,49 @@
1
+ require "fileutils"
2
+
3
+ module Fontist
4
+ module FontOperations
5
+ # Bundles several font files into one TTC/OTC (or dfont) collection.
6
+ module Pack
7
+ FORMATS = %w[ttc otc dfont].freeze
8
+
9
+ module_function
10
+
11
+ # @param output [String] collection path; extension picks the format
12
+ # unless +format+ is given
13
+ # @param files [Array<String>] two or more font files
14
+ # @return [String] the written collection path
15
+ def call(output, files, format: nil)
16
+ FontOperations.guard("Packing collection") do
17
+ format = (format || File.extname(output).delete(".")).to_s.downcase
18
+ validate!(files, format)
19
+ write(output, files, format)
20
+ end
21
+ end
22
+
23
+ def validate!(files, format)
24
+ unless FORMATS.include?(format)
25
+ raise Error, "Unsupported collection format '#{format}'. " \
26
+ "Use one of: #{FORMATS.join(', ')}"
27
+ end
28
+ if files.size < 2
29
+ raise Error, "A collection needs at least two font files"
30
+ end
31
+
32
+ missing = files.reject { |f| File.exist?(f) }
33
+ raise Errors::SourceNotFoundError, missing.first if missing.any?
34
+ end
35
+
36
+ def write(output, files, format)
37
+ FileUtils.mkdir_p(File.dirname(output))
38
+ Fontisan::Commands::PackCommand.new(
39
+ files, output: output, format: format
40
+ ).run
41
+ unless File.size?(output)
42
+ raise Error, "No collection was written to #{output}"
43
+ end
44
+
45
+ output
46
+ end
47
+ end
48
+ end
49
+ end