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
@@ -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
@@ -0,0 +1,68 @@
1
+ require "fileutils"
2
+
3
+ module Fontist
4
+ module FontOperations
5
+ # Keeps only the glyphs for the given characters.
6
+ module Subset
7
+ UNICODE_PART = /\A(u\+)?\h+(-(u\+)?\h+)?\z/i
8
+
9
+ module_function
10
+
11
+ # @param source [String] font file
12
+ # @param output [String] where to write the subset font
13
+ # @param unicodes [String, Array<Integer>, nil] "U+0020-007E,U+00E9"
14
+ # or codepoints
15
+ # @param text [String, nil] characters to keep
16
+ # @return [String] output path
17
+ def call(source, output, unicodes: nil, text: nil)
18
+ FontOperations.guard("Subsetting") do
19
+ raise Errors::SourceNotFoundError, source unless File.exist?(source)
20
+
21
+ codepoints = parse_unicodes(unicodes) + text.to_s.codepoints
22
+ if codepoints.empty?
23
+ raise Error, "No characters to keep: give unicodes or text"
24
+ end
25
+
26
+ write(source, output, codepoints)
27
+ end
28
+ end
29
+
30
+ def write(source, output, codepoints)
31
+ font = Fontisan::FontLoader.load(source)
32
+ gids = glyph_ids(font, codepoints, File.basename(source))
33
+ options = Fontisan::Subset::Options.new(profile: "web")
34
+ binary = Fontisan::Subset::Builder.new(font, gids, options).build
35
+ FileUtils.mkdir_p(File.dirname(output))
36
+ File.binwrite(output, binary)
37
+ output
38
+ end
39
+
40
+ def glyph_ids(font, codepoints, name)
41
+ mappings = font.table("cmap").unicode_mappings
42
+ gids = codepoints.uniq.filter_map { |cp| mappings[cp] }.uniq.sort
43
+ return gids if gids.any?
44
+
45
+ raise Error, "None of the requested characters exist in #{name}"
46
+ end
47
+
48
+ # "U+0020-007E,U+00E9" => [32..126, 233].flatten
49
+ def parse_unicodes(value)
50
+ return Array(value).map(&:to_i) unless value.nil? || value.is_a?(String)
51
+
52
+ value.to_s.split(/[,\s]+/).reject(&:empty?).flat_map do |part|
53
+ parse_part(part)
54
+ end
55
+ end
56
+
57
+ def parse_part(part)
58
+ unless part.match?(UNICODE_PART)
59
+ raise Error, "Invalid unicode '#{part}'. Use e.g. U+0020-007E,U+00E9"
60
+ end
61
+
62
+ first, last = part.split("-", 2)
63
+ .map { |h| h.sub(/\Au\+/i, "").to_i(16) }
64
+ last ? (first..last).to_a : [first]
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,25 @@
1
+ module Fontist
2
+ # Font file operations backed by the Fontisan gem: collection member
3
+ # extraction, collection bundling, format conversion, subsetting and
4
+ # variable-font instancing.
5
+ module FontOperations
6
+ class Error < Errors::GeneralError; end
7
+
8
+ autoload :Convert, "#{__dir__}/font_operations/convert"
9
+ autoload :Extract, "#{__dir__}/font_operations/extract"
10
+ autoload :Instance, "#{__dir__}/font_operations/instance"
11
+ autoload :Pack, "#{__dir__}/font_operations/pack"
12
+ autoload :Subset, "#{__dir__}/font_operations/subset"
13
+
14
+ # Runs the block, turning any Fontisan failure into FontOperations::Error
15
+ # that names the operation.
16
+ def self.guard(operation)
17
+ require "fontisan"
18
+ yield
19
+ rescue Error, Errors::GeneralError
20
+ raise
21
+ rescue StandardError => e
22
+ raise Error, "#{operation} failed: #{e.message}"
23
+ end
24
+ end
25
+ end
@@ -30,6 +30,16 @@ module Fontist
30
30
  # Collection handling
31
31
  attribute :collection_index, :integer
32
32
 
