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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +148 -0
  3. data/fontist.gemspec +4 -3
  4. data/lib/fontist/cache/store.rb +13 -6
  5. data/lib/fontist/cli.rb +106 -6
  6. data/lib/fontist/css/google_family.rb +50 -0
  7. data/lib/fontist/css/local.rb +157 -0
  8. data/lib/fontist/css/styles.rb +79 -0
  9. data/lib/fontist/css.rb +110 -0
  10. data/lib/fontist/errors.rb +3 -0
  11. data/lib/fontist/font_file.rb +2 -1
  12. data/lib/fontist/font_finder.rb +15 -2
  13. data/lib/fontist/font_installer.rb +97 -94
  14. data/lib/fontist/font_operations/convert.rb +55 -0
  15. data/lib/fontist/font_operations/extract.rb +53 -0
  16. data/lib/fontist/font_operations/instance.rb +92 -0
  17. data/lib/fontist/font_operations/pack.rb +49 -0
  18. data/lib/fontist/font_operations/subset.rb +68 -0
  19. data/lib/fontist/font_operations.rb +25 -0
  20. data/lib/fontist/format_spec.rb +35 -0
  21. data/lib/fontist/formula.rb +33 -5
  22. data/lib/fontist/formula_picker.rb +27 -8
  23. data/lib/fontist/from_file_cache.rb +4 -0
  24. data/lib/fontist/helpers.rb +1 -1
  25. data/lib/fontist/import/convert_formulas.rb +1 -1
  26. data/lib/fontist/import/google/data_sources/base.rb +3 -3
  27. data/lib/fontist/import/google/data_sources/github.rb +28 -0
  28. data/lib/fontist/import/google/formula_builders/formula_builder_v5.rb +30 -57
  29. data/lib/fontist/import/google/models/font_family.rb +7 -4
  30. data/lib/fontist/import/google/variable_font_source.rb +105 -0
  31. data/lib/fontist/import/google.rb +1 -0
  32. data/lib/fontist/import/helpers/hash_helper.rb +1 -1
  33. data/lib/fontist/import/sil_importer.rb +18 -5
  34. data/lib/fontist/import/v4_to_v5_migrator.rb +1 -1
  35. data/lib/fontist/import/windows.rb +13 -2
  36. data/lib/fontist/import_cli.rb +19 -0
  37. data/lib/fontist/index_cli.rb +1 -1
  38. data/lib/fontist/indexes/index_mixin.rb +4 -4
  39. data/lib/fontist/json.rb +76 -0
  40. data/lib/fontist/manifest.rb +63 -0
  41. data/lib/fontist/resource.rb +6 -1
  42. data/lib/fontist/validate_cli.rb +1 -1
  43. data/lib/fontist/version.rb +1 -1
  44. data/lib/fontist/yaml_backend.rb +18 -5
  45. data/lib/fontist.rb +3 -0
  46. metadata +17 -19
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cdbd031efa6ca583a610246930ceddc3cc365f3b6047d49ccc9287efee8223ec
4
- data.tar.gz: a5bc28594e55dc5e7a2050293338d9f19597edc44801dfe3bb5c98b9adb900ea
3
+ metadata.gz: d858c57f16d09454000112d1047f120b1a752a76331e3788f65cb441fe7a668c
4
+ data.tar.gz: 60c5f0e33198e6b7b8ad24db123280a5fff7c4eaf8b56581d3ab8c9108114b44
5
5
  SHA512:
