fontisan 0.4.28 → 0.4.30

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: bdd148825272b5984f48a9d7e83715161054702eaf84e1c270eff42151c6bcba
4
- data.tar.gz: d701f78f818f4b49fbcc047094eadd872e01c5dcd2372516bfaed1098bfa9bf5
3
+ metadata.gz: 5dd9aa72ac0517bcfbfed3d6eb3528af3e0b65a52bc721d14ba5fb545198e48f
4
+ data.tar.gz: be1d6d7379a3bd0cda435d02b91e5d7fc213736483e3aac66b93280d63261669
5
5
  SHA512:
6
- metadata.gz: 25d8d8fe6c8fed6067a068ac83aec0f737e627fb81775fdd2fd679a38007c72fbfae0554075f539637746d264b31eddd6125fbaeaee0c566a8b0947cb9ea15cf
7
- data.tar.gz: 5a6b96d94af207136e925b0a8ed675688c387fbdae471fbbb6120e9703869a42142c598579d25164121a6fd57d009e4ccad760d6c2464c45fe1d711f8071cd63
6
+ metadata.gz: b889fec5459a0d420ffe21d3ec792f107c69060eeddba8cf357e1a83b1e37669c3b6d017b41921b6d5be28519d51ef13ef2ab4da2d00e9ccfb9a31c204310150
7
+ data.tar.gz: 69952d13b5c18685c8b22d92b5f56c8ed2cfc89da8a5324080a9f206823d1999baf947718fbf56e77e239de422dcbf21033e9a7d060a30e5e5e5df6482727ce0
@@ -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)
@@ -93,9 +93,9 @@ The `--profile` option controls which font tables the subset retains. The right
93
93
 
94
94
  | Profile | When to use | Notable tables dropped |
95
95
  |---------|-------------|------------------------|
96
- | `pdf` (default) | PDF embedding, smallest valid output | GSUB, GPOS, CBDT, CBLC |
97
- | `web` | Browser/web font, **retains color-emoji bitmaps** | nothing that browsers need |
98
- | `minimal` | Absolute smallest core that still renders | glyf/loca, CBDT/CBLC, GSUB/GPOS |
96
+ | `pdf` (default) | PDF embedding, smallest valid output | GSUB, GPOS |
97
+ | `web` | Browser/web font, **retains color-emoji bitmaps + CFF outlines** | nothing that browsers need |
98
+ | `minimal` | Absolute smallest core that still renders | glyf/loca, CFF/CFF2, GSUB/GPOS |
99
99
  | `full` | Lossless re-export | (none) |
100
100
 