33
+ # Subsetting: keep only these characters (U+0020-007E,U+00E9 / literal text)
34
+ attribute :subset_unicodes, :string
35
+ attribute :subset_text, :string
36
+
37
+ # Static instance of a variable font: "wght=300,wdth=90"
38
+ attribute :instance, :string
39
+
40
+ # Explicit opt-in: install the original format when conversion fails
41
+ attribute :fallback_to_original, :boolean, default: false
42
+
33
43
  # Convenience constructor for CLI
34
44
  def self.from_options(options = {})
35
45
  new(
@@ -40,9 +50,19 @@ module Fontist
40
50
  transcode_path: options[:transcode_path],
41
51
  keep_original: options.fetch(:keep_original, true),
42
52
  collection_index: options[:collection_index],
53
+ **operation_options(options),
43
54
  )
44
55
  end
45
56
 
57
+ def self.operation_options(options)
58
+ {
59
+ subset_unicodes: options[:subset_unicodes],
60
+ subset_text: options[:subset_text],
61
+ instance: options[:instance],
62
+ fallback_to_original: options[:fallback_to_original] || false,
63
+ }
64
+ end
65
+
46
66
  def self.parse_variable_axes(value)
47
67
  return nil if value.nil?
48
68
  return value if value.is_a?(Array)
@@ -72,6 +92,21 @@ module Fontist
72
92
  !available_formats.include?(format)
73
93
  end
74
94
 
95
+ # Check if a subset of characters was requested
96
+ def subset_requested?
97
+ !(subset_unicodes.to_s.strip.empty? && subset_text.to_s.empty?)
98
+ end
99
+
100
+ # Check if a static instance of a variable font was requested
101
+ def instance_requested?
102
+ !instance.to_s.strip.empty?
103
+ end
104
+
105
+ # Axis coordinates of the requested instance: {"wght" => 300.0}
106
+ def instance_coordinates
107
+ FontOperations::Instance.parse(instance)
108
+ end
109
+
75
110
  # Check if we need a specific collection index
76
111
  def specific_collection_index?
77
112
  !collection_index.nil?
@@ -106,10 +106,16 @@ module Fontist
106
106
  # Parsing thousands of formula files dominates index rebuilds;
107
107
  # worker threads overlap the libyaml C parse, which releases
108
108
  # the GVL. The pool is capped like every other fontist pool, so
109
- # worker counts never scale with host core counts
109
+ # worker counts never scale with host core counts.
110
+ #
111
+ # The parses bypass the shared parse cache: a corpus sweep never
112
+ # hits it (the LRU holds 64 entries against thousands of files),
113
+ # so caching would only evict entries that lookups re-use and
114
+ # pin dozens of parsed formulas in long-lived host processes
115
+ # after the sweep ends
110
116
  workers = [Parallel.processor_count, 8].min
111
117
  formulas = Parallel.map(paths, in_threads: workers) do |path|
112
- Formula.from_file(path)
118
+ Formula.from_file(path, cache: false)
113
119
  end.compact
114
120
 
115
121
  FormulaCollection.new(formulas)
@@ -181,7 +187,7 @@ module Fontist
181
187
  find_by_key(key)
182
188
  end
183
189
 
184
- def from_file(path)
190
+ def from_file(path, cache: true)
185
191
  unless File.exist?(path)
186
192
  raise Fontist::Errors::FormulaNotFoundError,
187
193
  "Formula file not found: #{path}"
@@ -192,8 +198,13 @@ module Fontist
192
198
  # Parsing a formula costs ~10-20ms and the same formula is
193
199
  # re-read on every lookup, so the parsed instance is memoized
194
200
  # (mtime-validated) and shared between callers; treat as
195
- # read-only
196
- formula = Fontist::FromFileCache.memoize(path) { from_yaml(content) }
201
+ # read-only. Callers that sweep the whole corpus pass
202
+ # cache: false, since their parses would only thrash the cache
203
+ formula = if cache
204
+ Fontist::FromFileCache.memoize(path) { from_yaml(content) }
205
+ else
206
+ from_yaml(content)
207
+ end
197
208
 
198
209
  formula.tap do |f|
199
210
  f.path = path
@@ -218,6 +229,23 @@ module Fontist
218
229
  schema_version == 5
219
230
  end
220
231
 
