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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 80a76c3d84f5bbd6f7750df7505b1d7c5c3e6f4396ebefe3238705cef8dece02
4
- data.tar.gz: 96fa6ad2df65fd9e1c4ecaa1b15f1948b05537d63b708e4fde8ce9b72809cc0b
3
+ metadata.gz: 2a84b3003bce58bcd29e1ada7dd175235d6a57f90c062e70e8c6696f818b4a4a
4
+ data.tar.gz: e9890a2151d339ec8aee34bab3da816615afa1a19aade3739fb1928e2711158e
5
5
  SHA512:
6
- metadata.gz: 8718892c5630fb4e3371e42d5cf942a1080d1dc7f54213d55ef778d897c6a46ae276739045c353e7ea76a1241709b27564b5983458ae21557d1192b86b004abc
7
- data.tar.gz: 1831e3bdc8f2715559a41f036b2e6f20a54d5e941192fa1620fad0b3331c2fa0acc7495e61c1eebfa0e3c939d22fcbd852eddbf86a22b5b12604fed378bb0b06
6
+ metadata.gz: 0ee92245d86e3b325daa81caf2657c2dcad21c796a775ceea579482a85b5ba59e751f2f993e3ab9a5ac7af12ec8d6b1b698c4b8625fb968f44d5ebfc86434a7e
7
+ data.tar.gz: 5120ad6b71b8263c33fc0d22bf9a80d61377417aa95916ca2a205801aa1788fb8be958fa36c5e0cb428fa2ac4b7f155c83181c0a251d829a29fbfd6b9779d9c2
@@ -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) blocks TODO #15
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.respond_to?(:metric_for) ? hmtx.metric_for(gid) : nil
225
- widths[gid] = metric ? metric[:advance_width] : 0
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] = 0
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
- # emitted verbatim with +group: true+; the GPOS builder is
17
- # responsible for resolving group references via the UFO's
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 (component references with optional
135
- # transformations). Out of scope for MVP emits empty bytes.
136
- def self.encode_composite(_glyph)
137
- # TODO.full/07: implement composite glyph encoding when the
138
- # spec needs it. For now, return empty (the glyph renders as
139
- # nothing).
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
- gid1 = name_to_gid[names[0]]
57
- gid2 = name_to_gid[names[1]]
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
- pairs << [gid1, gid2, value.to_i]
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: widths.max || 0,
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 _font [Fontisan::Ufo::Font]
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(_font, glyphs:)
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
- data << [glyph.width.to_i, lsb].pack("nn")
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
- # TODO.full/10: this currently emits a placeholder CFF table
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
 
@@ -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
- attr_reader :x, :y, :type, :smooth
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.29"
4
+ VERSION = "0.4.31"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontisan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.29
4
+ version: 0.4.31
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.