fontisan 0.4.27 → 0.4.28

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: fa4e6e2127d519f9f74aaf89b3d94e675ac09306d6d99eff7735e4789830bd26
4
- data.tar.gz: a1f2188729497aff910dcc70e59856e3c72858e33323b4871f626d8443ff505a
3
+ metadata.gz: bdd148825272b5984f48a9d7e83715161054702eaf84e1c270eff42151c6bcba
4
+ data.tar.gz: d701f78f818f4b49fbcc047094eadd872e01c5dcd2372516bfaed1098bfa9bf5
5
5
  SHA512:
6
- metadata.gz: '09ec3032389068dd5870d82c03a89f1b318ad3b76b9b26c1f509d33419fc8728b6d8ff4f3c0e16798380312b003f79118501c9155f581f2f031c9d9893d0baba'
7
- data.tar.gz: a5a3cc828bbbd463fda1b74fa7e85b110bc83c55d058a5c484f44213ea4052001c0cf958d73c3b95ec1a3564f4e4ed1cf7901cf6002e7cde8b0ad0c43e98cc04
6
+ metadata.gz: 25d8d8fe6c8fed6067a068ac83aec0f737e627fb81775fdd2fd679a38007c72fbfae0554075f539637746d264b31eddd6125fbaeaee0c566a8b0947cb9ea15cf
7
+ data.tar.gz: 5a6b96d94af207136e925b0a8ed675688c387fbdae471fbbb6120e9703869a42142c598579d25164121a6fd57d009e4ccad760d6c2464c45fe1d711f8071cd63
@@ -0,0 +1,78 @@
1
+ # 15 — CFF/CFF2 subsetter strategy
2
+
3
+ ## Priority
4
+ P0
5
+
6
+ ## Problem
7
+
8
+ The subsetter's `TableStrategy::REGISTRY` has entries for glyf/loca (TrueType outlines) but NOT for CFF or CFF2 (PostScript outlines). Both fall through to `PassThroughStrategy`, which copies the full source table verbatim.
9
+
10
+ Two concrete failure modes:
11
+
12
+ 1. **Profiles drop CFF entirely.** The `web`, `pdf`, and `minimal` profiles list glyf/loca but not CFF/CFF2. A CFF-based OTF font subsetted to any of these profiles loses ALL outlines — the output has `cmap`/`head`/`hmtx`/`maxp` but no CFF. The font has no glyph data.
13
+
14
+ 2. **`full` profile includes CFF but doesn't subset it.** The full CFF table is copied verbatim while `maxp.numGlyphs` is updated to the subset count. The CFF CharStrings INDEX has the original glyph count; `maxp` says fewer. Invalid mismatch — fontTools and Chrome reject.
15
+
16
+ This affects every CFF-based OpenType font (the majority of professional fonts — Adobe, Google Fonts OTFs, variable CFF2 fonts).
17
+
18
+ ## Goal
19
+
20
+ Two new strategy classes:
21
+ - `Subset::TableStrategy::Cff` — subsets CFF v1 tables
22
+ - `Subset::TableStrategy::Cff2` — subsets CFF2 tables (variable-font aware)
23
+
24
+ Both registered in the REGISTRY. The `web` and `pdf` profiles updated to include CFF/CFF2 (conditionally — only when the source font has them; the profile loader already handles this via "if table present").
25
+
26
+ ## Approach
27
+
28
+ **Architecturally preferred path (TODO #05 + #10b dependency):**
29
+
30
+ If the CFF → UFO conversion (`Ufo::Convert::FromBinData#extract_cff_glyphs`, currently stubbed as TODO #10b) is fully implemented, AND the OTF compiler emits real CFF charstrings (TODO #05), then CFF subsetting is trivial:
31
+
32
+ ```
33
+ CFF font → Ufo::Convert::FromBinData → UFO (with contours)
34
+ → drop glyphs not in mapping
35
+ → Ufo::Compile::OtfCompiler → new CFF
36
+ ```
37
+
38
+ No standalone INDEX rebuilder needed. The UFO model is the canonical representation; CFF is a serialization. This is DRY, MECE, OCP-compliant.
39
+
40
+ **Fallback path (standalone CFF INDEX rebuilder):**
41
+
42
+ If TODO #05 + #10b are not yet done, a standalone strategy can work at the binary level:
43
+
44
+ 1. Parse CFF header → Name INDEX → Top DICT INDEX → String INDEX → Global Subr INDEX
45
+ 2. Decode Top DICT operators → get charset offset, CharStrings offset, Private offset
46
+ 3. Parse CharStrings INDEX → get byte range per charstring
47
+ 4. Filter to subset GIDs
48
+ 5. Rebuild CharStrings INDEX with retained charstrings
49
+ 6. Rebuild charset to match new GID ordering
50
+ 7. Optionally prune GlobalSubrs/PrivateSubrs
51
+ 8. Recompute all offsets in Top DICT
52
+ 9. Reassemble CFF bytes
53
+
54
+ This is more work than the UFO round-trip path and duplicates logic the compile pipeline already has. Prefer the UFO path.
55
+
56
+ ## Out of scope
57
+
58
+ - CFF2 blend/vsindex ENCODING (TODO 06) — that's about generating new CFF2 from scratch, not subsetting existing CFF2.
59
+ - OTF compiler real CFF (TODO 05) — that's about building CFF from UFO outlines.
60
+ - Font-level outline conversion (TTF→OTF) — that's `Converters::OutlineConverter`.
61
+
62
+ ## Effort
63
+
64
+ ~1-2 days for CFF v1 (well-understood algorithm).
65
+ ~1 additional day for CFF2 (adds FDSelect + ItemVariationStore handling).
66
+
67
+ ## Dependencies
68
+
69
+ None. The CFF/CFF2 BinData models already exist.
70
+
71
+ ## Acceptance criteria
72
+
73
+ - A CFF-based OTF font subsetted to the `web` profile produces valid output (fontTools accepts it, Chrome OTS doesn't reject).
74
+ - The subset CFF table has exactly `subset_glyph_count` charstrings (not the source's full count).
75
+ - Subroutines referenced only by dropped glyphs are pruned.
76
+ - CFF2 variable fonts retain their ItemVariationStore and variation deltas after subsetting.
77
+ - New spec covers: CFF font → web subset → fontTools decode → glyph count matches.
78
+ - Profile YAML updated: `web` and `pdf` profiles include CFF/CFF2 conditionally.
@@ -7,12 +7,13 @@ Each file is `NN-short-name.md` where `NN` is the priority order.
7
7
 
8
8
  ### P0 — Correctness gaps (open bugs / silent failures)
9
9
  - [x] ~~[01 — CBDT/CBLC GID-stable propagation](01-cbdt-cblc-gid-stable-propagation.md)~~ ✓ Done (PR #115)
10
- - [x] ~~[02 — Collection-mode outline-priority regression](02-collection-outline-priority.md)~~ ✓ Already fixed (PR #108 unskipped the test; now passing with TODO 01)
10
+ - [x] ~~[02 — Collection-mode outline-priority regression](02-collection-outline-priority.md)~~ ✓ Already fixed
11
+ - [15 — CFF/CFF2 subsetter strategy](15-cff-cff2-subsetter-strategy.md) — Head crash fixed, profiles updated; CFF→UFO extraction done (TODO #10b); blocked on TODO #05 for full round-trip (PR #108 unskipped the test; now passing with TODO 01)
11
12
 
12
13
  ### P1 — High-value features
13
14
  - [03 — `fontisan audit` command (identity+style+features lens)](03-fontisan-audit-command.md)
14
15
  - [04 — UFO composite glyph encoding](04-ufo-composite-glyph-encoding.md)
15
- - [05 — OTF compiler real CFF charstrings](05-otf-compiler-real-cff.md)
16
+ - [05 — OTF compiler real CFF charstrings](05-otf-compiler-real-cff.md) ← blocks TODO #15
16
17
 
17
18
  ### P2 — Specialist feature parity
18
19
  - [x] ~~[07 — CPAL v1 header fields](07-cpal-v1-header-fields.md)~~ ✓ Done (v0.4.25)
@@ -38,6 +38,8 @@ pdf:
38
38
  - post
39
39
  - loca
40
40
  - glyf
41
+ - CFF
42
+ - CFF2
41
43
 
42
44
  # Web Profile: Tables required for web font usage
43
45
  web:
@@ -53,6 +55,8 @@ web:
53
55
  - post
54
56
  - loca
55
57
  - glyf
58
+ - CFF
59
+ - CFF2
56
60
  - GSUB
57
61
  - GPOS
58
62
  - CBDT
@@ -28,6 +28,7 @@ module Fontisan
28
28
 
29
29
  def self.ensure_glyf_built!(context)
30
30
  return if context.state.glyf_data
31
+ return unless context.font.table_data["glyf"]
31
32
 
32
33
  builder = GlyfLocaBuilder.new(font: context.font,
33
34
  mapping: context.mapping).build
@@ -215,18 +215,78 @@ module Fontisan
215
215
  end
216
216
  end
217
217
 
218
- # OTF: extract outlines from CFF charstrings. TODO.full/10b —
219
- # for now, stub with advance widths only (no contours).
218
+ # OTF: extract outlines from CFF charstrings.
220
219
  def self.extract_cff_glyphs(font, ufo, cmap, widths, num_glyphs)
220
+ cff = font.table("CFF ")
221
+ return extract_cff_glyphs_stub(font, ufo, cmap, widths, num_glyphs) unless cff
222
+
221
223
  num_glyphs.times do |gid|
222
- glyph_name = glyph_name_for(font, gid) || "glyph#{gid}"
224
+ glyph_name = glyph_name_for(font, gid) || "gid#{gid}"
223
225
  ufo_glyph = Ufo::Glyph.new(name: glyph_name)
224
226
  ufo_glyph.width = widths.fetch(gid, 0).to_f
225
227
  cmap.fetch(gid, []).each { |cp| ufo_glyph.add_unicode(cp) }
228
+
229
+ charstring = cff.charstring_for_glyph(gid)
230
+ convert_cff_path_to_ufo(charstring.path, ufo_glyph) if charstring
231
+
226
232
  ufo.layers.default_layer.add(ufo_glyph)
227
233
  end
228
234
  end
229
235
 
236
+ # Fallback: widths only, no contours (used when CFF table can't
237
+ # be parsed — e.g., corrupted or unsupported variant).
238
+ def self.extract_cff_glyphs_stub(font, ufo, cmap, widths, num_glyphs)
239
+ num_glyphs.times do |gid|
240
+ glyph_name = glyph_name_for(font, gid) || "gid#{gid}"
241
+ ufo_glyph = Ufo::Glyph.new(name: glyph_name)
242
+ ufo_glyph.width = widths.fetch(gid, 0).to_f
243
+ cmap.fetch(gid, []).each { |cp| ufo_glyph.add_unicode(cp) }
244
+ ufo.layers.default_layer.add(ufo_glyph)
245
+ end
246
+ end
247
+
248
+ # Convert a CFF CharString path (array of command hashes) to
249
+ # UFO contours. Each :move_to starts a new contour; :line_to
250
+ # and :curve_to append points to the current contour. Cubic
251
+ # bezier curves produce 2 offcurve + 1 curve points per
252
+ # segment (GLIF convention).
253
+ def self.convert_cff_path_to_ufo(path, glyph)
254
+ return if path.empty?
255
+
256
+ current_contour = nil
257
+
258
+ path.each do |cmd|
259
+ case cmd[:type]
260
+ when :move_to
261
+ glyph.add_contour(current_contour) if current_contour
262
+ current_contour = Ufo::Contour.new
263
+ current_contour.points << Ufo::Point.new(
264
+ x: cmd[:x].to_i, y: cmd[:y].to_i, type: "move",
265
+ )
266
+ when :line_to
267
+ next unless current_contour
268
+
269
+ current_contour.points << Ufo::Point.new(
270
+ x: cmd[:x].to_i, y: cmd[:y].to_i, type: "line",
271
+ )
272
+ when :curve_to
273
+ next unless current_contour
274
+
275
+ current_contour.points << Ufo::Point.new(
276
+ x: cmd[:x1].to_i, y: cmd[:y1].to_i, type: "offcurve",
277
+ )
278
+ current_contour.points << Ufo::Point.new(
279
+ x: cmd[:x2].to_i, y: cmd[:y2].to_i, type: "offcurve",
280
+ )
281
+ current_contour.points << Ufo::Point.new(
282
+ x: cmd[:x].to_i, y: cmd[:y].to_i, type: "curve",
283
+ )
284
+ end
285
+ end
286
+
287
+ glyph.add_contour(current_contour) if current_contour
288
+ end
289
+
230
290
  # Look up a glyph name from the post table (v2.0) or synthesize.
231
291
  def self.glyph_name_for(font, gid)
232
292
  post = font.table("post")
@@ -11,7 +11,7 @@ module Fontisan
11
11
  # The class is the read/write API; serialization is handled by
12
12
  # Fontisan::Ufo::Reader and Fontisan::Ufo::Writer.
13
13
  class Font
14
- attr_accessor :path, :info, :features, :kerning, :lib, :ufo_version
14
+ attr_accessor :path, :info, :features, :kerning, :groups, :lib, :ufo_version
15
15
  attr_reader :layers, :data, :images
16
16
 
17
17
  def initialize
@@ -21,6 +21,7 @@ module Fontisan
21
21
  @layers = LayerSet.new
22
22
  @features = Features.new
23
23
  @kerning = Kerning.new
24
+ @groups = Groups.new
24
25
  @lib = Lib.new
25
26
  @data = nil # DataSet needs the Font ref, set by Reader
26
27
  @images = ImageSet.new
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ # Group definitions parsed from `groups.plist`. Maps a group name
6
+ # to a list of glyph names belonging to that group:
7
+ #
8
+ # { "MMK_L_A" => ["A", "Agrave", "Aacute", ...],
9
+ # "MMK_R_T" => ["T", "Tcommaaccent", ...] }
10
+ #
11
+ # Group pair keys in `kerning.plist` (e.g. `"MMK_L_A MMK_R_T"`)
12
+ # reference these names — kern writer + GPOS builder resolve them
13
+ # to glyph sets before emitting PairPos records.
14
+ class Groups
15
+ attr_reader :groups
16
+
17
+ def initialize(values = {})
18
+ @groups = values
19
+ end
20
+
21
+ # All group names.
22
+ def names
23
+ @groups.keys
24
+ end
25
+
26
+ # Glyph names belonging to +name+. Empty array if unknown.
27
+ def glyphs(name)
28
+ @groups[name] || []
29
+ end
30
+
31
+ # Whether +name+ is a known group.
32
+ def include?(name)
33
+ @groups.key?(name)
34
+ end
35
+
36
+ def empty?
37
+ @groups.empty?
38
+ end
39
+
40
+ def to_plist
41
+ @groups
42
+ end
43
+ end
44
+ end
45
+ end
@@ -32,6 +32,7 @@ module Fontisan
32
32
  read_layercontents
33
33
  read_glyphs_contents
34
34
  read_kerning
35
+ read_groups
35
36
  read_features
36
37
  read_lib
37
38
  @font
@@ -123,6 +124,14 @@ module Fontisan
123
124
  @font.kerning = Kerning.new(data)
124
125
  end
125
126
 
127
+ def read_groups
128
+ path = join(@font.path, "groups.plist")
129
+ return unless File.exist?(path)
130
+
131
+ data = Plist.parse(File.read(path))
132
+ @font.groups = Groups.new(data)
133
+ end
134
+
126
135
  def read_features
127
136
  path = join(@font.path, "features.fea")
128
137
  return unless File.exist?(path)
data/lib/fontisan/ufo.rb CHANGED
@@ -33,6 +33,7 @@ module Fontisan
33
33
  autoload :Kerning, "fontisan/ufo/kerning"
34
34
  autoload :Features, "fontisan/ufo/features"
35
35
  autoload :Lib, "fontisan/ufo/lib"
36
+ autoload :Groups, "fontisan/ufo/groups"
36
37
  autoload :DataSet, "fontisan/ufo/data_set"
37
38
  autoload :ImageSet, "fontisan/ufo/image_set"
38
39
  autoload :Plist, "fontisan/ufo/plist"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.27"
4
+ VERSION = "0.4.28"
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.27
4
+ version: 0.4.28
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -147,6 +147,7 @@ files:
147
147
  - TODO.improvements/12-cbdt-fixture-bindata-conversion.md
148
148
  - TODO.improvements/13-split-octokit-fetcher.md
149
149
  - TODO.improvements/14-rubocop-baseline-chip.md
150
+ - TODO.improvements/15-cff-cff2-subsetter-strategy.md
150
151
  - TODO.improvements/README.md
151
152
  - benchmark/compile_benchmark.rb
152
153
  - benchmark/variation_quick_bench.rb
@@ -704,6 +705,7 @@ files:
704
705
  - lib/fontisan/ufo/font.rb
705
706
  - lib/fontisan/ufo/glyph.rb
706
707
  - lib/fontisan/ufo/glyph_exists_error.rb
708
+ - lib/fontisan/ufo/groups.rb
707
709
  - lib/fontisan/ufo/guideline.rb
708
710
  - lib/fontisan/ufo/image.rb
709
711
  - lib/fontisan/ufo/image_set.rb