232
+ # Formats of the style files, as recorded or read from file extensions.
233
+ # Archive resources often carry no format of their own.
234
+ def style_formats
235
+ fonts.flat_map(&:styles).flat_map do |style|
236
+ extension = File.extname(style.font.to_s).delete(".").downcase
237
+ Array(style.formats) + [extension]
238
+ end.reject(&:empty?).uniq
239
+ end
240
+
241
+ # Whether a resource offers a format, falling back to the style files
242
+ # when the resource records none.
243
+ def resource_offers_format?(resource, format)
244
+ return resource.format == format if resource.format
245
+
246
+ style_formats.include?(format)
247
+ end
248
+
221
249
  # Get the effective schema version (default to 4 if not set)
222
250
  def effective_schema_version
223
251
  schema_version || 4
@@ -21,10 +21,9 @@ module Fontist
21
21
  list = filter(formulas)
22
22
  return [] if list.empty?
23
23
 
24
- # Use FormatMatcher for format filtering
25
24
  if @format_spec&.has_constraints?
26
25
  filtered = filter_by_format_spec(list)
27
- raise_format_not_available_error(list) if filtered.empty? && !list.empty?
26
+ raise_format_not_available_error(list) if filtered.empty?
28
27
 
29
28
  list = filtered
30
29
  end
@@ -45,17 +44,37 @@ module Fontist
45
44
  ensure_fontist_version(list)
46
45
  end
47
46
 
47
+ # Formulas offering the requested format come first. When none does, a
48
+ # plain format request falls back to every formula so the installer can
49
+ # convert; a variable-font request cannot be met by conversion.
48
50
  def filter_by_format_spec(formulas)
49
51
  matcher = FormatMatcher.new(@format_spec)
52
+ offering = formulas.filter_map do |formula|
53
+ narrow_to_offered_format(formula, matcher)
54
+ end
55
+ return offering if offering.any?
56
+ return formulas if convertible_request?
50
57
 
51
- formulas.filter_map do |formula|
52
- next formula unless formula.v5?
58
+ []
59
+ end
53
60
 
54
- matching = matcher.filter_resources(formula.resources)
55
- if matching.any?
56
- formula.dup.tap { |f| f.resources = matching }
57
- end
61
+ def narrow_to_offered_format(formula, matcher)
62
+ return formula unless formula.v5?
63
+
64
+ matching = formula.resources.select do |resource|
65
+ offers_format?(formula, resource) && matcher.matches_resource?(resource)
58
66
  end
67
+ formula.dup.tap { |f| f.resources = matching } if matching.any?
68
+ end
69
+
70
+ def offers_format?(formula, resource)
71
+ return true unless @format_spec.format
72
+
73
+ formula.resource_offers_format?(resource, @format_spec.format)
74
+ end
75
+
76
+ def convertible_request?
77
+ !@format_spec.format.nil? && !@format_spec.variable_requested?
59
78
  end
60
79
 
61
80
  def ensure_fontist_version(formulas)
@@ -41,6 +41,10 @@ module Fontist
41
41
  mutex.synchronize { cache.clear }
42
42
  end
43
43
 
44
+ def size
45
+ mutex.synchronize { cache.size }
46
+ end
47
+
44
48
  private
45
49
 
46
50
  attr_reader :mutex
@@ -3,7 +3,7 @@ module Fontist
3
3
  def self.url_object(request)
4
4
  return request unless request.include?("\"url\"")
5
5
 
6
- obj = JSON.parse(request.gsub("=>", ":"))
6
+ obj = Fontist::JSON.parse(request.gsub("=>", ":"))
7
7
  Struct.new(:url, :headers).new(obj["url"], obj["headers"])
8
8
  end
9
9
 
@@ -56,7 +56,7 @@ module Fontist
56
56
  end
57
57
 
58
58
  def stringify_keys(hash)
59
- JSON.parse(hash.to_json)
59
+ Fontist::JSON.parse(Fontist::JSON.dump(hash))
60
60
  end
61
61
  end
62
62
  end
@@ -58,8 +58,8 @@ module Fontist
58
58
  raise "API request failed: #{response.code} #{response.message}"
59
59
  end
60
60
 
