fontisan 0.2.23 → 0.3.0

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/lib/fontisan/cli.rb +6 -0
  3. data/lib/fontisan/stitcher/selector/codepoints.rb +29 -0
  4. data/lib/fontisan/stitcher/selector/gid.rb +25 -0
  5. data/lib/fontisan/stitcher/selector/range.rb +30 -0
  6. data/lib/fontisan/stitcher/selector.rb +26 -0
  7. data/lib/fontisan/stitcher/source.rb +97 -0
  8. data/lib/fontisan/stitcher.rb +182 -0
  9. data/lib/fontisan/stitcher_cli.rb +69 -0
  10. data/lib/fontisan/ufo/anchor.rb +17 -0
  11. data/lib/fontisan/ufo/cli.rb +85 -0
  12. data/lib/fontisan/ufo/compile/base_compiler.rb +81 -0
  13. data/lib/fontisan/ufo/compile/cff.rb +224 -0
  14. data/lib/fontisan/ufo/compile/cmap.rb +129 -0
  15. data/lib/fontisan/ufo/compile/filters/cubic_to_quadratic.rb +174 -0
  16. data/lib/fontisan/ufo/compile/filters/decompose_components.rb +33 -0
  17. data/lib/fontisan/ufo/compile/filters/flatten_components.rb +22 -0
  18. data/lib/fontisan/ufo/compile/filters/reverse_contour_direction.rb +27 -0
  19. data/lib/fontisan/ufo/compile/filters.rb +57 -0
  20. data/lib/fontisan/ufo/compile/glyf_loca.rb +145 -0
  21. data/lib/fontisan/ufo/compile/head.rb +98 -0
  22. data/lib/fontisan/ufo/compile/hhea.rb +36 -0
  23. data/lib/fontisan/ufo/compile/hmtx.rb +27 -0
  24. data/lib/fontisan/ufo/compile/maxp.rb +57 -0
  25. data/lib/fontisan/ufo/compile/name.rb +79 -0
  26. data/lib/fontisan/ufo/compile/os2.rb +81 -0
  27. data/lib/fontisan/ufo/compile/otf_compiler.rb +43 -0
  28. data/lib/fontisan/ufo/compile/post.rb +32 -0
  29. data/lib/fontisan/ufo/compile/ttf_compiler.rb +69 -0
  30. data/lib/fontisan/ufo/compile.rb +48 -0
  31. data/lib/fontisan/ufo/component.rb +18 -0
  32. data/lib/fontisan/ufo/contour.rb +29 -0
  33. data/lib/fontisan/ufo/convert/from_bin_data.rb +246 -0
  34. data/lib/fontisan/ufo/convert.rb +18 -0
  35. data/lib/fontisan/ufo/data_set.rb +21 -0
  36. data/lib/fontisan/ufo/features.rb +17 -0
  37. data/lib/fontisan/ufo/font.rb +61 -0
  38. data/lib/fontisan/ufo/glyph.rb +421 -0
  39. data/lib/fontisan/ufo/guideline.rb +19 -0
  40. data/lib/fontisan/ufo/image.rb +16 -0
  41. data/lib/fontisan/ufo/image_set.rb +19 -0
  42. data/lib/fontisan/ufo/info.rb +79 -0
  43. data/lib/fontisan/ufo/kerning.rb +32 -0
  44. data/lib/fontisan/ufo/layer.rb +38 -0
  45. data/lib/fontisan/ufo/layer_set.rb +37 -0
  46. data/lib/fontisan/ufo/lib.rb +24 -0
  47. data/lib/fontisan/ufo/plist.rb +118 -0
  48. data/lib/fontisan/ufo/point.rb +38 -0
  49. data/lib/fontisan/ufo/reader.rb +144 -0
  50. data/lib/fontisan/ufo/transformation.rb +39 -0
  51. data/lib/fontisan/ufo/writer.rb +115 -0
  52. data/lib/fontisan/ufo.rb +44 -0
  53. data/lib/fontisan/version.rb +1 -1
  54. data/lib/fontisan.rb +3 -0
  55. metadata +51 -1
