fontisan 0.4.11 → 0.4.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.adoc +27 -1
- data/docs/AFDKO_MIGRATION.adoc +175 -0
- data/docs/FEATURE_PARITY.adoc +263 -0
- data/docs/STITCHER_GUIDE.adoc +100 -6
- data/docs/TTX.adoc +176 -0
- data/docs/UFO_COMPILATION.adoc +281 -3
- data/lib/fontisan/converters/woff2_encoder.rb +147 -242
- data/lib/fontisan/sfnt_font.rb +7 -2
- data/lib/fontisan/stitcher/source.rb +11 -7
- data/lib/fontisan/subset/table_subsetter.rb +104 -6
- data/lib/fontisan/svg/standalone_glyph.rb +129 -0
- data/lib/fontisan/svg.rb +1 -0
- data/lib/fontisan/tables/head.rb +10 -0
- data/lib/fontisan/ufo/cli.rb +34 -21
- data/lib/fontisan/ufo/compile/feature_writers/base.rb +56 -0
- data/lib/fontisan/ufo/compile/feature_writers/curs.rb +72 -0
- data/lib/fontisan/ufo/compile/feature_writers/gdef.rb +90 -0
- data/lib/fontisan/ufo/compile/feature_writers/kern.rb +51 -0
- data/lib/fontisan/ufo/compile/feature_writers/kern2.rb +57 -0
- data/lib/fontisan/ufo/compile/feature_writers/mark.rb +45 -0
- data/lib/fontisan/ufo/compile/feature_writers/mark_family_base.rb +122 -0
- data/lib/fontisan/ufo/compile/feature_writers/mkmk.rb +53 -0
- data/lib/fontisan/ufo/compile/feature_writers.rb +37 -0
- data/lib/fontisan/ufo/compile/filters/propagate_anchors.rb +102 -0
- data/lib/fontisan/ufo/compile/filters/remove_overlaps.rb +94 -0
- data/lib/fontisan/ufo/compile/filters/sort_contours.rb +69 -0
- data/lib/fontisan/ufo/compile/filters/transformations.rb +113 -0
- data/lib/fontisan/ufo/compile/filters.rb +12 -0
- data/lib/fontisan/ufo/compile.rb +1 -0
- data/lib/fontisan/ufo/convert/to_dfont.rb +41 -0
- data/lib/fontisan/ufo/convert/to_otc.rb +37 -0
- data/lib/fontisan/ufo/convert/to_otf.rb +18 -0
- data/lib/fontisan/ufo/convert/to_otf2.rb +20 -0
- data/lib/fontisan/ufo/convert/to_postscript.rb +59 -0
- data/lib/fontisan/ufo/convert/to_ttc.rb +35 -0
- data/lib/fontisan/ufo/convert/to_ttf.rb +21 -0
- data/lib/fontisan/ufo/convert/to_woff.rb +56 -0
- data/lib/fontisan/ufo/convert/to_woff2.rb +45 -0
- data/lib/fontisan/ufo/convert.rb +68 -5
- data/lib/fontisan/ufo/transformation.rb +11 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/directory.rb +37 -40
- data/lib/fontisan/woff2/encoder_rules.rb +66 -0
- data/lib/fontisan/woff2/glyf_loca_transform.rb +323 -0
- data/lib/fontisan/woff2/triplet_codec.rb +181 -0
- data/lib/fontisan/woff2.rb +3 -0
- data/lib/fontisan/woff_font.rb +5 -2
- metadata +30 -1
|
@@ -56,6 +56,8 @@ module Fontisan
|
|
|
56
56
|
@options = options
|
|
57
57
|
@glyf_data = nil
|
|
58
58
|
@loca_offsets = nil
|
|
59
|
+
@subset_bbox = nil # [xMin, yMin, xMax, yMax] over actual subset glyphs
|
|
60
|
+
@subset_max_advance = 0 # largest advanceWidth in subset hmtx
|
|
59
61
|
end
|
|
60
62
|
|
|
61
63
|
# Subset a table by tag
|
|
@@ -110,10 +112,12 @@ module Fontisan
|
|
|
110
112
|
data
|
|
111
113
|
end
|
|
112
114
|
|
|
113
|
-
# Subset hhea table (update numberOfHMetrics)
|
|
115
|
+
# Subset hhea table (update numberOfHMetrics + advanceWidthMax)
|
|
114
116
|
#
|
|
115
|
-
# Updates
|
|
116
|
-
#
|
|
117
|
+
# Updates numberOfHMetrics to reflect the subset's glyph count and
|
|
118
|
+
# recomputes advanceWidthMax from the subset's actual hmtx. The
|
|
119
|
+
# source TTC's advanceWidthMax covers every donor font and is far
|
|
120
|
+
# larger than any per-block subset needs.
|
|
117
121
|
#
|
|
118
122
|
# @param table [Hhea] Parsed hhea table
|
|
119
123
|
# @param hmtx [Hmtx, nil] Optional parsed hmtx table (for calculating metrics)
|
|
@@ -128,9 +132,18 @@ module Fontisan
|
|
|
128
132
|
calculate_number_of_h_metrics
|
|
129
133
|
end
|
|
130
134
|
|
|
131
|
-
#
|
|
135
|
+
# numberOfHMetrics at offset 34 (uint16)
|
|
132
136
|
data[34, 2] = [new_num_h_metrics].pack("n")
|
|
133
137
|
|
|
138
|
+
# advanceWidthMax at offset 10 (uint16). Recompute from subset
|
|
139
|
+
# hmtx so a per-block subset doesn't keep the source TTC's
|
|
140
|
+
# max (which can be 4x larger than any glyph in the subset).
|
|
141
|
+
# Tables are processed alphabetically (hhea before hmtx), so
|
|
142
|
+
# we read hmtx directly here rather than relying on a cached
|
|
143
|
+
# value from subset_hmtx.
|
|
144
|
+
new_max = compute_subset_max_advance
|
|
145
|
+
data[10, 2] = [new_max].pack("n") if new_max.positive?
|
|
146
|
+
|
|
134
147
|
data
|
|
135
148
|
end
|
|
136
149
|
|
|
@@ -152,14 +165,18 @@ module Fontisan
|
|
|
152
165
|
# Build new hmtx data
|
|
153
166
|
data = String.new(encoding: Encoding::BINARY)
|
|
154
167
|
|
|
168
|
+
max_advance = 0
|
|
155
169
|
mapping.old_ids.each do |old_id|
|
|
156
170
|
metric = table.metric_for(old_id)
|
|
157
171
|
next unless metric
|
|
158
172
|
|
|
159
|
-
|
|
173
|
+
advance = metric[:advance_width]
|
|
174
|
+
max_advance = advance if advance && advance > max_advance
|
|
175
|
+
data << [advance].pack("n")
|
|
160
176
|
data << [metric[:lsb]].pack("n")
|
|
161
177
|
end
|
|
162
178
|
|
|
179
|
+
@subset_max_advance = max_advance
|
|
163
180
|
data
|
|
164
181
|
end
|
|
165
182
|
|
|
@@ -264,8 +281,32 @@ module Fontisan
|
|
|
264
281
|
#
|
|
265
282
|
# @param table [Head] Parsed head table
|
|
266
283
|
# @return [String] Binary data of subset head table
|
|
284
|
+
# Subset head table (recompute bbox from actual subset glyphs)
|
|
285
|
+
#
|
|
286
|
+
# The source TTC's head.xMin/yMin/xMax/yMax covers every donor
|
|
287
|
+
# font in the collection — far larger than any per-block subset.
|
|
288
|
+
# Browsers and layout engines use head bbox (plus hhea + OS/2)
|
|
289
|
+
# to compute line height and clip text; a too-large bbox makes
|
|
290
|
+
# glyphs render at the wrong visual size.
|
|
291
|
+
#
|
|
292
|
+
# @param _table [Head] Parsed head table (unused; we re-serialize
|
|
293
|
+
# directly from font.table_data so we don't lose other fields)
|
|
294
|
+
# @return [String] Binary data of subset head table
|
|
267
295
|
def subset_head(_table)
|
|
268
|
-
|
|
296
|
+
# Trigger glyf build (which populates @subset_bbox) if not done.
|
|
297
|
+
glyf = font.table("glyf")
|
|
298
|
+
build_glyf_and_loca(glyf) unless @glyf_data
|
|
299
|
+
|
|
300
|
+
data = font.table_data["head"].dup
|
|
301
|
+
|
|
302
|
+
if @subset_bbox
|
|
303
|
+
x_min, y_min, x_max, y_max = @subset_bbox
|
|
304
|
+
# head layout: xMin at offset 36, yMin 38, xMax 40, yMax 42
|
|
305
|
+
# (each int16, big-endian, signed)
|
|
306
|
+
data[36, 8] = [x_min, y_min, x_max, y_max].pack("n4")
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
data
|
|
269
310
|
end
|
|
270
311
|
|
|
271
312
|
# Subset OS/2 table (optionally prune Unicode ranges)
|
|
@@ -295,6 +336,32 @@ module Fontisan
|
|
|
295
336
|
mapping.size
|
|
296
337
|
end
|
|
297
338
|
|
|
339
|
+
# Compute the largest advanceWidth across all subset glyphs by
|
|
340
|
+
# reading the source hmtx directly. Called from subset_hhea
|
|
341
|
+
# because hhea is processed before hmtx (alphabetical order).
|
|
342
|
+
#
|
|
343
|
+
# @return [Integer] max advanceWidth, or 0 if no metrics found
|
|
344
|
+
def compute_subset_max_advance
|
|
345
|
+
hmtx = font.table("hmtx")
|
|
346
|
+
return 0 unless hmtx
|
|
347
|
+
|
|
348
|
+
unless hmtx.parsed?
|
|
349
|
+
hhea = font.table("hhea")
|
|
350
|
+
maxp = font.table("maxp")
|
|
351
|
+
hmtx.parse_with_context(hhea.number_of_h_metrics, maxp.num_glyphs)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
max_advance = 0
|
|
355
|
+
mapping.old_ids.each do |old_id|
|
|
356
|
+
metric = hmtx.metric_for(old_id)
|
|
357
|
+
next unless metric
|
|
358
|
+
|
|
359
|
+
advance = metric[:advance_width]
|
|
360
|
+
max_advance = advance if advance && advance > max_advance
|
|
361
|
+
end
|
|
362
|
+
max_advance
|
|
363
|
+
end
|
|
364
|
+
|
|
298
365
|
# Build glyf and loca tables together
|
|
299
366
|
#
|
|
300
367
|
# This method extracts glyph data for all glyphs in the mapping,
|
|
@@ -318,6 +385,17 @@ module Fontisan
|
|
|
318
385
|
@loca_offsets = []
|
|
319
386
|
current_offset = 0
|
|
320
387
|
|
|
388
|
+
# Track union bbox across all subset glyphs. Glyph binary layout:
|
|
389
|
+
# int16 numberOfContours (offset 0)
|
|
390
|
+
# int16 xMin (offset 2)
|
|
391
|
+
# int16 yMin (offset 4)
|
|
392
|
+
# int16 xMax (offset 6)
|
|
393
|
+
# int16 yMax (offset 8)
|
|
394
|
+
bbox_x_min = 1 << 30
|
|
395
|
+
bbox_y_min = 1 << 30
|
|
396
|
+
bbox_x_max = -(1 << 30)
|
|
397
|
+
bbox_y_max = -(1 << 30)
|
|
398
|
+
|
|
321
399
|
# Process glyphs in mapping order
|
|
322
400
|
mapping.old_ids.each do |old_id|
|
|
323
401
|
@loca_offsets << current_offset
|
|
@@ -334,6 +412,21 @@ module Fontisan
|
|
|
334
412
|
# Extract glyph data
|
|
335
413
|
glyph_data = glyf_table.raw_data[offset, size]
|
|
336
414
|
|
|
415
|
+
# Update bbox union. Each glyph has at least 10 bytes of
|
|
416
|
+
# header (numberOfContours + 4 int16 bbox fields).
|
|
417
|
+
if glyph_data.bytesize >= 10
|
|
418
|
+
_n, gx_min, gy_min, gx_max, gy_max = glyph_data[0, 10].unpack("n5")
|
|
419
|
+
# Treat as signed int16
|
|
420
|
+
gx_min = (gx_min ^ 0x8000) - 0x8000
|
|
421
|
+
gy_min = (gy_min ^ 0x8000) - 0x8000
|
|
422
|
+
gx_max = (gx_max ^ 0x8000) - 0x8000
|
|
423
|
+
gy_max = (gy_max ^ 0x8000) - 0x8000
|
|
424
|
+
bbox_x_min = gx_min if gx_min < bbox_x_min
|
|
425
|
+
bbox_y_min = gy_min if gy_min < bbox_y_min
|
|
426
|
+
bbox_x_max = gx_max if gx_max > bbox_x_max
|
|
427
|
+
bbox_y_max = gy_max if gy_max > bbox_y_max
|
|
428
|
+
end
|
|
429
|
+
|
|
337
430
|
# Check if compound glyph and remap components
|
|
338
431
|
if compound_glyph?(glyph_data)
|
|
339
432
|
glyph_data = remap_compound_glyph(glyph_data)
|
|
@@ -346,6 +439,11 @@ module Fontisan
|
|
|
346
439
|
|
|
347
440
|
# Add final offset
|
|
348
441
|
@loca_offsets << current_offset
|
|
442
|
+
|
|
443
|
+
# Stash union bbox if we saw at least one non-empty glyph.
|
|
444
|
+
return if bbox_x_min > bbox_x_max
|
|
445
|
+
|
|
446
|
+
@subset_bbox = [bbox_x_min, bbox_y_min, bbox_x_max, bbox_y_max]
|
|
349
447
|
end
|
|
350
448
|
|
|
351
449
|
# Check if glyph data represents a compound glyph
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Svg
|
|
5
|
+
# Renders a single UFO glyph as a standalone SVG document (a
|
|
6
|
+
# `<svg>` root with one `<path>` and a viewBox sized to the
|
|
7
|
+
# glyph's bounding box). Distinct from {FontGenerator}, which
|
|
8
|
+
# emits a `<svg><defs><font>` document covering every glyph.
|
|
9
|
+
#
|
|
10
|
+
# Used by the `fontisan ufo extract` CLI command and by
|
|
11
|
+
# downstream consumers that want per-glyph SVG exports without
|
|
12
|
+
# having to slice up a font-format SVG themselves.
|
|
13
|
+
#
|
|
14
|
+
# Path rendering supports quadratic and cubic Bezier curves
|
|
15
|
+
# (UFO's standard outline model). Curve logic is inlined here
|
|
16
|
+
# rather than reaching into GlyphGenerator's private path code
|
|
17
|
+
# — that logic is font-format-specific (Y-axis flip via
|
|
18
|
+
# ViewBoxCalculator) and doesn't cleanly factor out.
|
|
19
|
+
class StandaloneGlyph
|
|
20
|
+
# @param units_per_em [Integer] font units-per-em (default 1000)
|
|
21
|
+
# @param ascent [Integer] font ascender in font units (default 800)
|
|
22
|
+
# @param descent [Integer] font descender in font units (default -200)
|
|
23
|
+
def initialize(units_per_em: 1000, ascent: 800, descent: -200)
|
|
24
|
+
@units_per_em = units_per_em.to_i
|
|
25
|
+
@ascent = ascent.to_i
|
|
26
|
+
@descent = descent.to_i
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param glyph [Fontisan::Ufo::Glyph]
|
|
30
|
+
# @return [String] standalone SVG XML
|
|
31
|
+
def generate(glyph)
|
|
32
|
+
path_data = path_data_for(glyph)
|
|
33
|
+
view_box = view_box_for(glyph)
|
|
34
|
+
|
|
35
|
+
<<~SVG
|
|
36
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
37
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="#{view_box}">
|
|
38
|
+
<path d="#{path_data}"/>
|
|
39
|
+
</svg>
|
|
40
|
+
SVG
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def path_data_for(glyph)
|
|
46
|
+
return "" if glyph.contours.empty?
|
|
47
|
+
|
|
48
|
+
glyph.contours.filter_map { |c| contour_path(c) }.join(" ")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Emit an SVG path for one contour with Y-axis flip applied.
|
|
52
|
+
# Walks points left-to-right; collects off-curve runs and
|
|
53
|
+
# flushes them as a Bezier curve (Q for 1 control point, C
|
|
54
|
+
# for 2). Single on-curve points become line segments.
|
|
55
|
+
def contour_path(contour)
|
|
56
|
+
points = contour.points
|
|
57
|
+
return nil if points.empty?
|
|
58
|
+
|
|
59
|
+
parts = []
|
|
60
|
+
first = points.first
|
|
61
|
+
parts << "M #{first.x} #{flip_y(first.y)}"
|
|
62
|
+
|
|
63
|
+
i = 1
|
|
64
|
+
while i < points.length
|
|
65
|
+
if points[i].on_curve?
|
|
66
|
+
parts << "L #{points[i].x} #{flip_y(points[i].y)}"
|
|
67
|
+
i += 1
|
|
68
|
+
else
|
|
69
|
+
# Collect consecutive off-curve points (1 → quadratic,
|
|
70
|
+
# 2 → cubic, 3+ → rare; treat as multiple quadratics).
|
|
71
|
+
controls = []
|
|
72
|
+
while i < points.length && !points[i].on_curve?
|
|
73
|
+
controls << points[i]
|
|
74
|
+
i += 1
|
|
75
|
+
end
|
|
76
|
+
end_pt = i < points.length ? points[i] : first
|
|
77
|
+
i += 1 if i < points.length
|
|
78
|
+
|
|
79
|
+
parts << curve_segment(controls, end_pt)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
parts << "Z"
|
|
84
|
+
parts.join(" ")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def curve_segment(controls, end_pt)
|
|
88
|
+
end_x = end_pt.x
|
|
89
|
+
end_y = flip_y(end_pt.y)
|
|
90
|
+
|
|
91
|
+
case controls.size
|
|
92
|
+
when 1
|
|
93
|
+
"Q #{controls[0].x} #{flip_y(controls[0].y)} #{end_x} #{end_y}"
|
|
94
|
+
when 2
|
|
95
|
+
"C #{controls[0].x} #{flip_y(controls[0].y)} " \
|
|
96
|
+
"#{controls[1].x} #{flip_y(controls[1].y)} " \
|
|
97
|
+
"#{end_x} #{end_y}"
|
|
98
|
+
else
|
|
99
|
+
# 3+ controls: emit a Q to the midpoint of the first two,
|
|
100
|
+
# then continue. Rare in practice; degrades to Q chain.
|
|
101
|
+
mid_x = (controls[0].x + controls[1].x) / 2.0
|
|
102
|
+
mid_y = flip_y((controls[0].y + controls[1].y) / 2.0)
|
|
103
|
+
"Q #{controls[0].x} #{flip_y(controls[0].y)} #{mid_x} #{mid_y}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def flip_y(y)
|
|
108
|
+
@ascent - y.to_f
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def view_box_for(glyph)
|
|
112
|
+
bbox = bounding_box(glyph)
|
|
113
|
+
format("%<xmin>s %<ymin>s %<w>s %<h>s",
|
|
114
|
+
xmin: bbox[:x_min], ymin: bbox[:y_min],
|
|
115
|
+
w: (bbox[:x_max] - bbox[:x_min]),
|
|
116
|
+
h: (bbox[:y_max] - bbox[:y_min]))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def bounding_box(glyph)
|
|
120
|
+
points = glyph.contours.flat_map(&:points)
|
|
121
|
+
return { x_min: 0, y_min: 0, x_max: @units_per_em, y_max: @units_per_em } if points.empty?
|
|
122
|
+
|
|
123
|
+
xs = points.map(&:x)
|
|
124
|
+
ys = points.map(&:y)
|
|
125
|
+
{ x_min: xs.min, y_min: ys.min, x_max: xs.max, y_max: ys.max }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
data/lib/fontisan/svg.rb
CHANGED
|
@@ -7,6 +7,7 @@ module Fontisan
|
|
|
7
7
|
autoload :FontFaceGenerator, "fontisan/svg/font_face_generator"
|
|
8
8
|
autoload :FontGenerator, "fontisan/svg/font_generator"
|
|
9
9
|
autoload :GlyphGenerator, "fontisan/svg/glyph_generator"
|
|
10
|
+
autoload :StandaloneGlyph, "fontisan/svg/standalone_glyph"
|
|
10
11
|
autoload :ViewBoxCalculator, "fontisan/svg/view_box_calculator"
|
|
11
12
|
end
|
|
12
13
|
end
|
data/lib/fontisan/tables/head.rb
CHANGED
|
@@ -18,6 +18,16 @@ module Fontisan
|
|
|
18
18
|
# Magic number that must be present in the head table
|
|
19
19
|
MAGIC_NUMBER = 0x5F0F3CF5
|
|
20
20
|
|
|
21
|
+
# Bit flags for the `flags` field (uint16), per OpenType spec.
|
|
22
|
+
# Only bits we currently reference are named here; see the OpenType
|
|
23
|
+
# `head` table specification for the full list.
|
|
24
|
+
#
|
|
25
|
+
# Bit 11 indicates the font data has been losslessly modified
|
|
26
|
+
# (e.g. by subsetting or WOFF2 encoding). Per WOFF2 spec section 5,
|
|
27
|
+
# encoders MUST set this bit. Chrome's OpenType Sanitizer (OTS)
|
|
28
|
+
# rejects WOFF2 files where it is not set.
|
|
29
|
+
FLAG_LOSSLESS_MODIFYING = 0x0800
|
|
30
|
+
|
|
21
31
|
# Version as 16.16 fixed-point (stored as int32)
|
|
22
32
|
int32 :version_raw
|
|
23
33
|
|
data/lib/fontisan/ufo/cli.rb
CHANGED
|
@@ -14,20 +14,15 @@ module Fontisan
|
|
|
14
14
|
method_option :output, type: :string, required: true,
|
|
15
15
|
desc: "Output file path"
|
|
16
16
|
method_option :to, type: :string, default: "ttf",
|
|
17
|
-
desc: "Output format (ttf or
|
|
17
|
+
desc: "Output format (ttf, otf, or otf2)"
|
|
18
18
|
def build(ufo)
|
|
19
19
|
font = Font.open(ufo)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
case format_sym
|
|
23
|
-
when :ttf then Compile::TtfCompiler
|
|
24
|
-
when :otf then Compile::OtfCompiler
|
|
25
|
-
else
|
|
26
|
-
warn "unknown format: #{options[:to].inspect}"
|
|
27
|
-
exit 1
|
|
28
|
-
end
|
|
29
|
-
compiler.new(font).compile(output_path: options[:output])
|
|
20
|
+
format = (options[:to] || "ttf").to_s.downcase.to_sym
|
|
21
|
+
Convert.convert(font, to: format, output_path: options[:output])
|
|
30
22
|
puts "wrote #{options[:output]} (#{File.size(options[:output])} bytes)"
|
|
23
|
+
rescue ArgumentError => e
|
|
24
|
+
warn e.message
|
|
25
|
+
exit 1
|
|
31
26
|
rescue Errno::ENOENT
|
|
32
27
|
warn "UFO not found: #{ufo}"
|
|
33
28
|
exit 1
|
|
@@ -35,20 +30,12 @@ module Fontisan
|
|
|
35
30
|
|
|
36
31
|
desc "convert INPUT OUTPUT", "Convert between UFO and binary formats"
|
|
37
32
|
method_option :to, type: :string,
|
|
38
|
-
desc: "Override format detection (ttf, otf, ufo)"
|
|
33
|
+
desc: "Override format detection (ttf, otf, otf2, ufo)"
|
|
39
34
|
def convert(input, output)
|
|
40
35
|
if ufo?(input)
|
|
41
36
|
font = Font.open(input)
|
|
42
37
|
format = options[:to] || File.extname(output).delete(".").downcase
|
|
43
|
-
|
|
44
|
-
case format.to_sym
|
|
45
|
-
when :ttf then Compile::TtfCompiler
|
|
46
|
-
when :otf then Compile::OtfCompiler
|
|
47
|
-
else
|
|
48
|
-
warn "unsupported output format: #{format.inspect}"
|
|
49
|
-
exit 1
|
|
50
|
-
end
|
|
51
|
-
compiler.new(font).compile(output_path: output)
|
|
38
|
+
Convert.convert(font, to: format.to_sym, output_path: output)
|
|
52
39
|
else
|
|
53
40
|
# Binary → UFO
|
|
54
41
|
loaded = Fontisan::FontLoader.load(input)
|
|
@@ -56,6 +43,9 @@ module Fontisan
|
|
|
56
43
|
Writer.new(ufo).write(output)
|
|
57
44
|
end
|
|
58
45
|
puts "wrote #{output}"
|
|
46
|
+
rescue ArgumentError => e
|
|
47
|
+
warn e.message
|
|
48
|
+
exit 1
|
|
59
49
|
end
|
|
60
50
|
|
|
61
51
|
desc "validate UFO", "Check a UFO source for structural issues"
|
|
@@ -75,6 +65,29 @@ module Fontisan
|
|
|
75
65
|
end
|
|
76
66
|
end
|
|
77
67
|
|
|
68
|
+
desc "extract UFO GLYPH_NAME OUTPUT.svg",
|
|
69
|
+
"Render a single glyph from a UFO as a standalone SVG"
|
|
70
|
+
def extract(ufo, glyph_name, output)
|
|
71
|
+
font = Font.open(ufo)
|
|
72
|
+
glyph = font.glyph(glyph_name)
|
|
73
|
+
raise ArgumentError, "glyph not found: #{glyph_name.inspect}" unless glyph
|
|
74
|
+
|
|
75
|
+
head = font.info
|
|
76
|
+
renderer = Fontisan::Svg::StandaloneGlyph.new(
|
|
77
|
+
units_per_em: head.units_per_em || 1000,
|
|
78
|
+
ascent: head.ascender || 800,
|
|
79
|
+
descent: head.descender || -200,
|
|
80
|
+
)
|
|
81
|
+
File.write(output, renderer.generate(glyph))
|
|
82
|
+
puts "wrote #{output} (#{File.size(output)} bytes)"
|
|
83
|
+
rescue ArgumentError => e
|
|
84
|
+
warn e.message
|
|
85
|
+
exit 1
|
|
86
|
+
rescue Errno::ENOENT
|
|
87
|
+
warn "UFO not found: #{ufo}"
|
|
88
|
+
exit 1
|
|
89
|
+
end
|
|
90
|
+
|
|
78
91
|
private
|
|
79
92
|
|
|
80
93
|
def ufo?(path)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module FeatureWriters
|
|
7
|
+
# Value object returned by every feature writer's +#write+
|
|
8
|
+
# method. Carries the structured data the corresponding
|
|
9
|
+
# table builder needs to encode the binary subtable.
|
|
10
|
+
#
|
|
11
|
+
# Writers return +nil+ instead when the UFO has no data for
|
|
12
|
+
# the feature — the compiler skips +nil+ outputs.
|
|
13
|
+
FeatureOutput = Struct.new(
|
|
14
|
+
:table_tag, # "GPOS", "GSUB", "GDEF"
|
|
15
|
+
:feature_tag, # "kern", "mark", "curs", etc. (nil for GDEF)
|
|
16
|
+
:lookup_type, # Integer GSUB/GPOS lookup type
|
|
17
|
+
:data, # Hash — writer-specific structure
|
|
18
|
+
keyword_init: true,
|
|
19
|
+
) do
|
|
20
|
+
def table_tag
|
|
21
|
+
self[:table_tag]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def feature_tag
|
|
25
|
+
self[:feature_tag]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def lookup_type
|
|
29
|
+
self[:lookup_type]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def data
|
|
33
|
+
self[:data]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Abstract base class for feature writers. Concrete writers
|
|
38
|
+
# (Kern, Gdef, Mark, etc.) extend this and implement +#write+.
|
|
39
|
+
class Base
|
|
40
|
+
attr_reader :font
|
|
41
|
+
|
|
42
|
+
# @param font [Fontisan::Ufo::Font] the UFO source
|
|
43
|
+
def initialize(font)
|
|
44
|
+
@font = font
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [FeatureOutput, nil] structured data for the
|
|
48
|
+
# feature, or +nil+ if the UFO has nothing for it
|
|
49
|
+
def write
|
|
50
|
+
raise NotImplementedError, "#{self.class} must implement #write"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module FeatureWriters
|
|
7
|
+
# GPOS Cursive (lookup type 3) — cursive attachment.
|
|
8
|
+
#
|
|
9
|
+
# Pairs glyphs via their entry and exit anchors for cursive
|
|
10
|
+
# (joining) scripts. UFO convention: a glyph's +<name>entry+
|
|
11
|
+
# and +<name>exit+ anchors define where it connects to its
|
|
12
|
+
# neighbors.
|
|
13
|
+
#
|
|
14
|
+
# Pairs every glyph that has at least one +entry+ anchor with
|
|
15
|
+
# every glyph that has the corresponding +exit+ anchor.
|
|
16
|
+
# Multiple entry/exit class names are supported.
|
|
17
|
+
class Curs < Base
|
|
18
|
+
LOOKUP_TYPE = 3
|
|
19
|
+
FEATURE_TAG = "curs"
|
|
20
|
+
TABLE_TAG = "GPOS"
|
|
21
|
+
|
|
22
|
+
# @return [FeatureOutput, nil]
|
|
23
|
+
def write
|
|
24
|
+
attachments = {}
|
|
25
|
+
|
|
26
|
+
entry_glyphs = glyphs_with_anchor(/entry\z/)
|
|
27
|
+
exit_glyphs = glyphs_with_anchor(/exit\z/)
|
|
28
|
+
|
|
29
|
+
return nil if entry_glyphs.empty? && exit_glyphs.empty?
|
|
30
|
+
|
|
31
|
+
# Cursive attachment: each glyph can be both a "previous"
|
|
32
|
+
# (with exit anchor) and "next" (with entry anchor) in
|
|
33
|
+
# a cursive chain. The GPOS builder handles the
|
|
34
|
+
# cross-references; we emit every glyph with its entry
|
|
35
|
+
# and/or exit anchor positions.
|
|
36
|
+
font.glyphs.each_value do |glyph|
|
|
37
|
+
entry = find_anchor(glyph, /entry\z/)
|
|
38
|
+
exit = find_anchor(glyph, /exit\z/)
|
|
39
|
+
next unless entry || exit
|
|
40
|
+
|
|
41
|
+
attachments[glyph.name] = {
|
|
42
|
+
entry: entry && [entry.x, entry.y],
|
|
43
|
+
exit: exit && [exit.x, exit.y],
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
return nil if attachments.empty?
|
|
48
|
+
|
|
49
|
+
FeatureOutput.new(
|
|
50
|
+
table_tag: TABLE_TAG,
|
|
51
|
+
feature_tag: FEATURE_TAG,
|
|
52
|
+
lookup_type: LOOKUP_TYPE,
|
|
53
|
+
data: { attachments: attachments },
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def glyphs_with_anchor(pattern)
|
|
60
|
+
font.glyphs.select do |_name, glyph|
|
|
61
|
+
glyph.anchors.any? { |a| pattern.match?(a.name.to_s) }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def find_anchor(glyph, pattern)
|
|
66
|
+
glyph.anchors.find { |a| pattern.match?(a.name.to_s) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module FeatureWriters
|
|
7
|
+
# GDEF GlyphClassDef — classifies every glyph into one of
|
|
8
|
+
# the four OpenType glyph classes:
|
|
9
|
+
#
|
|
10
|
+
# 1 — Base glyph
|
|
11
|
+
# 2 — Ligature glyph
|
|
12
|
+
# 3 — Combining mark
|
|
13
|
+
# 4 — Glyph spacing variant (e.g. alternate space)
|
|
14
|
+
#
|
|
15
|
+
# The classification drives how GSUB/GPOS lookups apply. UFO
|
|
16
|
+
# sources carry this in the glyph's +lib+ plist under the
|
|
17
|
+
# +public.openTypeCategory+ key; this writer translates that
|
|
18
|
+
# into the structured form the GDEF builder expects.
|
|
19
|
+
class Gdef < Base
|
|
20
|
+
TABLE_TAG = "GDEF"
|
|
21
|
+
|
|
22
|
+
# Map UFO public.openTypeCategory values → OpenType glyph
|
|
23
|
+
# class integers. Glyphs whose category isn't listed get
|
|
24
|
+
# class 0 (unclassified — the GDEF ClassDef omits them).
|
|
25
|
+
CATEGORY_TO_CLASS = {
|
|
26
|
+
"base" => 1,
|
|
27
|
+
"ligature" => 2,
|
|
28
|
+
"mark" => 3,
|
|
29
|
+
"component" => 3, # UFO treats component as mark for GDEF
|
|
30
|
+
"spacing" => 4,
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# @return [FeatureOutput]
|
|
34
|
+
def write
|
|
35
|
+
classifications = {}
|
|
36
|
+
|
|
37
|
+
font.glyphs.each_value do |glyph|
|
|
38
|
+
cls = classify(glyph)
|
|
39
|
+
classifications[glyph.name] = cls if cls
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A GDEF without any classified glyphs is meaningless —
|
|
43
|
+
# return nil so the compiler skips the table.
|
|
44
|
+
return nil if classifications.empty?
|
|
45
|
+
|
|
46
|
+
FeatureOutput.new(
|
|
47
|
+
table_tag: TABLE_TAG,
|
|
48
|
+
feature_tag: nil,
|
|
49
|
+
lookup_type: nil,
|
|
50
|
+
data: { classes: classifications },
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Resolve a glyph's GDEF class. Prefer the explicit
|
|
57
|
+
# +public.openTypeCategory+ lib entry; fall back to a
|
|
58
|
+
# heuristic (glyphs with anchors named "top"/"bottom" are
|
|
59
|
+
# marks; glyphs with component references are ligatures
|
|
60
|
+
# only if their name has a +.liga+ suffix).
|
|
61
|
+
def classify(glyph)
|
|
62
|
+
category = glyph_lib_category(glyph)
|
|
63
|
+
return CATEGORY_TO_CLASS[category] if category && CATEGORY_TO_CLASS.key?(category)
|
|
64
|
+
|
|
65
|
+
heuristic_class(glyph)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def glyph_lib_category(glyph)
|
|
69
|
+
return nil unless glyph.lib
|
|
70
|
+
|
|
71
|
+
lib = glyph.lib
|
|
72
|
+
return lib["public.openTypeCategory"] if lib["public.openTypeCategory"]
|
|
73
|
+
|
|
74
|
+
categories = lib["public.openTypeCategories"]
|
|
75
|
+
categories[glyph.name] if categories.is_a?(Hash)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def heuristic_class(glyph)
|
|
79
|
+
anchor_names = glyph.anchors.map(&:name)
|
|
80
|
+
mark_anchor_pattern = /\A_[a-zA-Z]+\z/
|
|
81
|
+
return 3 if anchor_names.any? { |n| mark_anchor_pattern.match?(n.to_s) }
|
|
82
|
+
return 2 if glyph.name.to_s.end_with?(".liga")
|
|
83
|
+
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|