61
- JSON.parse(response.body)
62
- rescue JSON::ParserError => e
61
+ Fontist::JSON.parse(response.body)
62
+ rescue Fontist::JSON::ParseError => e
63
63
  raise "Failed to parse API response: #{e.message}"
64
64
  rescue StandardError => e
65
65
  raise "Failed to fetch from API: #{e.message}"
@@ -90,7 +90,7 @@ module Fontist
90
90
  # @param item [Hash] the raw item data
91
91
  # @return [FontFamily] the parsed font family
92
92
  def parse_item(item)
93
- Models::FontFamily.from_json(item.to_json)
93
+ Models::FontFamily.from_json(Fontist::JSON.dump(item))
94
94
  end
95
95
  end
96
96
  end
@@ -51,6 +51,23 @@ module Fontist
51
51
 
52
52
  private
53
53
 
54
+ # HEAD commit of the google/fonts clone, used to pin raw file URLs
55
+ def repository_commit
56
+ return @repository_commit if defined?(@repository_commit)
57
+
58
+ @repository_commit = git_head_commit
59
+ end
60
+
61
+ # No shell involved, so this also works on Windows, where
62
+ # `2>/dev/null` is not a valid redirect.
63
+ def git_head_commit
64
+ sha = IO.popen(%w[git rev-parse HEAD], chdir: @source_path.to_s,
65
+ err: File::NULL, &:read)
66
+ sha.strip[/\A\h{40}\z/]
67
+ rescue SystemCallError
68
+ nil
69
+ end
70
+
54
71
  # Validate that the source path exists and contains fonts
55
72
  def validate_source_path!
56
73
  unless @source_path.exist?
@@ -155,6 +172,17 @@ module Fontist
155
172
  description: description,
156
173
  homepage: license_info[:homepage],
157
174
  font_file_data: font_files_data,
175
+ ).tap do |family|
176
+ family.variable_source = variable_source_for(metadata, family_dir)
177
+ end
178
+ end
179
+
180
+ # Real variable font files of the family, pinned to the clone's commit
181
+ def variable_source_for(metadata, family_dir)
182
+ VariableFontSource.from_metadata(
183
+ metadata,
184
+ directory: "#{family_dir.parent.basename}/#{family_dir.basename}",
185
+ commit: repository_commit,
158
186
  )
159
187
  end
160
188
 
@@ -50,48 +50,32 @@ module Fontist
50
50
  def build_resources
51
51
  resources = {}
52
52
 
53
- # Check if this is a variable font (has axes)
54
- is_variable = family.variable_font?
55
-
56
- if is_variable
57
- # Variable fonts: the single file IS the variable font
58
- # Google Fonts variable fonts are WOFF2 only
59
- if woff2_files[family.family]&.any?
60
- resources["woff2_variable"] = build_resource_entry(
61
- woff2_files[family.family],
62
- format: "woff2",
63
- variable: true,
64
- axes: family.axes,
65
- )
66
- end
53
+ # The only genuine variable files are the ones in google/fonts.
54
+ # The API serves static per-weight instances for every family,
55
+ # including variable ones, so those always go to *_static.
56
+ if variable_source
57
+ resources["ttf_variable"] =
58
+ variable_source.resource(family.family)
59
+ end
67
60
 
68
- # Also include TTF if available (for desktop use)
69
- if ttf_files[family.family]&.any?
70
- resources["ttf_variable"] = build_resource_entry(
71
- ttf_files[family.family],
72
- format: "ttf",
73
- variable: true,
74
- axes: family.axes,
75
- )
76
- end
77
- else
78
- # Static fonts: separate TTF and WOFF2
79
- static_ttf = filter_static_files(ttf_files[family.family])
80
- if static_ttf.any?
81
- resources["ttf_static"] = build_resource_entry(
82
- static_ttf, format: "ttf", variable: false
83
- )
84
- end
61
+ resources.merge(static_resources)
62
+ end
85
63
 
86
- static_woff2 = filter_static_files(woff2_files[family.family])
87
- if static_woff2.any?
88
- resources["woff2_static"] = build_resource_entry(
89
- static_woff2, format: "woff2", variable: false
90
- )
91
- end
92
- end
64
+ def static_resources
65
+ static_resource("ttf_static", "ttf", ttf_files)
66
+ .merge(static_resource("woff2_static", "woff2", woff2_files))
67
+ end
93
68
 