6
- metadata.gz: 2d7ec1b13570f62ebe344a5b0e11d18b13e4160ed1e76f4ccbdf93fe3f5d40d376b4af457ee5e5e3f658afbe6c9df9a144a0f011f98cbc2eb39163c15f77f014
7
- data.tar.gz: 8ebaacfb87497c01fbc41c9074e4febbca0f788f7d81ab6c1d409ae0b7123cdc9d78dbd24501fb03cfa824dcc4932cb10d64f203039185582456a0a466e00410
6
+ metadata.gz: 581ebfd99f20a966cea6574294b4a3de879cdcea6051f916645e84ac9bd5e9ee16d4d0bfcbbd82f0291b6d6ae3e72dcf1c5524d02f6e1d67d3fbcfd880b03d73
7
+ data.tar.gz: 7188a89be063579e76ffd351c5ddd0c7296836a4c6162c1974ca54f79ca5cddd6134a71b3d48d04455fbc1ac8debbe1e6f2922d414a16aea2e85c83a62742fc2
data/README.adoc CHANGED
@@ -311,11 +311,92 @@ Supported options:
311
311
  `-S, [--size-limit=N]`:: Specify upper limit for file size of a formula to be installed
312
312
  (default is 300 MB)
313
313
  `-u, [--update-fontconfig]`:: Update Fontconfig
314
+ `--collection-index=N`, `--format=FORMAT`, `--fallback-to-original`,
315
+ `--subset-unicodes=LIST`, `--subset-text=TEXT`:: See <<Font operations>>
314
316
 
315
317
 
316
318
  NOTE: The `install` command is similar to the `Font.install` library call.
317
319
 
318
320
 
321
+ === Font operations
322
+
323
+ Fontist can transform fonts with the https://github.com/fontist/fontisan[Fontisan]
324
+ gem while installing, and bundle fonts into collections. Without any of these
325
+ options, `install` copies fonts unchanged.
326
+
327
+ ==== Install one member of a TTC/OTC collection
328
+
329
+ [source,sh]
330
+ ----
331
+ fontist install "Noto Sans Oriya" --collection-index 1
332
+ ----
333
+
334
+ Installs member 1 (zero-based) as a standalone font. An index outside the
335
+ collection fails with `Collection index N out of range. Valid range: 0-M`.
336
+
337
+ ==== Convert formats
338
+
339
+ [source,sh]
340
+ ----
341
+ fontist install "Lato" --format woff2
342
+ ----
343
+
344
+ Targets: `ttf`, `otf`, `woff`, `woff2`. If Fontisan cannot convert the font,
345
+ the install fails with an error. Pass `--fallback-to-original` to install the
346
+ original format instead.
347
+
348
+ ==== Subset
349
+
350
+ [source,sh]
351
+ ----
352
+ fontist install "Lato" --subset-unicodes "U+0020-007E,U+00E9"
353
+ fontist install "Lato" --subset-text "Hello"
354
+ ----
355
+
356
+ Keeps only the glyphs for those characters. Options can be combined with
357
+ `--format`.
358
+
359
+ ==== Bundle a collection
360
+
361
+ [source,sh]
362
+ ----
363
+ fontist pack family.ttc Regular.ttf Bold.ttf
364
+ ----
365
+
366
+ ==== Ruby API
367
+
368
+ [source,ruby]
369
+ ----
370
+ Fontist::FontOperations::Extract.call("family.ttc", index: 1, output_dir: "out")
371
+ Fontist::FontOperations::Pack.call("family.ttc", ["a.ttf", "b.ttf"])
372
+ Fontist::FontOperations::Convert.call("a.ttf", "woff2", "a.woff2")
373
+ Fontist::FontOperations::Subset.call("a.ttf", "sub.ttf", unicodes: "U+0041-005A")
374
+
375
+ Fontist::Font.install("Lato", format: "woff2", subset_text: "Hello")
376
+ ----
377
+
378
+ ==== Variable-font instancing
379
+
380
+ `--instance` installs a static instance of a variable font at the given axis
381
+ values. Tags and ranges are checked against the font's `fvar` table; an
382
+ unknown axis, an out-of-range value or a non-variable font is an error.
383
+
384
+ [source,sh]
385
+ ----
386
+ fontist install "Mona Sans" --instance wght=300
387
+ fontist install "Mona Sans" --instance wght=300,wdth=90
388
+ ----
389
+
390
+ [source,ruby]
391
+ ----
392
+ Fontist::FontOperations::Instance.call("Font[wght].ttf", "light.ttf",
393
+ coordinates: { "wght" => 300 })
394
+ Fontist::Font.install("Mona Sans", instance: "wght=300")
395
+ ----
396
+
397
+ NOTE: Instancing needs a Fontisan release that includes static instancing
398
+ (fontisan commit `b06fab3` or later; not in 0.4.46).
399
+
319
400
  === Install locations
