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.
- checksums.yaml +4 -4
- data/README.adoc +148 -0
- data/fontist.gemspec +1 -1
- data/lib/fontist/cli.rb +105 -5
- 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 +17 -0
- data/lib/fontist/formula_picker.rb +27 -8
- 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 +3 -0
- data/lib/fontist/import/google/variable_font_source.rb +105 -0
- data/lib/fontist/import/google.rb +1 -0
- data/lib/fontist/import/sil_importer.rb +18 -5
- data/lib/fontist/import/windows.rb +13 -2
- data/lib/fontist/import_cli.rb +19 -0
- data/lib/fontist/manifest.rb +63 -0
- data/lib/fontist/version.rb +1 -1
- data/lib/fontist.rb +2 -0
- metadata +14 -3
|
@@ -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
|
data/lib/fontist/format_spec.rb
CHANGED
|
@@ -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?
|
data/lib/fontist/formula.rb
CHANGED
|
@@ -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?
|
|
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
|
-
|
|
52
|
-
|
|
58
|
+
[]
|
|
59
|
+
end
|
|
53
60
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
177
|
-
#
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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(
|
|
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.
|
|
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
|
|
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 = "#{
|
|
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
|
data/lib/fontist/import_cli.rb
CHANGED
|
@@ -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]
|