94
- resources
69
+ def static_resource(key, format, files_by_family)
70
+ files = filter_static_files(files_by_family[family.family])
71
+ return {} if files.empty?
72
+
73
+ { key => build_resource_entry(files, format: format,
74
+ variable: false) }
75
+ end
76
+
77
+ def variable_source
78
+ github_family&.variable_source
95
79
  end
96
80
 
97
81
  def build_resource_entry(files, format:, variable:, axes: nil)
@@ -132,17 +116,13 @@ module Fontist
132
116
 
133
117
  def is_variable_variant?(variant)
134
118
  # Variable fonts often have axes in the filename like "VariableFont_wght"
135
- return true if variant.include?("Variable")
136
-
137
- return false unless family.variable_font?
138
-
139
- family.axes.any? { |axis| variant.include?(axis.tag) }
119
+ variant.include?("Variable")
140
120
  end
141
121
 
142
122
  def build_fonts
143
123
  parsed_fonts = []
144
124
 
145
- # V5: Download and parse ALL TTF files (including variable fonts)
125
+ # V5: Download and parse the API's static TTF files
146
126
  ttf_files[family.family]&.each do |variant, url|
147
127
  sleep(0.05) # Throttle API requests
148
128
 
@@ -150,7 +130,6 @@ module Fontist
150
130
  downloaded = Fontist::Utils::Downloader.download(url)
151
131
  metadata = Fontist::Import::FontMetadataExtractor.new(downloaded.path).extract
152
132
 
153
- # V5: Include both static and variable fonts
154
133
  filename = url.split("/").last
155
134
 
156
135
  style_data = build_style_data(metadata, filename)
@@ -164,6 +143,7 @@ module Fontist
164
143
  end
165
144
  end
166
145
 
146
+ parsed_fonts.concat(variable_source.styles) if variable_source
167
147
  return [] if parsed_fonts.empty?
168
148
 
169
149
  group_fonts_by_subfamily(parsed_fonts)
@@ -173,17 +153,10 @@ module Fontist
173
153
  # Add formats available for this style
174
154
  style["formats"] = determine_formats_for_style(variant)
175
155
 
176
- # Add variable font info
177
- # For variable fonts, ALL styles should be marked as variable
178
- if family.variable_font?
179
- style["variable_font"] = true
180
- if family.axes&.any?
181
- style["variable_axes"] =
182
- family.axes.map(&:tag)
183
- end
184
- else
185
- style["variable_font"] = false
186
- end
156
+ # Styles built from API files are static instances; the real
157
+ # variable styles come from variable_source.
158
+ style["variable_font"] = false
159
+ style["source_resource"] = "ttf_static" if variable_source
187
160
  end
188
161
 
189
162
  def determine_formats_for_style(variant)
@@ -24,6 +24,9 @@ module Fontist
24
24
  attribute :homepage, :string
25
25
  attribute :font_file_data, :string
26
26
 
27
+ # Real variable font files from google/fonts (VariableFontSource)
28
+ attr_accessor :variable_source
29
+
27
30
  json do
28
31
  map "family", to: :family
29
32
  map "variants", to: :variants
@@ -56,15 +59,15 @@ module Fontist
56
59
 
57
60
  # Try to parse as JSON first
58
61
  begin
59
- JSON.parse(files_data)
60
- rescue JSON::ParserError, TypeError
62
+ Fontist::JSON.parse(files_data)
63
+ rescue Fontist::JSON::ParseError, TypeError
61
64
  # If it fails, convert Ruby hash syntax to JSON and parse
62
65
  if files_data.is_a?(String)
63
66
  # Convert Ruby hash syntax (=>) to JSON syntax (:)
64
67
  json_str = files_data.gsub("=>", ":")
65
68
  begin
66
- return JSON.parse(json_str)
67
- rescue JSON::ParserError
69
+ return Fontist::JSON.parse(json_str)
70
+ rescue Fontist::JSON::ParseError
68
71
  {}
69
72
  end
70
73
  end