101
101
  ```bash
@@ -38,7 +38,7 @@ pdf:
38
38
  - post
39
39
  - loca
40
40
  - glyf
41
- - CFF
41
+ - "CFF "
42
42
  - CFF2
43
43
 
44
44
  # Web Profile: Tables required for web font usage
@@ -55,7 +55,7 @@ web:
55
55
  - post
56
56
  - loca
57
57
  - glyf
58
- - CFF
58
+ - "CFF "
59
59
  - CFF2
60
60
  - GSUB
61
61
  - GPOS
@@ -98,7 +98,7 @@ full:
98
98
  - GDEF
99
99
  - BASE
100
100
  - JSTF
101
- - CFF
101
+ - "CFF "
102
102
  - CFF2
103
103
  - VORG
104
104
  - kern
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Subset
5
+ module TableStrategy
6
+ # CFF (Compact Font Format) subsetter strategy.
7
+ #
8
+ # Subsets CFF tables by routing through the UFO model:
9
+ #
10
+ # source font → Ufo::Convert::FromBinData → UFO (with contours)
11
+ # → filter glyphs to mapping →
12
+ # → Ufo::Compile::Cff.build → new CFF bytes
13
+ #
14
+ # This is the architecturally preferred path: the UFO model is
15
+ # the canonical representation, CFF is a serialization. The
16
+ # strategy reuses the existing compile pipeline rather than
17
+ # reimplementing CFF INDEX arithmetic.
18
+ #
19
+ # Glyphs not in the mapping are dropped from the UFO before
20
+ # compilation. The Cff.build pipeline generates the correct
21
+ # charset (GID → SID mapping) from the retained glyph names.
22
+ #
23
+ # See TODO #15 for the full design rationale and the CFF2
24
+ # extension path.
25
+ class Cff
26
+ # @param context [SubsetContext]
27
+ # @param tag [String] "CFF "
28
+ # @param table [Object] parsed CFF table (unused — we work
29
+ # from the source font directly)
30
+ # @return [String] subset CFF table bytes
31
+ def self.call(context:, tag:, table:)
32
+ source_font = context.font
33
+ mapping = context.mapping
34
+
35
+ ufo = Ufo::Convert::FromBinData.convert(source_font)
36
+ filter_ufo_glyphs!(ufo, mapping)
37
+
38
+ # Rename the first glyph (source GID 0) to .notdef so the
39
+ # CFF compiler's charset generation produces a valid .notdef
40
+ # entry at GID 0.
41
+ first_name = ufo.glyphs.keys.first
42
+ if first_name && first_name != ".notdef"
43
+ notdef = ufo.glyphs.delete(first_name)
44
+ notdef = Ufo::Glyph.new(name: ".notdef") if notdef.nil?
45
+ ufo.glyphs[".notdef"] = notdef
46
+ end
47
+
48
+ glyphs = ufo.layers.default_layer.each.to_a
49
+ Fontisan::Ufo::Compile::Cff.build(ufo, glyphs: glyphs)
50
+ end
51
+
52
+ # Drop glyphs from the UFO that are not in the mapping's
53
+ # new-id set. Keeps .notdef (GID 0) regardless of mapping.
54
+ #
55
+ # @param ufo [Ufo::Font]
56
+ # @param mapping [GlyphMapping]
57
+ def self.filter_ufo_glyphs!(ufo, mapping)
58
+ retained_old_ids = Set.new(mapping.old_ids)
59
+
60
+ # The UFO doesn't carry GID info after conversion; we rely
61
+ # on the mapping's old_ids. Since the UFO was converted
62
+ # from the same source font, the UFO glyph order matches
63
+ # the source GID order. So GID N in the source = Nth glyph
64
+ # added to the UFO default layer.
65
+ all_names = ufo.glyphs.keys
66
+ names_to_keep = all_names.each_with_index.with_object(Set.new) do |(name, gid), keep|
67
+ keep << name if retained_old_ids.include?(gid)
68
+ end
69
+
70
+ # Always keep .notdef
71
+ names_to_keep << ".notdef" if all_names.any?(".notdef")
72
+
73
+ ufo.glyphs.select! { |name, _| names_to_keep.include?(name) }
74
+ end
75
+
76
+ # Ensure .notdef is present at GID 0. The CFF compiler
77
+ # expects it.
78
+ #
79
+ # @param ufo [Ufo::Font]
80
+ def self.ensure_notdef_present!(ufo)
81
+ return if ufo.glyphs.key?(".notdef")
82
+
83
+ notdef = Ufo::Glyph.new(name: ".notdef")
84
+ notdef.width = 0
85
+ ufo.layers.default_layer.add(notdef)
86
+ end
87
+
88
+ private_class_method :filter_ufo_glyphs!, :ensure_notdef_present!
89
+ end
90
+ end
91
+ end
92
+ end
@@ -16,6 +16,7 @@ module Fontisan
16
16
  module TableStrategy
17
17
  autoload :Cblc, "fontisan/subset/table_strategy/cblc"
18
18
  autoload :Cbdt, "fontisan/subset/table_strategy/cbdt"
19
+ autoload :Cff, "fontisan/subset/table_strategy/cff"
19
20
  autoload :Cmap, "fontisan/subset/table_strategy/cmap"
20
21
  autoload :ColorBitmapPlacement,
21
22
  "fontisan/subset/table_strategy/color_bitmap_placement"
@@ -55,6 +56,7 @@ module Fontisan
55
56
  "name" => :Name,
56
57
  "head" => :Head,
57
58
  "OS/2" => :Os2,
59
+ "CFF " => :Cff,
58
60
  "CBDT" => :Cbdt,
59
61
  "CBLC" => :Cblc,
60
62
  }.freeze
@@ -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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.28"
4
+ VERSION = "0.4.30"
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.28
4
+ version: 0.4.30
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -485,6 +485,7 @@ files:
485
485
  - lib/fontisan/subset/table_strategy.rb
486
486
  - lib/fontisan/subset/table_strategy/cbdt.rb
487
487
  - lib/fontisan/subset/table_strategy/cblc.rb
488
+ - lib/fontisan/subset/table_strategy/cff.rb
488
489
  - lib/fontisan/subset/table_strategy/cmap.rb
489
490
  - lib/fontisan/subset/table_strategy/color_bitmap_placement.rb
490
491
  - lib/fontisan/subset/table_strategy/color_bitmap_strike_plan.rb