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.
- checksums.yaml +4 -4
- data/lib/fontisan/cli.rb +6 -0
- data/lib/fontisan/stitcher/selector/codepoints.rb +29 -0
- data/lib/fontisan/stitcher/selector/gid.rb +25 -0
- data/lib/fontisan/stitcher/selector/range.rb +30 -0
- data/lib/fontisan/stitcher/selector.rb +26 -0
- data/lib/fontisan/stitcher/source.rb +97 -0
- data/lib/fontisan/stitcher.rb +182 -0
- data/lib/fontisan/stitcher_cli.rb +69 -0
- data/lib/fontisan/ufo/anchor.rb +17 -0
- data/lib/fontisan/ufo/cli.rb +85 -0
- data/lib/fontisan/ufo/compile/base_compiler.rb +81 -0
- data/lib/fontisan/ufo/compile/cff.rb +224 -0
- data/lib/fontisan/ufo/compile/cmap.rb +129 -0
- data/lib/fontisan/ufo/compile/filters/cubic_to_quadratic.rb +174 -0
- data/lib/fontisan/ufo/compile/filters/decompose_components.rb +33 -0
- data/lib/fontisan/ufo/compile/filters/flatten_components.rb +22 -0
- data/lib/fontisan/ufo/compile/filters/reverse_contour_direction.rb +27 -0
- data/lib/fontisan/ufo/compile/filters.rb +57 -0
- data/lib/fontisan/ufo/compile/glyf_loca.rb +145 -0
- data/lib/fontisan/ufo/compile/head.rb +98 -0
- data/lib/fontisan/ufo/compile/hhea.rb +36 -0
- data/lib/fontisan/ufo/compile/hmtx.rb +27 -0
- data/lib/fontisan/ufo/compile/maxp.rb +57 -0
- data/lib/fontisan/ufo/compile/name.rb +79 -0
- data/lib/fontisan/ufo/compile/os2.rb +81 -0
- data/lib/fontisan/ufo/compile/otf_compiler.rb +43 -0
- data/lib/fontisan/ufo/compile/post.rb +32 -0
- data/lib/fontisan/ufo/compile/ttf_compiler.rb +69 -0
- data/lib/fontisan/ufo/compile.rb +48 -0
- data/lib/fontisan/ufo/component.rb +18 -0
- data/lib/fontisan/ufo/contour.rb +29 -0
- data/lib/fontisan/ufo/convert/from_bin_data.rb +246 -0
- data/lib/fontisan/ufo/convert.rb +18 -0
- data/lib/fontisan/ufo/data_set.rb +21 -0
- data/lib/fontisan/ufo/features.rb +17 -0
- data/lib/fontisan/ufo/font.rb +61 -0
- data/lib/fontisan/ufo/glyph.rb +421 -0
- data/lib/fontisan/ufo/guideline.rb +19 -0
- data/lib/fontisan/ufo/image.rb +16 -0
- data/lib/fontisan/ufo/image_set.rb +19 -0
- data/lib/fontisan/ufo/info.rb +79 -0
- data/lib/fontisan/ufo/kerning.rb +32 -0
- data/lib/fontisan/ufo/layer.rb +38 -0
- data/lib/fontisan/ufo/layer_set.rb +37 -0
- data/lib/fontisan/ufo/lib.rb +24 -0
- data/lib/fontisan/ufo/plist.rb +118 -0
- data/lib/fontisan/ufo/point.rb +38 -0
- data/lib/fontisan/ufo/reader.rb +144 -0
- data/lib/fontisan/ufo/transformation.rb +39 -0
- data/lib/fontisan/ufo/writer.rb +115 -0
- data/lib/fontisan/ufo.rb +44 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +3 -0
- 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
|