fontist 3.0.28 → 3.0.30
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.
- checksums.yaml +4 -4
- data/README.adoc +148 -0
- data/fontist.gemspec +4 -3
- data/lib/fontist/cache/store.rb +13 -6
- data/lib/fontist/cli.rb +106 -6
- data/lib/fontist/css/google_family.rb +50 -0
- data/lib/fontist/css/local.rb +157 -0
- data/lib/fontist/css/styles.rb +79 -0
- data/lib/fontist/css.rb +110 -0
- data/lib/fontist/errors.rb +3 -0
- data/lib/fontist/font_file.rb +2 -1
- data/lib/fontist/font_finder.rb +15 -2
- data/lib/fontist/font_installer.rb +97 -94
- data/lib/fontist/font_operations/convert.rb +55 -0
- data/lib/fontist/font_operations/extract.rb +53 -0
- data/lib/fontist/font_operations/instance.rb +92 -0
- data/lib/fontist/font_operations/pack.rb +49 -0
- data/lib/fontist/font_operations/subset.rb +68 -0
- data/lib/fontist/font_operations.rb +25 -0
- data/lib/fontist/format_spec.rb +35 -0
- data/lib/fontist/formula.rb +33 -5
- data/lib/fontist/formula_picker.rb +27 -8
- data/lib/fontist/from_file_cache.rb +4 -0
- data/lib/fontist/helpers.rb +1 -1
- data/lib/fontist/import/convert_formulas.rb +1 -1
- data/lib/fontist/import/google/data_sources/base.rb +3 -3
- data/lib/fontist/import/google/data_sources/github.rb +28 -0
- data/lib/fontist/import/google/formula_builders/formula_builder_v5.rb +30 -57
- data/lib/fontist/import/google/models/font_family.rb +7 -4
- data/lib/fontist/import/google/variable_font_source.rb +105 -0
- data/lib/fontist/import/google.rb +1 -0
- data/lib/fontist/import/helpers/hash_helper.rb +1 -1
- data/lib/fontist/import/sil_importer.rb +18 -5
- data/lib/fontist/import/v4_to_v5_migrator.rb +1 -1
- data/lib/fontist/import/windows.rb +13 -2
- data/lib/fontist/import_cli.rb +19 -0
- data/lib/fontist/index_cli.rb +1 -1
- data/lib/fontist/indexes/index_mixin.rb +4 -4
- data/lib/fontist/json.rb +76 -0
- data/lib/fontist/manifest.rb +63 -0
- data/lib/fontist/resource.rb +6 -1
- data/lib/fontist/validate_cli.rb +1 -1
- data/lib/fontist/version.rb +1 -1
- data/lib/fontist/yaml_backend.rb +18 -5
- data/lib/fontist.rb +3 -0
- metadata +17 -19
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module Fontist
|
|
2
|
+
module Css
|
|
3
|
+
# Style names as listed in formulas, mapped to CSS weights.
|
|
4
|
+
module Styles
|
|
5
|
+
# Fixed table: style name (case and separators ignored) => weight.
|
|
6
|
+
WEIGHTS = {
|
|
7
|
+
"thin" => 100, "hairline" => 100,
|
|
8
|
+
"extralight" => 200, "ultralight" => 200,
|
|
9
|
+
"light" => 300,
|
|
10
|
+
"regular" => 400, "normal" => 400, "book" => 400,
|
|
11
|
+
"medium" => 500,
|
|
12
|
+
"semibold" => 600, "demibold" => 600,
|
|
13
|
+
"bold" => 700,
|
|
14
|
+
"extrabold" => 800, "ultrabold" => 800,
|
|
15
|
+
"black" => 900, "heavy" => 900
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# One available style: weight 100..900, italic flag, formula style.
|
|
19
|
+
Face = Struct.new(:weight, :italic, :style, keyword_init: true) do
|
|
20
|
+
def tuple
|
|
21
|
+
[italic ? 1 : 0, weight]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def token
|
|
25
|
+
Styles.token(tuple)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# "300,600i" or ["300", "600i"] => sorted unique [[ital, wght], ...]
|
|
31
|
+
def parse(styles)
|
|
32
|
+
Array(styles).flat_map { |s| s.to_s.split(",") }
|
|
33
|
+
.map(&:strip).reject(&:empty?)
|
|
34
|
+
.map { |token| parse_token(token) }.uniq.sort
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def token(tuple)
|
|
38
|
+
"#{tuple[1]}#{'i' if tuple[0] == 1}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Faces a formula lists, sorted upright first, by weight.
|
|
42
|
+
def faces(formula)
|
|
43
|
+
formula.all_fonts.flat_map(&:styles).filter_map { |s| face_for(s) }
|
|
44
|
+
.uniq(&:tuple).sort_by(&:tuple)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Face for one formula style; nil when its name is not in the table.
|
|
48
|
+
def face_for(style)
|
|
49
|
+
name = (style.preferred_type || style.type).to_s
|
|
50
|
+
base = name.gsub(/italic|oblique/i, "").downcase.gsub(/[\s_-]+/, "")
|
|
51
|
+
weight = WEIGHTS[base.empty? ? "regular" : base]
|
|
52
|
+
return unless weight
|
|
53
|
+
|
|
54
|
+
Face.new(weight: weight, italic: name.match?(/italic|oblique/i),
|
|
55
|
+
style: style)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Raise unless every requested tuple is among the available faces.
|
|
59
|
+
def verify(family, faces, requested)
|
|
60
|
+
have = faces.map(&:tuple)
|
|
61
|
+
missing = requested.find { |tuple| !have.include?(tuple) }
|
|
62
|
+
return unless missing
|
|
63
|
+
|
|
64
|
+
raise UnavailableStyleError.new(family, token(missing),
|
|
65
|
+
faces.map(&:token))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def parse_token(token)
|
|
71
|
+
match = /\A([1-9]00)(i?)\z/.match(token)
|
|
72
|
+
raise InvalidStyleError, token unless match
|
|
73
|
+
|
|
74
|
+
[match[2] == "i" ? 1 : 0, match[1].to_i]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/fontist/css.rb
ADDED
|
@@ -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"
|
data/lib/fontist/errors.rb
CHANGED
data/lib/fontist/font_file.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
data/lib/fontist/font_finder.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|