@@ -0,0 +1,421 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Fontisan
6
+ module Ufo
7
+ # A glyph in a UFO source. Holds contours, components, anchors,
8
+ # guidelines, images, unicode codepoints, advance width/height,
9
+ # and a custom-data bag (`lib`).
10
+ #
11
+ # The `.glif` XML format has multiple revisions (1, 2, 3); the
12
+ # parser accepts all of them.
13
+ class Glyph
14
+ attr_accessor :width, :height, :note, :lib
15
+ attr_reader :name, :unicodes, :contours, :components, :anchors,
16
+ :guidelines, :images
17
+
18
+ def initialize(name:)
19
+ @name = name.to_s
20
+ @unicodes = []
21
+ @width = 0.0
22
+ @height = 0.0
23
+ @contours = []
24
+ @components = []
25
+ @anchors = []
26
+ @guidelines = []
27
+ @images = []
28
+ @note = nil
29
+ @lib = Lib.new
30
+ end
31
+
32
+ def add_unicode(codepoint)
33
+ @unicodes << codepoint.to_i
34
+ end
35
+
36
+ def add_contour(contour)
37
+ @contours << contour
38
+ contour
39
+ end
40
+
41
+ def add_component(component)
42
+ @components << component
43
+ component
44
+ end
45
+
46
+ def add_anchor(anchor)
47
+ @anchors << anchor
48
+ anchor
49
+ end
50
+
51
+ def add_guideline(guideline)
52
+ @guidelines << guideline
53
+ guideline
54
+ end
55
+
56
+ def add_image(image)
57
+ @images << image
58
+ image
59
+ end
60
+
61
+ # Composite glyphs reference other glyphs via components.
62
+ def composite?
63
+ !@components.empty?
64
+ end
65
+
66
+ # Total number of points across all contours.
67
+ def point_count
68
+ @contours.sum(&:point_count)
69
+ end
70
+
71
+ # @return [BoundingBox, nil] axis-aligned bbox of contours.
72
+ def bbox
73
+ return nil if @contours.empty?
74
+
75
+ points = @contours.flat_map(&:points)
76
+ return nil if points.empty?
77
+
78
+ BoundingBox.new(
79
+ x_min: points.map(&:x).min,
80
+ y_min: points.map(&:y).min,
81
+ x_max: points.map(&:x).max,
82
+ y_max: points.map(&:y).max,
83
+ )
84
+ end
85
+
86
+ # @param xml [String] the .glif XML body
87
+ # @return [Glyph] parsed glyph
88
+ def self.from_glif(xml)
89
+ doc = Nokogiri::XML(xml)
90
+ root = doc.root
91
+ new(name: root["name"]).tap { |g| g.read_from(root) }
92
+ end
93
+
94
+ # Render this glyph back to .glif XML.
95
+ # @return [String] XML text
96
+ def to_glif
97
+ builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
98
+ xml.glyph(name: @name, format: "2") do
99
+ emit_advance(xml)
100
+ emit_unicodes(xml)
101
+ emit_outline(xml)
102
+ emit_lib(xml)
103
+ emit_note(xml)
104
+ end
105
+ end
106
+ builder.to_xml
107
+ end
108
+
109
+ # Populate this glyph from a <glyph> Nokogiri node.
110
+ def read_from(root)
111
+ read_advance(root)
112
+ read_unicodes(root)
113
+ read_outline(root)
114
+ read_anchors(root)
115
+ read_guidelines(root)
116
+ read_image(root)
117
+ read_lib(root)
118
+ read_note(root)
119
+ end
120
+
121
+ # Convert this glyph to a fontisan Models::Outline for use by
122
+ # the CFF charstring builder.
123
+ #
124
+ # UFO contours use cubic Bezier curves ("curve" type with two
125
+ # off-curve controls). Single off-curves are interpreted as
126
+ # quadratic and degree-elevated to cubic.
127
+ #
128
+ # @return [Fontisan::Models::Outline]
129
+ def to_outline
130
+ commands = []
131
+ bbox_hash = { x_min: 0, y_min: 0, x_max: 0, y_max: 0 }
132
+
133
+ @contours.each do |contour|
134
+ next if contour.points.empty?
135
+
136
+ points = contour.points
137
+ commands << { type: :move_to, x: points.first.x, y: points.first.y }
138
+
139
+ i = 1
140
+ while i < points.size
141
+ pt = points[i]
142
+ if pt.on_curve?
143
+ commands << { type: :line_to, x: pt.x, y: pt.y }
144
+ i += 1
145
+ elsif i + 2 < points.size &&
146
+ !points[i + 1].on_curve? && points[i + 2].on_curve?
147
+ commands << {
148
+ type: :curve_to,
149
+ cx1: points[i].x, cy1: points[i].y,
150
+ cx2: points[i + 1].x, cy2: points[i + 1].y,
151
+ x: points[i + 2].x, y: points[i + 2].y
152
+ }
153
+ i += 3
154
+ elsif i + 1 < points.size && points[i + 1].on_curve?
155
+ prev = points[i - 1]
156
+ nxt = points[i + 1]
157
+ cx1 = prev.x + (2.0 / 3.0) * (pt.x - prev.x)
158
+ cy1 = prev.y + (2.0 / 3.0) * (pt.y - prev.y)
159
+ cx2 = nxt.x + (2.0 / 3.0) * (pt.x - nxt.x)
160
+ cy2 = nxt.y + (2.0 / 3.0) * (pt.y - nxt.y)
161
+ commands << {
162
+ type: :curve_to,
163
+ cx1: cx1, cy1: cy1, cx2: cx2, cy2: cy2,
164
+ x: nxt.x, y: nxt.y
165
+ }
166
+ i += 2
167
+ else
168
+ i += 1
169
+ end
170
+ end
171
+
172
+ commands << { type: :close_path }
173
+ end
174
+
175
+ bb = bbox
176
+ if bb
177
+ bbox_hash = {
178
+ x_min: bb.x_min.to_i, y_min: bb.y_min.to_i,
179
+ x_max: bb.x_max.to_i, y_max: bb.y_max.to_i
180
+ }
181
+ end
182
+
183
+ Fontisan::Models::Outline.new(
184
+ glyph_id: 0,
185
+ commands: commands,
186
+ bbox: bbox_hash,
187
+ width: @width.to_i,
188
+ )
189
+ end
190
+
191
+ private
192
+
193
+ def read_advance(root)
194
+ adv = root.at_xpath("advance")
195
+ return unless adv
196
+
197
+ @width = adv["width"].to_f if adv["width"]
198
+ @height = adv["height"].to_f if adv["height"]
199
+ end
200
+
201
+ def read_unicodes(root)
202
+ root.xpath("unicode").each do |u|
203
+ hex = u["hex"] || u.text
204
+ add_unicode(hex.to_i(16)) if hex
205
+ end
206
+ end
207
+
208
+ def read_outline(root)
209
+ outline = root.at_xpath("outline")
210
+ return unless outline
211
+
212
+ outline.xpath("contour").each do |c|
213
+ add_contour(read_contour(c))
214
+ end
215
+ outline.xpath("component").each do |c|
216
+ add_component(read_component(c))
217
+ end
218
+ end
219
+
220
+ def read_contour(node)
221
+ points = node.xpath("point").map do |p|
222
+ Point.new(
223
+ x: p["x"].to_f,
224
+ y: p["y"].to_f,
225
+ type: p["type"] || "offcurve",
226
+ smooth: p["smooth"] == "yes",
227
+ )
228
+ end
229
+ Contour.new(points)
230
+ end
231
+
232
+ def read_component(node)
233
+ Component.new(
234
+ base_glyph: node["base"],
235
+ transformation: read_transformation(node),
236
+ identifier: node["identifier"],
237
+ )
238
+ end
239
+
240
+ def read_transformation(node)
241
+ return nil unless node["xScale"] || node["yScale"] ||
242
+ node["xyScale"] || node["yxScale"] ||
243
+ node["xOffset"] || node["yOffset"]
244
+
245
+ Transformation.new(
246
+ a: node["xScale"]&.to_f || 1.0,
247
+ b: node["xyScale"].to_f,
248
+ c: node["yxScale"].to_f,
249
+ d: node["yScale"]&.to_f || 1.0,
250
+ e: node["xOffset"].to_f,
251
+ f: node["yOffset"].to_f,
252
+ )
253
+ end
254
+
255
+ def read_anchors(root)
256
+ root.xpath("anchor").each do |a|
257
+ add_anchor(Anchor.new(
258
+ x: a["x"].to_f,
259
+ y: a["y"].to_f,
260
+ name: a["name"],
261
+ identifier: a["identifier"],
262
+ ))
263
+ end
264
+ end
265
+
266
+ def read_guidelines(root)
267
+ root.xpath("guideline").each do |g|
268
+ add_guideline(Guideline.new(
269
+ x: g["x"].to_f,
270
+ y: g["y"].to_f,
271
+ angle: g["angle"]&.to_f,
272
+ name: g["name"],
273
+ identifier: g["identifier"],
274
+ ))
275
+ end
276
+ end
277
+
278
+ def read_image(root)
279
+ img = root.at_xpath("image")
280
+ return unless img && img["fileName"]
281
+
282
+ add_image(Image.new(
283
+ file_name: img["fileName"],
284
+ transformation: read_transformation(img),
285
+ color: img["color"],
286
+ ))
287
+ end
288
+
289
+ def read_lib(root)
290
+ lib_node = root.at_xpath("lib")
291
+ return unless lib_node
292
+
293
+ dict = lib_node.at_xpath("dict")
294
+ return unless dict
295
+
296
+ @lib = Lib.new(read_dict_to_hash(dict))
297
+ end
298
+
299
+ def read_note(root)
300
+ note_node = root.at_xpath("note")
301
+ @note = note_node.text if note_node
302
+ end
303
+
304
+ # Helper: read a <dict> Nokogiri node into a Hash. Reuses the
305
+ # same key/value pair logic as Plist.parse but operates on the
306
+ # inlined lib plist.
307
+ def read_dict_to_hash(dict_node)
308
+ result = {}
309
+ children = dict_node.element_children.to_a
310
+ while children.any?
311
+ key_node = children.shift
312
+ next unless key_node.name == "key"
313
+
314
+ value_node = children.shift
315
+ result[key_node.text] = read_plist_value(value_node)
316
+ end
317
+ result
318
+ end
319
+
320
+ def read_plist_value(node)
321
+ case node.name
322
+ when "string" then node.text
323
+ when "integer" then node.text.to_i
324
+ when "real" then node.text.to_f
325
+ when "true" then true
326
+ when "false" then false
327
+ when "array" then node.element_children.map { |c| read_plist_value(c) }
328
+ when "dict" then read_dict_to_hash(node)
329
+ end
330
+ end
331
+
332
+ # ---------- emission ----------
333
+
334
+ def emit_advance(xml)
335
+ attrs = {}
336
+ attrs[:width] = @width unless @width.zero?
337
+ attrs[:height] = @height unless @height.zero?
338
+ xml.advance(**attrs) unless attrs.empty?
339
+ end
340
+
341
+ def emit_unicodes(xml)
342
+ @unicodes.each { |cp| xml.unicode(hex: format("%04X", cp)) }
343
+ end
344
+
345
+ def emit_outline(xml)
346
+ return if @contours.empty? && @components.empty?
347
+
348
+ xml.outline do
349
+ @contours.each { |c| emit_contour(xml, c) }
350
+ @components.each { |comp| emit_component(xml, comp) }
351
+ end
352
+ end
353
+
354
+ def emit_contour(xml, contour)
355
+ xml.contour do
356
+ contour.points.each { |p| emit_point(xml, p) }
357
+ end
358
+ end
359
+
360
+ def emit_point(xml, point)
361
+ attrs = { x: point.x, y: point.y }
362
+ attrs[:type] = point.type unless point.type == "offcurve"
363
+ attrs[:smooth] = "yes" if point.smooth
364
+ xml.point(**attrs)
365
+ end
366
+
367
+ def emit_component(xml, comp)
368
+ attrs = { base: comp.base_glyph }
369
+ if comp.transformation && !comp.transformation.identity?
370
+ t = comp.transformation
371
+ attrs[:xScale] = t.a
372
+ attrs[:xyScale] = t.b
373
+ attrs[:yxScale] = t.c
374
+ attrs[:yScale] = t.d
375
+ attrs[:xOffset] = t.e
376
+ attrs[:yOffset] = t.f
377
+ end
378
+ attrs[:identifier] = comp.identifier if comp.identifier
379
+ xml.component(**attrs)
380
+ end
381
+
382
+ def emit_lib(xml)
383
+ return if @lib.data.empty?
384
+
385
+ xml.lib do
386
+ emit_dict(xml, @lib.data)
387
+ end
388
+ end
389
+
390
+ def emit_dict(xml, hash)
391
+ xml.dict do
392
+ hash.each do |k, v|
393
+ xml.key(k.to_s)
394
+ emit_value(xml, v)
395
+ end
396
+ end
397
+ end
398
+
399
+ def emit_value(xml, v)
400
+ case v
401
+ when String then xml.string(v)
402
+ when Integer then xml.integer(v)
403
+ when Float then xml.real(v)
404
+ when true then xml.__send__(true)
405
+ when false then xml.__send__(false)
406
+ when Array then xml.array { v.each { |i| emit_value(xml, i) } }
407
+ when Hash then emit_dict(xml, v)
408
+ else xml.string(v.to_s)
409
+ end
410
+ end
411
+
412
+ def emit_note(xml)
413
+ xml.note(@note) if @note
414
+ end
415
+ end
416
+
417
+ # Plain-data bounding box (used by glyph bbox computation; not a
418
+ # full OpenType table).
419
+ BoundingBox = Struct.new(:x_min, :y_min, :x_max, :y_max, keyword_init: true)
420
+ end
421
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ # A helper guideline on a glyph. Editor-only; not compiled into
6
+ # the final font.
7
+ class Guideline
8
+ attr_reader :x, :y, :angle, :name, :identifier
9
+
10
+ def initialize(x:, y:, angle: nil, name: nil, identifier: nil)
11
+ @x = x
12
+ @y = y
13
+ @angle = angle
14
+ @name = name
15
+ @identifier = identifier
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ # A background image anchored to a glyph (common in color fonts).
6
+ class Image
7
+ attr_reader :file_name, :transformation, :color
8
+
9
+ def initialize(file_name:, transformation: nil, color: nil)
10
+ @file_name = file_name.to_s
11
+ @transformation = transformation
12
+ @color = color
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ # Background images from `images/<layer>/...`. MVP stores nothing;
6
+ # a real implementation lands with TODO 02 (glyph model + images).
7
+ class ImageSet
8
+ attr_reader :images
9
+
10
+ def initialize
11
+ @images = {}
12
+ end
13
+
14
+ def empty?
15
+ @images.empty?
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ # Typed wrapper around a UFO's `fontinfo.plist`. Provides accessor
6
+ # methods for every standard UFO 3 field, with sensible defaults
7
+ # when reading a UFO that omits some fields.
8
+ #
9
+ # Field naming follows UFO 3 (camelCase in the plist, snake_case in
10
+ # Ruby). Fields are looked up case-insensitively on read.
11
+ class Info
12
+ # Convenience: standard fields. Add to this list as new compiler
13
+ # needs arise; serialization walks all known fields.
14
+ STANDARD_FIELDS = %i[
15
+ family_name style_name version_major version_minor units_per_em
16
+ ascender descender cap_height x_height italic_angle
17
+ postscript_font_name postscript_full_name postscript_weight_name
18
+ copyright created modified note
19
+ open_type_head_created open_type_head_flags
20
+ open_type_hhea_ascender open_type_hhea_descender
21
+ open_type_hhea_line_gap open_type_name_records
22
+ open_type_os2_weight_class open_type_os2_width_class
23
+ open_type_vhea_ascender open_type_vhea_descender open_type_vhea_line_gap
24
+ year_month_day_time_seconds_since_epoch
25
+ ].freeze
26
+
27
+ attr_accessor(*STANDARD_FIELDS)
28
+
29
+ # Catch-all for non-standard (vendor-specific) fields.
30
+ attr_accessor :extras
31
+
32
+ def initialize(values = {})
33
+ @extras = {}
34
+ values.each do |key, value|
35
+ attr = camel_to_snake(key.to_s).to_sym
36
+ if STANDARD_FIELDS.include?(attr)
37
+ public_send("#{attr}=", value)
38
+ else
39
+ @extras[key.to_s] = value
40
+ end
41
+ end
42
+ end
43
+
44
+ # @return [Hash] a Hash<String, Object> suitable for emit() to
45
+ # serialize back to plist. Keys are in camelCase per UFO 3.
46
+ def to_plist
47
+ h = {}
48
+ STANDARD_FIELDS.each do |attr|
49
+ value = public_send(attr)
50
+ h[snake_to_camel(attr.to_s)] = value unless value.nil?
51
+ end
52
+ @extras.each { |k, v| h[k] = v }
53
+ h
54
+ end
55
+
56
+ # ---------- case conversion ----------
57
+
58
+ # "familyName" -> "family_name"
59
+ # "openTypeOS2WeightClass" -> "open_type_os2_weight_class"
60
+ # "OTTO" -> "otto"
61
+ def camel_to_snake(str)
62
+ str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
63
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
64
+ .downcase
65
+ end
66
+
67
+ # "family_name" -> "familyName"
68
+ # "open_type_hhea_ascender" -> "openTypeHheaAscender"
69
+ # "version_major" -> "versionMajor"
70
+ def snake_to_camel(str)
71
+ parts = str.split("_")
72
+ return str if parts.size <= 1
73
+
74
+ parts[0] + parts[1..].map(&:capitalize).join
75
+ end
76
+ private :camel_to_snake, :snake_to_camel
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ # A Kerning pairs table parsed from `kerning.plist`. Pairs are
6
+ # stored as `"<left> <right>" => float`, matching the file format.
7
+ # Group pair keys (`"<left_group> <right_group>"`) are stored verbatim.
8
+ class Kerning
9
+ attr_reader :pairs
10
+
11
+ def initialize(values = {})
12
+ @pairs = values
13
+ end
14
+
15
+ def [](pair_key)
16
+ @pairs[pair_key.to_s]
17
+ end
18
+
19
+ def []=(pair_key, value)
20
+ @pairs[pair_key.to_s] = value
21
+ end
22
+
23
+ def empty?
24
+ @pairs.empty?
25
+ end
26
+
27
+ def to_plist
28
+ @pairs
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ # A single layer in a UFO source. A Layer holds a set of glyphs
6
+ # keyed by name. The default layer is `public.default` per UFO 3.
7
+ #
8
+ # glyphs is mutated in place by Reader and Writer; the Layer does
9
+ # not own serialization concerns.
10
+ class Layer
11
+ DEFAULT_NAME = "public.default"
12
+
13
+ attr_reader :name, :glyphs
14
+
15
+ def initialize(name = DEFAULT_NAME)
16
+ @name = name
17
+ @glyphs = {}
18
+ end
19
+
20
+ def [](glyph_name)
21
+ @glyphs[glyph_name.to_s]
22
+ end
23
+
24
+ def add(glyph)
25
+ @glyphs[glyph.name.to_s] = glyph
26
+ glyph
27
+ end
28
+
29
+ def each(&)
30
+ @glyphs.each_value(&)
31
+ end
32
+
33
+ def size
34
+ @glyphs.size
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ # The collection of layers in a UFO source. The default layer is
6
+ # always present and keyed by `"public.default"`.
7
+ class LayerSet
8
+ attr_reader :layers
9
+
10
+ def initialize
11
+ @layers = { Layer::DEFAULT_NAME => Layer.new }
12
+ end
13
+
14
+ def default_layer
15
+ @layers[Layer::DEFAULT_NAME]
16
+ end
17
+
18
+ def [](name)
19
+ @layers[name.to_s]
20
+ end
21
+
22
+ def add(name)
23
+ name = name.to_s
24
+ @layers[name] ||= Layer.new(name)
25
+ @layers[name]
26
+ end
27
+
28
+ def each(&)
29
+ @layers.each_value(&)
30
+ end
31
+
32
+ def size
33
+ @layers.size
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ # Custom font-wide data from `lib.plist`. Generic key/value store.
6
+ # Not used by standard UFO compilation, but downstream tools may
7
+ # read it.
8
+ class Lib
9
+ attr_reader :data
10
+
11
+ def initialize(values = {})
12
+ @data = values
13
+ end
14
+
15
+ def [](key)
16
+ @data[key.to_s]
17
+ end
18
+
19
+ def []=(key, value)
20
+ @data[key.to_s] = value
21
+ end
22
+ end
23
+ end
24
+ end