fontisan 0.4.29 → 0.4.31
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/TODO.improvements/README.md +3 -3
- data/lib/fontisan/stitcher/source.rb +48 -3
- data/lib/fontisan/ufo/compile/feature_writers/kern.rb +2 -3
- data/lib/fontisan/ufo/compile/glyf_loca.rb +70 -11
- data/lib/fontisan/ufo/compile/gpos.rb +28 -7
- data/lib/fontisan/ufo/compile/hhea.rb +24 -3
- data/lib/fontisan/ufo/compile/hmtx.rb +14 -3
- data/lib/fontisan/ufo/compile/otf_compiler.rb +2 -5
- data/lib/fontisan/ufo/point.rb +2 -1
- data/lib/fontisan/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2a84b3003bce58bcd29e1ada7dd175235d6a57f90c062e70e8c6696f818b4a4a
|
|
4
|
+
data.tar.gz: e9890a2151d339ec8aee34bab3da816615afa1a19aade3739fb1928e2711158e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0ee92245d86e3b325daa81caf2657c2dcad21c796a775ceea579482a85b5ba59e751f2f993e3ab9a5ac7af12ec8d6b1b698c4b8625fb968f44d5ebfc86434a7e
|
|
7
|
+
data.tar.gz: 5120ad6b71b8263c33fc0d22bf9a80d61377417aa95916ca2a205801aa1788fb8be958fa36c5e0cb428fa2ac4b7f155c83181c0a251d829a29fbfd6b9779d9c2
|
data/TODO.improvements/README.md
CHANGED
|
@@ -12,8 +12,8 @@ Each file is `NN-short-name.md` where `NN` is the priority order.
|
|
|
12
12
|
|
|
13
13
|
### P1 — High-value features
|
|
14
14
|
- [03 — `fontisan audit` command (identity+style+features lens)](03-fontisan-audit-command.md)
|
|
15
|
-
- [04 — UFO composite glyph encoding](04-ufo-composite-glyph-encoding.md)
|
|
16
|
-
- [05 — OTF compiler real CFF charstrings](05-otf-compiler-real-cff.md)
|
|
15
|
+
- [x] ~~[04 — UFO composite glyph encoding](04-ufo-composite-glyph-encoding.md)~~ ✓ Done
|
|
16
|
+
- [x] ~~[05 — OTF compiler real CFF charstrings](05-otf-compiler-real-cff.md)~~ ✓ Already working (discovered in PR #117 — Cff.build produces real Type 2 charstrings; stale TODO marker removed)
|
|
17
17
|
|
|
18
18
|
### P2 — Specialist feature parity
|
|
19
19
|
- [x] ~~[07 — CPAL v1 header fields](07-cpal-v1-header-fields.md)~~ ✓ Done (v0.4.25)
|
|
@@ -21,7 +21,7 @@ Each file is `NN-short-name.md` where `NN` is the priority order.
|
|
|
21
21
|
- [06 — CFF2 blend/vsindex operators](06-cff2-blend-vsindex-operators.md)
|
|
22
22
|
- [09 — Type 1 seac expansion](09-type1-seac-expansion.md)
|
|
23
23
|
- [10 — UFO image set + feature writers](10-ufo-image-set-feature-writers.md)
|
|
24
|
-
- [11 — kern groups.plist support](11-kern-groups-plist.md)
|
|
24
|
+
- [x] ~~[11 — kern groups.plist support](11-kern-groups-plist.md)~~ ✓ Done (Groups model in PR #116 + GPOS group resolution in this PR)
|
|
25
25
|
|
|
26
26
|
### P3 — Code-quality cleanup
|
|
27
27
|
- [x] ~~[13 — Split `OctokitFetcher` out of `fixture_downloader.rb`](13-split-octokit-fetcher.md)~~ ✓ Done (v0.4.24)
|
|
@@ -213,6 +213,7 @@ module Fontisan
|
|
|
213
213
|
|
|
214
214
|
hhea = @font.table("hhea")
|
|
215
215
|
maxp = @font.table("maxp")
|
|
216
|
+
head = @font.table("head")
|
|
216
217
|
num_h_metrics = hhea&.number_of_h_metrics || 1
|
|
217
218
|
num_glyphs = maxp&.num_glyphs || 0
|
|
218
219
|
|
|
@@ -220,11 +221,22 @@ module Fontisan
|
|
|
220
221
|
hmtx.parse_with_context(num_h_metrics, num_glyphs)
|
|
221
222
|
end
|
|
222
223
|
|
|
224
|
+
# Fallback advance width when hmtx lookup fails for a GID.
|
|
225
|
+
# Per the OpenType spec, glyphs at GID >= numberOfHMetrics
|
|
226
|
+
# inherit the last LongHorMetric's advanceWidth. If the table
|
|
227
|
+
# is empty or corrupt, fall back to the font's unitsPerEm.
|
|
228
|
+
fallback_width = if hmtx.respond_to?(:h_metrics) && hmtx.h_metrics&.any?
|
|
229
|
+
hmtx.h_metrics.last[:advance_width]
|
|
230
|
+
else
|
|
231
|
+
head&.units_per_em || 1000
|
|
232
|
+
end
|
|
233
|
+
|
|
223
234
|
num_glyphs.times do |gid|
|
|
224
|
-
metric = hmtx
|
|
225
|
-
|
|
235
|
+
metric = hmtx&.metric_for(gid)
|
|
236
|
+
aw = metric ? metric[:advance_width] : nil
|
|
237
|
+
widths[gid] = aw&.positive? ? aw : fallback_width
|
|
226
238
|
rescue StandardError
|
|
227
|
-
widths[gid] =
|
|
239
|
+
widths[gid] = fallback_width
|
|
228
240
|
end
|
|
229
241
|
widths
|
|
230
242
|
end
|
|
@@ -278,12 +290,45 @@ module Fontisan
|
|
|
278
290
|
flatten_compound_into(raw, glyph, cache, Set.new)
|
|
279
291
|
end
|
|
280
292
|
|
|
293
|
+
normalize_glyph_metrics!(glyph, cache[:head])
|
|
281
294
|
add_cmap_unicodes(gid, glyph)
|
|
282
295
|
glyph
|
|
283
296
|
rescue StandardError
|
|
284
297
|
nil
|
|
285
298
|
end
|
|
286
299
|
|
|
300
|
+
# Fix glyphs whose contours extend far left of the origin
|
|
301
|
+
# (massively negative LSB) or whose advance width was lost
|
|
302
|
+
# during donor hmtx extraction. Without this, Egyptian
|
|
303
|
+
# Hieroglyphs and similar donor glyphs overflow into the
|
|
304
|
+
# preceding character cell.
|
|
305
|
+
#
|
|
306
|
+
# Shifts all x-coordinates so xMin >= 0, then ensures the
|
|
307
|
+
# advance width covers the glyph's full visual extent.
|
|
308
|
+
def normalize_glyph_metrics!(glyph, head)
|
|
309
|
+
return if glyph.contours.empty?
|
|
310
|
+
|
|
311
|
+
bbox = glyph.bbox
|
|
312
|
+
return unless bbox
|
|
313
|
+
|
|
314
|
+
upm = head&.units_per_em || 1000
|
|
315
|
+
threshold = -(upm * 0.1).to_i
|
|
316
|
+
|
|
317
|
+
# Shift contours right if the glyph extends past the origin
|
|
318
|
+
if bbox.x_min < threshold
|
|
319
|
+
shift = -bbox.x_min.to_i
|
|
320
|
+
glyph.contours.each do |contour|
|
|
321
|
+
contour.points.each { |pt| pt.x = pt.x + shift }
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Ensure advance width is positive and covers the glyph
|
|
326
|
+
visual_width = glyph.bbox&.x_max&.to_i || upm
|
|
327
|
+
if glyph.width.to_i <= 0 || glyph.width.to_i < visual_width
|
|
328
|
+
glyph.width = [visual_width, upm].max
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
287
332
|
# Copy a SimpleGlyph's contours + points into a Ufo::Glyph.
|
|
288
333
|
def copy_simple_contours(simple, ufo_glyph)
|
|
289
334
|
num_contours = simple.end_pts_of_contours&.size || 0
|
|
@@ -13,9 +13,8 @@ module Fontisan
|
|
|
13
13
|
# omits the GPOS table entirely.
|
|
14
14
|
#
|
|
15
15
|
# Group-based kerning keys (e.g. +"@MMK_L_A @MMK_R_B"+) are
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# groups.plist data (TODO: groups.plist support is partial).
|
|
16
|
+
# resolved to individual glyph pairs by the GPOS builder via
|
|
17
|
+
# the UFO's groups.plist data (font.groups).
|
|
19
18
|
class Kern < Base
|
|
20
19
|
# GPOS lookup type 2 — PairPos (pair-positioning).
|
|
21
20
|
LOOKUP_TYPE = 2
|
|
@@ -23,11 +23,14 @@ module Fontisan
|
|
|
23
23
|
# @param glyphs [Array<Fontisan::Ufo::Glyph>] in gid order
|
|
24
24
|
# @return [Hash<String, String>] {"glyf" => bytes, "loca" => bytes}
|
|
25
25
|
def self.build(_font, glyphs:)
|
|
26
|
+
name_to_gid = {}
|
|
27
|
+
glyphs.each_with_index { |g, i| name_to_gid[g.name] = i }
|
|
28
|
+
|
|
26
29
|
glyf_bytes = +""
|
|
27
30
|
offsets = [0]
|
|
28
31
|
|
|
29
32
|
glyphs.each do |glyph|
|
|
30
|
-
glyf_bytes << encode_glyph(glyph)
|
|
33
|
+
glyf_bytes << encode_glyph(glyph, name_to_gid)
|
|
31
34
|
glyf_bytes << "\x00" while glyf_bytes.bytesize.odd? # 2-byte align
|
|
32
35
|
offsets << glyf_bytes.bytesize
|
|
33
36
|
end
|
|
@@ -47,9 +50,9 @@ module Fontisan
|
|
|
47
50
|
|
|
48
51
|
# Encode a single glyph into glyf bytes. Empty glyphs (no
|
|
49
52
|
# contours, no components) produce zero bytes per spec.
|
|
50
|
-
def self.encode_glyph(glyph)
|
|
53
|
+
def self.encode_glyph(glyph, name_to_gid = {})
|
|
51
54
|
return "" if glyph.contours.empty? && glyph.components.empty?
|
|
52
|
-
return encode_composite(glyph) if glyph.composite?
|
|
55
|
+
return encode_composite(glyph, name_to_gid) if glyph.composite?
|
|
53
56
|
|
|
54
57
|
encode_simple(glyph)
|
|
55
58
|
end
|
|
@@ -131,16 +134,72 @@ module Fontisan
|
|
|
131
134
|
[flags, x_bytes, y_bytes]
|
|
132
135
|
end
|
|
133
136
|
|
|
134
|
-
# Composite glyph encoding
|
|
135
|
-
#
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
# Composite glyph encoding per OpenType spec section 5.6.
|
|
138
|
+
# Emits a compound glyph record with one component entry per
|
|
139
|
+
# UFO component reference. Each component's base glyph name is
|
|
140
|
+
# resolved to a compiled GID via the name_to_gid map.
|
|
141
|
+
#
|
|
142
|
+
# Transform encoding:
|
|
143
|
+
# - identity transform → no transform flags
|
|
144
|
+
# - uniform scale (a=d, b=c=0) → WE_HAVE_A_SCALE (1 F2Dot14)
|
|
145
|
+
# - non-uniform scale (b=c=0, a≠d) → WE_HAVE_AN_X_AND_Y_SCALE (2 F2Dot14)
|
|
146
|
+
# - full 2×2 → WE_HAVE_A_TWO_BY_TWO (4 F2Dot14)
|
|
147
|
+
def self.encode_composite(glyph, name_to_gid)
|
|
148
|
+
header = [-1, 0, 0, 0, 0].pack("s>n4")
|
|
149
|
+
|
|
150
|
+
body = +""
|
|
151
|
+
components = glyph.components
|
|
152
|
+
components.each_with_index do |comp, idx|
|
|
153
|
+
gid = name_to_gid[comp.base_glyph]
|
|
154
|
+
next unless gid
|
|
155
|
+
|
|
156
|
+
flags = 0x0002 # ARGS_ARE_XY_VALUES (always for UFO)
|
|
157
|
+
|
|
158
|
+
dx = comp.transformation ? comp.transformation.e.to_i : 0
|
|
159
|
+
dy = comp.transformation ? comp.transformation.f.to_i : 0
|
|
160
|
+
|
|
161
|
+
flags |= 0x0001 if dx < -128 || dx > 127 || dy < -128 || dy > 127
|
|
162
|
+
|
|
163
|
+
t = comp.transformation
|
|
164
|
+
if t && !t.identity?
|
|
165
|
+
flags |= if t.b.zero? && t.c.zero? && (t.a - t.d).abs < 1e-6
|
|
166
|
+
0x0008 # WE_HAVE_A_SCALE
|
|
167
|
+
elsif t.b.zero? && t.c.zero?
|
|
168
|
+
0x0040 # WE_HAVE_AN_X_AND_Y_SCALE
|
|
169
|
+
else
|
|
170
|
+
0x0080 # WE_HAVE_A_TWO_BY_TWO
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
flags |= 0x0020 if idx < components.size - 1 # MORE_COMPONENTS
|
|
175
|
+
|
|
176
|
+
body << [flags, gid].pack("n*")
|
|
177
|
+
|
|
178
|
+
body << if (flags & 0x0001).zero?
|
|
179
|
+
[dx, dy].pack("cc")
|
|
180
|
+
else
|
|
181
|
+
[dx, dy].pack("s>s>")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
if flags & 0x0008 != 0
|
|
185
|
+
body << [to_f2dot14(t.a)].pack("s>")
|
|
186
|
+
elsif flags & 0x0040 != 0
|
|
187
|
+
body << [to_f2dot14(t.a), to_f2dot14(t.d)].pack("s>s>")
|
|
188
|
+
elsif flags & 0x0080 != 0
|
|
189
|
+
body << [to_f2dot14(t.a), to_f2dot14(t.b),
|
|
190
|
+
to_f2dot14(t.c), to_f2dot14(t.d)].pack("s>4")
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
header + body
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Encode a float as F2Dot14 (2 integer + 14 fractional bits).
|
|
198
|
+
def self.to_f2dot14(value)
|
|
199
|
+
(value * 16_384).round
|
|
141
200
|
end
|
|
142
201
|
private_class_method :encode_glyph, :encode_simple, :encode_points,
|
|
143
|
-
:encode_composite
|
|
202
|
+
:encode_composite, :to_f2dot14
|
|
144
203
|
end
|
|
145
204
|
end
|
|
146
205
|
end
|
|
@@ -40,7 +40,9 @@ module Fontisan
|
|
|
40
40
|
# ---------- pair collection ----------
|
|
41
41
|
|
|
42
42
|
# Collect kerning pairs from the UFO model. Each pair is
|
|
43
|
-
# (gid1, gid2, x_advance_delta).
|
|
43
|
+
# (gid1, gid2, x_advance_delta). Resolves class-based kerning
|
|
44
|
+
# keys (e.g. "@MMK_L_A @MMK_R_T") by expanding group members
|
|
45
|
+
# into individual glyph pairs via font.groups.
|
|
44
46
|
# @return [Array<[Integer, Integer, Integer]>]
|
|
45
47
|
def self.collect_kerning_pairs(font, glyphs)
|
|
46
48
|
name_to_gid = {}
|
|
@@ -48,21 +50,40 @@ module Fontisan
|
|
|
48
50
|
|
|
49
51
|
pairs = []
|
|
50
52
|
font.kerning.each_pair do |key, value|
|
|
51
|
-
# UFO kerning key is "glyph1 glyph2" or a class name.
|
|
52
|
-
# We only handle individual glyph pairs (not classes).
|
|
53
53
|
names = key.split
|
|
54
54
|
next unless names.size == 2
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
next unless gid1 && gid2
|
|
56
|
+
left_names = resolve_kerning_side(names[0], font)
|
|
57
|
+
right_names = resolve_kerning_side(names[1], font)
|
|
59
58
|
|
|
60
|
-
|
|
59
|
+
left_names.each do |ln|
|
|
60
|
+
right_names.each do |rn|
|
|
61
|
+
gid1 = name_to_gid[ln]
|
|
62
|
+
gid2 = name_to_gid[rn]
|
|
63
|
+
next unless gid1 && gid2
|
|
64
|
+
|
|
65
|
+
pairs << [gid1, gid2, value.to_i]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
61
68
|
end
|
|
62
69
|
|
|
63
70
|
pairs.sort_by { |a| [a[0], a[1]] }
|
|
64
71
|
end
|
|
65
72
|
|
|
73
|
+
# Resolve a kerning key side to individual glyph names. If
|
|
74
|
+
# +name+ is a group reference (starts with "@"), returns all
|
|
75
|
+
# group members. Otherwise returns +[name]+ for an individual
|
|
76
|
+
# glyph.
|
|
77
|
+
# @param name [String] glyph name or group reference
|
|
78
|
+
# @param font [Fontisan::Ufo::Font]
|
|
79
|
+
# @return [Array<String>] glyph names
|
|
80
|
+
def self.resolve_kerning_side(name, font)
|
|
81
|
+
return [name] unless name.start_with?("@")
|
|
82
|
+
|
|
83
|
+
members = font.groups.glyphs(name)
|
|
84
|
+
members.empty? ? [] : members
|
|
85
|
+
end
|
|
86
|
+
|
|
66
87
|
# ---------- GPOS binary assembly ----------
|
|
67
88
|
|
|
68
89
|
def self.build_gpos_table(pairs)
|
|
@@ -14,15 +14,36 @@ module Fontisan
|
|
|
14
14
|
def self.build(font, glyphs:)
|
|
15
15
|
info = font.info
|
|
16
16
|
widths = glyphs.map { |g| g.width.to_i }
|
|
17
|
+
|
|
18
|
+
# Calculate real LSB/RSB/extent from glyph bounding boxes
|
|
19
|
+
# instead of hardcoding 0. These values help layout engines
|
|
20
|
+
# optimize text rendering and detect clipping.
|
|
21
|
+
min_lsb = nil
|
|
22
|
+
min_rsb = nil
|
|
23
|
+
max_extent = nil
|
|
24
|
+
|
|
25
|
+
glyphs.each do |glyph|
|
|
26
|
+
w = glyph.width.to_i
|
|
27
|
+
bbox = glyph.bbox
|
|
28
|
+
next unless bbox
|
|
29
|
+
|
|
30
|
+
xmin = bbox.x_min.to_i
|
|
31
|
+
xmax = bbox.x_max.to_i
|
|
32
|
+
min_lsb = xmin if min_lsb.nil? || xmin < min_lsb
|
|
33
|
+
rsb = w - xmax
|
|
34
|
+
min_rsb = rsb if min_rsb.nil? || rsb < min_rsb
|
|
35
|
+
max_extent = xmax if max_extent.nil? || xmax > max_extent
|
|
36
|
+
end
|
|
37
|
+
|
|
17
38
|
Fontisan::Tables::Hhea.new(
|
|
18
39
|
version_raw: VERSION_1_0,
|
|
19
40
|
ascent: info.ascender || 800,
|
|
20
41
|
descent: info.descender || -200,
|
|
21
42
|
line_gap: info.open_type_hhea_line_gap || 0,
|
|
22
43
|
advance_width_max: widths.max || 0,
|
|
23
|
-
min_left_side_bearing: 0,
|
|
24
|
-
min_right_side_bearing: 0,
|
|
25
|
-
x_max_extent:
|
|
44
|
+
min_left_side_bearing: min_lsb || 0,
|
|
45
|
+
min_right_side_bearing: min_rsb || 0,
|
|
46
|
+
x_max_extent: max_extent || 0,
|
|
26
47
|
caret_slope_rise: 1,
|
|
27
48
|
caret_slope_run: 0,
|
|
28
49
|
caret_offset: 0,
|
|
@@ -9,15 +9,26 @@ module Fontisan
|
|
|
9
9
|
# No trailing "leftSideBearing" array (use numberOfHMetrics = numGlyphs).
|
|
10
10
|
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/hmtx
|
|
11
11
|
module Hmtx
|
|
12
|
-
# @param
|
|
12
|
+
# @param font [Fontisan::Ufo::Font]
|
|
13
13
|
# @param glyphs [Array<Fontisan::Ufo::Glyph>] in gid order
|
|
14
14
|
# @return [String] hmtx table bytes
|
|
15
|
-
def self.build(
|
|
15
|
+
def self.build(font, glyphs:)
|
|
16
|
+
upm = font.info.units_per_em&.to_i || 1000
|
|
16
17
|
data = +""
|
|
17
18
|
glyphs.each do |glyph|
|
|
18
19
|
bbox = glyph.bbox
|
|
19
20
|
lsb = bbox ? bbox.x_min.to_i : 0
|
|
20
|
-
|
|
21
|
+
width = glyph.width.to_i
|
|
22
|
+
|
|
23
|
+
# Safety net: never emit advance_width=0 for a non-empty
|
|
24
|
+
# glyph. This happens when donor hmtx parsing fails during
|
|
25
|
+
# stitching and the width wasn't caught upstream.
|
|
26
|
+
if width <= 0
|
|
27
|
+
width = bbox ? (bbox.x_max.to_i - bbox.x_min.to_i) : upm
|
|
28
|
+
width = [width, upm].max
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
data << [width, lsb].pack("nn")
|
|
21
32
|
end
|
|
22
33
|
data
|
|
23
34
|
end
|
|
@@ -5,11 +5,8 @@ module Fontisan
|
|
|
5
5
|
module Compile
|
|
6
6
|
# UFO → OTF. Uses CFF outlines (instead of TrueType glyf/loca).
|
|
7
7
|
# Maxp version 0.5 (no TrueType metrics); sfnt version OTTO.
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# that satisfies the OTTO signature but does NOT yet encode
|
|
11
|
-
# real charstrings. Full CFF construction lands when TODO 10
|
|
12
|
-
# ships.
|
|
8
|
+
# The CFF table is built by Compile::Cff which encodes real
|
|
9
|
+
# Type 2 charstrings from UFO contours via CharStringBuilder.
|
|
13
10
|
class OtfCompiler < BaseCompiler
|
|
14
11
|
SFNT_VERSION = SFNT_VERSION_OPEN_TYPE
|
|
15
12
|
|
data/lib/fontisan/ufo/point.rb
CHANGED
|
@@ -10,7 +10,8 @@ module Fontisan
|
|
|
10
10
|
# "offcurve" is the UFO 1/2 name; UFO 3 uses "qcurve". Both are
|
|
11
11
|
# accepted on read.
|
|
12
12
|
class Point
|
|
13
|
-
|
|
13
|
+
attr_accessor :x, :y
|
|
14
|
+
attr_reader :type, :smooth
|
|
14
15
|
|
|
15
16
|
def initialize(x:, y:, type:, smooth: false)
|
|
16
17
|
@x = x
|
data/lib/fontisan/version.rb
CHANGED