320
401
 
321
402
  ==== General
@@ -1083,6 +1164,73 @@ Fontist::Fontconfig.remove(force: true) # do not fail if no config exists
1083
1164
  ----
1084
1165
 
1085
1166
 
1167
+ == Web fonts
1168
+
1169
+ === Google Fonts CSS URL: `fontist css`
1170
+
1171
+ Prints the Google Fonts CSS2 URL for a Google family. It reads the formula
1172
+ only: no network access and no font installation.
1173
+
1174
+ [source,sh]
1175
+ ----
1176
+ $ fontist css "Source Sans 3" --styles 300,300i,600,600i
1177
+ https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,300;0,600;1,300;1,600&display=swap
1178
+
1179
+ $ fontist css "Space Mono" --styles 400,700 --format link
1180
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&amp;display=swap">
1181
+ ----
1182
+
1183
+ Options:
1184
+
1185
+ `--styles`:: Comma-separated weights from 100 to 900; add `i` for italic
1186
+ (`300,300i,600`). Weights are checked against the styles the formula lists
1187
+ (for example Thin, ExtraLight, Light, Regular, Medium, SemiBold, Bold,
1188
+ ExtraBold, Black). Without `--styles` the URL asks for the provider default.
1189
+ `--display`:: `auto`, `block`, `swap` (default), `fallback` or `optional`.
1190
+ `--format`:: `url` (default) or `link` (an HTML `<link>` tag with `&` escaped
1191
+ as `&amp;`).
1192
+
1193
+ Only the URL (or tag) goes to stdout. Errors go to stderr with a nonzero
1194
+ exit status, for an unknown family, a family that is not served by Google
1195
+ Fonts, an unlisted style or an invalid `--display`.
1196
+
1197
+ The family name sent to Google is the formula's Google resource `family`; it
1198
+ is never derived from the formula name. So `Source Sans Pro` is not renamed to
1199
+ `Source Sans 3`: use the name the formula lists.
1200
+
1201
+ From Ruby:
1202
+
1203
+ [source,ruby]
1204
+ ----
1205
+ Fontist::Css.url("Source Sans 3", styles: %w[300 300i 600 600i])
1206
+ Fontist::Css.link("Space Mono", styles: "400,700", display: "swap")
1207
+ ----
1208
+
1209
+ === Self-hosted CSS: `fontist css --local`
1210
+
1211
+ Builds a CSS bundle from fonts already installed by fontist: one file per
1212
+ style is copied into `--output-dir`, and `fonts.css` gets one `@font-face` per
1213
+ style (`font-family`, `src` with `format()`, `font-weight`, `font-style`).
1214
+
1215
+ [source,sh]
1216
+ ----
1217
+ $ fontist install "Space Mono"
1218
+ $ fontist css "Space Mono" --local --output-dir public/fonts \
1219
+ --url-prefix /fonts --styles 400,700
1220
+ public/fonts/fonts.css
1221
+ ----
1222
+
1223
+ `--url-prefix`:: Prefix for the font URLs in `fonts.css` (default: relative
1224
+ file names).
1225
+ `--font-format`:: `woff2`, `woff`, `ttf` or `otf`. Selects an installed file of
1226
+ that format; the file must already be installed (for example with
1227
+ `fontist install "Space Mono" --format woff2`). There is no silent fallback
1228
+ to another format.
1229
+
1230
+ Limits: only fonts installed in the fontist fonts directory are used, and no
1231
+ Google unicode-range subset files are produced. If the font is not installed
1232
+ the command tells you to run `fontist install`.
1233
+
1086
1234
  == Platform-specific features
1087
1235
 
1088
1236
  === macOS supplementary fonts
data/fontist.gemspec CHANGED
@@ -8,8 +8,10 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["Ribose Inc."]
9
9
  spec.email = ["open.source@ribose.com"]
10
10
 
11
+ # rubocop:disable Layout/LineLength
11
12
  spec.summary = "Install openly-licensed fonts on Windows, Linux and Mac!"
12
13
  spec.description = "Install openly-licensed fonts on Windows, Linux and Mac!"
14
+ # rubocop:enable Layout/LineLength
13
15
  spec.homepage = "https://github.com/fontist/fontist"
14
16
  spec.license = "BSD-2-Clause"
15
17
 
@@ -31,14 +33,13 @@ Gem::Specification.new do |spec|
31
33
 
32
34
  spec.add_dependency "down", "~> 5.0"
33
35
  spec.add_dependency "excavate", "~> 1.0", ">= 1.0.3"
34
- spec.add_dependency "fontisan", "~> 0.4", ">= 0.4.9"
36
+ spec.add_dependency "fontisan", "~> 0.4", ">= 0.4.47"
35
37
  spec.add_dependency "fuzzy_match", "~> 2.1"
36
38
  spec.add_dependency "git", "~> 6.0"
37
- spec.add_dependency "json", "~> 2.0"
38
39
  spec.add_dependency "lutaml-model", "~> 0.8.0"
39
40
  spec.add_dependency "marcel", "~> 1.0"
40
41
  spec.add_dependency "nokogiri", "~> 1.0"
41
- spec.add_dependency "octokit", "~> 4.0"
42
+ spec.add_dependency "octokit", "~> 10.0"
42
43
  spec.add_dependency "paint", "~> 2.3"
43
44
  spec.add_dependency "parallel", "~> 1.24"
44
45
  spec.add_dependency "plist", "~> 3.0"
@@ -40,7 +40,7 @@ module Fontist
40
40
  ensure_cache_dir
41
41
  entry = CacheEntry.new(value, ttl)
42
42
  write_entry(key, entry)
43
- rescue TypeError, JSON::GeneratorError
43
+ rescue TypeError, Fontist::JSON::DumpError
44
44
  # Values outside the JSON type system (Time, Date, arbitrary
45
45
  # objects) skip the write and read back as a miss
46
46
  false
@@ -113,9 +113,9 @@ module Fontist
113
113
  def read_entry(key)
114
114
  return nil unless File.exist?(cache_path(key))
115
115
 
116
- payload = JSON.parse(File.read(cache_path(key)))
116
+ payload = Fontist::JSON.parse(File.read(cache_path(key)))
117
117
  CacheEntry.new(payload["value"], expires_at: payload["expires_at"])
118
- rescue JSON::ParserError, TypeError, EOFError
118
+ rescue Fontist::JSON::ParseError, TypeError, EOFError
119
119
  # Corrupted file - delete it and return nil. This can happen
120
120
  # on Windows when a file is read while being written, or when
121
121
  # cache files from previous runs are corrupted
@@ -137,9 +137,16 @@ module Fontist
137
137
  end
138
138
 
139
139
  def entry_payload(entry)
140
- JSON.dump("version" => FORMAT_VERSION,
141
- "expires_at" => entry.expires_at,
142
- "value" => entry.value)
140
+ Fontist::JSON.dump("version" => FORMAT_VERSION,
141
+ "expires_at" => entry.expires_at,
142
+ "value" => entry.value)
143
+ rescue Fontist::JSON::DumpError
144
+ # Values outside the JSON type system (Time, Date, arbitrary
145
+ # objects) degrade to their string form, keeping the cache a
146
+ # best-effort store instead of a source of crashes
147
+ Fontist::JSON.dump("version" => FORMAT_VERSION,
148
+ "expires_at" => entry.expires_at,
149
+ "value" => entry.value.to_s)
143
150
  end
144
151
  end
145
152
  end
data/lib/fontist/cli.rb CHANGED
@@ -29,6 +29,10 @@ module Fontist
29
29
  ERROR_TO_STATUS = {
30
30
  Fontist::Errors::UnsupportedFontError => [STATUS_NON_SUPPORTED_FONT_ERROR],
31
31
  Fontist::Errors::MissingFontError => [STATUS_MISSING_FONT_ERROR],
32
+ Fontist::FontOperations::Error => [STATUS_UNKNOWN_ERROR],
33
+ Fontist::Errors::CollectionIndexError => [STATUS_UNKNOWN_ERROR],
34
+ Fontist::Errors::UnsupportedTranscodeError => [STATUS_UNKNOWN_ERROR],
35
+ Fontist::Errors::SourceNotFoundError => [STATUS_UNKNOWN_ERROR],
32
36
  Fontist::Errors::SizeLimitError => [
33
37
  STATUS_SIZE_LIMIT_ERROR,
34
38
  :append,
@@ -119,8 +123,12 @@ module Fontist
119
123
  # Format selection options
120
124
  option :format,
121
125
  type: :string,
122
- desc: "Font format to install (ttf, otf, woff, woff2, ttc, otc). " \
123
- "If format not available, will transcode from available formats."
126
+ desc: "Font format to install (ttf, otf, woff, woff2). " \
127
+ "If format not available, will convert via Fontisan " \
128
+ "(fails with an error unless --fallback-to-original)."
129
+ option :fallback_to_original,
130
+ type: :boolean,
131
+ desc: "If conversion fails, install the original format instead"
124
132
  option :variable_axes,
125
133
  type: :string,
126
134
  desc: "Variable axes to match (comma-separated, e.g., 'wght,wdth')"
@@ -140,7 +148,20 @@ module Fontist
140
148
  # Collection options
141
149
  option :collection_index,
142
150
  type: :numeric,
143
- desc: "Extract specific font from TTC/OTC collection (0-indexed)"
151
+ desc: "Install only this member of a TTC/OTC collection " \
152
+ "as a standalone font (0-indexed)"
153
+ # Subsetting options
154
+ option :subset_unicodes,
155
+ type: :string,
156
+ desc: "Keep only these codepoints (e.g., 'U+0020-007E,U+00E9')"
157
+ option :subset_text,
158
+ type: :string,
159
+ desc: "Keep only the glyphs for these characters"
160
+ # Variable-font instancing
161
+ option :instance,
162
+ type: :string,
163
+ desc: "Install a static instance of a variable font " \
164
+ "(e.g., 'wght=300,wdth=90')"
144
165
  def install(*fonts)
145
166
  handle_class_options(options)
146
167
 
@@ -205,6 +226,18 @@ module Fontist
205
226
  handle_error(e)
206
227
  end
207
228
 
229
+ desc "pack OUTPUT FONT_FILE...",
230
+ "Bundle two or more font files into a TTC/OTC collection"
231
+ def pack(output, *files)
232
+ handle_class_options(options)
233
+
234
+ Fontist::FontOperations::Pack.call(output, files)
235
+ Fontist.ui.success("Collection written to #{output}")
236
+ STATUS_SUCCESS
237
+ rescue Fontist::Errors::GeneralError => e
238
+ handle_error(e)
239
+ end
240
+
208
241
  desc "uninstall/remove FONT", "Uninstall font by font or formula"
209
242
  def uninstall(font)
210
243
  handle_class_options(options)
@@ -270,14 +303,16 @@ module Fontist
270
303
  finder.by_axes(axes)
271
304
  elsif options[:category]
272
305
  finder.by_category(options[:category])
306
+ elsif options[:format]
307
+ finder.by_format(options[:format])
273
308
  else
274
- error("Please specify --axes, --variable, or --category",
275
- STATUS_UNKNOWN_ERROR)
309
+ error("Please specify --axes, --variable, --category " \
310
+ "or --format", STATUS_UNKNOWN_ERROR)
276
311
  return
277
312
  end
278
313
 
279
314
  if options[:json]
280
- Fontist.ui.say(JSON.pretty_generate(results.map(&:to_h)))
315
+ Fontist.ui.say(Fontist::JSON.pretty(results.map(&:to_h)))
281
316
  else
282
317
  print_find_results(results)
283
318
  end
@@ -287,6 +322,39 @@ module Fontist
287
322
  handle_error(e)
288
323
  end
289
324
 
325
+ desc "css FAMILY", "Print a Google Fonts CSS URL, or build local CSS"
326
+ long_desc <<-LONGDESC
327
+ Prints the Google Fonts CSS2 URL for FAMILY (no network, no install).
328
+ With --local, copies installed font files into --output-dir and writes
329
+ fonts.css there.
330
+ LONGDESC
331
+ option :styles, type: :string,
332
+ desc: "Comma-separated weights, 'i' for italic " \
333
+ "(e.g., 300,300i,600,600i)"
334
+ option :display, type: :string, default: "swap",
335
+ desc: "font-display: auto, block, swap, fallback, optional"
336
+ option :format, type: :string, default: "url",
337
+ desc: "Output: url or link (HTML <link> tag)"
338
+ option :local, type: :boolean, desc: "Build CSS from installed fonts"
339
+ option :output_dir, type: :string, desc: "Directory for --local output"
340
+ option :url_prefix, type: :string,
341
+ desc: "URL prefix of font files in --local fonts.css"
342
+ option :font_format, type: :string,
343
+ desc: "Font file format for --local (woff2, woff, " \
344
+ "ttf, otf)"
345
+ def css(family)
346
+ handle_class_options(options)
347
+ return css_local(family) if options[:local]
348
+
349
+ css_google(family)
350
+ rescue Fontist::Css::UnknownFamilyError
351
+ css_error($!, STATUS_NON_SUPPORTED_FONT_ERROR)
352
+ rescue Fontist::Errors::MissingFontError
353
+ css_error($!, STATUS_MISSING_FONT_ERROR)
354
+ rescue Fontist::Css::Error
355
+ css_error($!, STATUS_UNKNOWN_ERROR)
356
+ end
357
+
290
358
  desc "update", "Update formulas"
291
359
  def update
292
360
  handle_class_options(options)
@@ -414,6 +482,38 @@ module Fontist
414
482
  STATUS_SUCCESS
415
483
  end
416
484
 
485
+ def css_google(family)
486
+ kind = options[:format]
487
+ unless %w[url link].include?(kind)
488
+ raise Fontist::Css::InvalidOptionError,
489
+ "Invalid format '#{kind}'. Use url or link."
490
+ end
491
+
492
+ $stdout.puts Fontist::Css.public_send(
493
+ kind, family, styles: options[:styles], display: options[:display]
494
+ )
495
+ success
496
+ end
497
+
498
+ def css_local(family)
499
+ unless options[:output_dir]
500
+ raise Fontist::Css::InvalidOptionError, "--local requires --output-dir."
501
+ end
502
+
503
+ path = Fontist::Css.local(family, output_dir: options[:output_dir],
504
+ url_prefix: options[:url_prefix],
505
+ styles: options[:styles],
506
+ font_format: options[:font_format])
507
+ $stdout.puts path
508
+ success
509
+ end
510
+
511
+ # Errors go to stderr so stdout carries only the result.
512
+ def css_error(exception, status)
513
+ warn exception.message
514
+ status
515
+ end
516
+
417
517
  def handle_error(exception)
418
518
  status, mode, message = ERROR_TO_STATUS[exception.class]
419
519
  raise exception unless status
@@ -0,0 +1,50 @@
1
+ module Fontist
2
+ module Css
3
+ # Finds the Google formula for a family name and its canonical
4
+ # Google Fonts family (Resource#family; never derived from a slug).
5
+ module GoogleFamily
6
+ class << self
7
+ # @return [Array(Formula, String)] formula and canonical family
8
+ def resolve(family)
9
+ name = family.to_s.strip
10
+ matches = google_matches(candidates(name), name)
11
+ raise NotGoogleFamilyError, name if matches.empty?
12
+
13
+ keys = matches.map { |formula, _| formula.key }
14
+ raise AmbiguousFamilyError.new(name, keys) if keys.uniq.size > 1
15
+
16
+ matches.first
17
+ end
18
+
19
+ private
20
+
21
+ def candidates(name)
22
+ formulas = name.empty? ? [] : Formula.find_many(name)
23
+ raise UnknownFamilyError, name if formulas.empty?
24
+
25
+ formulas
26
+ end
27
+
28
+ def google_matches(formulas, name)
29
+ formulas.filter_map do |formula|
30
+ canonical = canonical_family(formula, name)
31
+ [formula, canonical] if canonical
32
+ end
33
+ end
34
+
35
+ def canonical_family(formula, name)
36
+ wanted = normalize(name)
37
+ Array(formula.resources).each do |resource|
38
+ next unless resource.source == "google"
39
+ return resource.family if normalize(resource.family) == wanted
40
+ end
41
+ nil
42
+ end
43
+
44
+ def normalize(text)
45
+ text.to_s.downcase.gsub(/[\s_-]+/, " ")
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,157 @@
1
+ module Fontist
2
+ module Css
3
+ # Builds a self-hosted CSS bundle from fonts already installed by
4
+ # fontist: one copied file per style plus fonts.css with one @font-face
5
+ # per style. No downloads, installs or format conversion happen here.
6
+ class Local
7
+ CSS_FORMATS = {
8
+ "woff2" => "woff2",
9
+ "woff" => "woff",
10
+ "ttf" => "truetype",
11
+ "otf" => "opentype",
12
+ }.freeze
13
+
14
+ def initialize(family, output_dir:, url_prefix: nil, styles: nil,
15
+ font_format: nil)
16
+ @family = family.to_s.strip
17
+ @output_dir = output_dir.to_s
18
+ @url_prefix = url_prefix.to_s
19
+ @styles = styles
20
+ @font_format = font_format&.to_s&.downcase
21
+ validate_font_format!
22
+ end
23
+
24
+ # @return [String] path of the written fonts.css
25
+ def call
26
+ raise UnknownFamilyError, @family if @family.empty?
27
+
28
+ entries = installed_entries
29
+ FileUtils.mkdir_p(@output_dir)
30
+ entries.each { |e| write_font(e) }
31
+ css_path = File.join(@output_dir, "fonts.css")
32
+ File.write(css_path, entries.map { |e| font_face(e) }.join("\n"))
33
+ css_path
34
+ end
35
+
36
+ private
37
+
38
+ def validate_font_format!
39
+ return if @font_format.nil? || CSS_FORMATS.key?(@font_format)
40
+
41
+ raise InvalidOptionError,
42
+ "Unsupported font format '#{@font_format}'. " \
43
+ "Use one of: #{CSS_FORMATS.keys.join(', ')}."
44
+ end
45
+
46
+ def installed_entries
47
+ formulas = Formula.find_many(@family)
48
+ raise UnknownFamilyError, @family if formulas.empty?
49
+
50
+ faces = formulas.filter_map { |formula| selected_faces(formula) }
51
+ raise UnknownFamilyError, @family if faces.empty?
52
+
53
+ faces.each do |candidate|
54
+ entries = locate(candidate)
55
+ return entries unless entries.empty?
56
+ end
57
+ raise Errors::MissingFontError, @family
58
+ end
59
+
60
+ # Faces of this family in the formula, narrowed to --styles.
61
+ def selected_faces(formula)
62
+ faces = Styles.faces(formula).select { |f| family?(f.style) }
63
+ return if faces.empty?
64
+
65
+ requested = Styles.parse(@styles)
66
+ return faces if requested.empty?
67
+
68
+ Styles.verify(@family, faces, requested)
69
+ faces.select { |f| requested.include?(f.tuple) }
70
+ end
71
+
72
+ def family?(style)
73
+ name = style.preferred_family_name || style.family_name
74
+ name.to_s.casecmp?(@family)
75
+ end
76
+
77
+ def locate(faces)
78
+ files = installed_files
79
+ faces.filter_map { |face| entry_for(face, files) }
80
+ end
81
+
82
+ # The installed file in the requested format, or the installed original
83
+ # to convert into it.
84
+ def entry_for(face, files)
85
+ path = files[wanted_name(face.style.font)]
86
+ return { path: path, face: face, ext: extension(path) } if path
87
+ return unless @font_format
88
+
89
+ original = files[File.basename(face.style.font.to_s).downcase]
90
+ return unless original
91
+
92
+ { path: original, face: face, ext: @font_format, convert: true }
93
+ end
94
+
95
+ def extension(path)
96
+ File.extname(path).delete(".")
97
+ end
98
+
99
+ def write_font(entry)
100
+ if entry[:convert]
101
+ FontOperations::Convert.call(entry[:path], @font_format,
102
+ target(entry))
103
+ else
104
+ FileUtils.cp(entry[:path], target(entry))
105
+ end
106
+ end
107
+
108
+ # A file name keyed by lowercase basename, so extension swaps match.
109
+ def wanted_name(font)
110
+ stem = File.basename(font.to_s, ".*")
111
+ ext = @font_format || File.extname(font.to_s).delete(".")
112
+ "#{stem}.#{ext}".downcase
113
+ end
114
+
115
+ def installed_files
116
+ patterns = Utils.font_file_patterns(Fontist.fonts_path.join("**").to_s)
117
+ patterns.flat_map { |p| Dir.glob(p) }.sort
118
+ .to_h { |path| [File.basename(path).downcase, path] }
119
+ end
120
+
121
+ def target(entry)
122
+ name = "#{File.basename(entry[:path], '.*')}.#{entry[:ext]}"
123
+ File.join(@output_dir, name)
124
+ end
125
+
126
+ def font_face(entry)
127
+ name = File.basename(target(entry))
128
+ face = entry[:face]
129
+ <<~CSS
130
+ @font-face {
131
+ font-family: "#{escape(@family)}";
132
+ src: url("#{escape(url_for(name))}") format("#{css_format(entry)}");
133
+ font-weight: #{face.weight};
134
+ font-style: #{face.italic ? 'italic' : 'normal'};
135
+ }
136
+ CSS
137
+ end
138
+
139
+ def url_for(name)
140
+ encoded = CGI.escape(name).gsub("+", "%20")
141
+ prefix = @url_prefix.chomp("/")
142
+ prefix.empty? ? encoded : "#{prefix}/#{encoded}"
143
+ end
144
+
145
+ def css_format(entry)
146
+ CSS_FORMATS.fetch(entry[:ext].downcase) do
147
+ raise InvalidOptionError,
148
+ "Cannot describe font file format '#{entry[:ext]}' in CSS."
149
+ end
150
+ end
151
+
152
+ def escape(text)
153
+ text.to_s.gsub(/[\\"]/) { |c| "\\#{c}" }.gsub(/\r?\n/, "\\A ")
154
+ end
155
+ end
156
+ end
157
+ end