fontisan 0.4.26 → 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: d9d761c9308f66218312a055b752135a728413c634fba54caa309c37076a03f0
4
- data.tar.gz: 8ec9394bfde51dec443f7b854ab29d698a9cb70c63543ea4067b0fc23ffd10fc
3
+ metadata.gz: bdd148825272b5984f48a9d7e83715161054702eaf84e1c270eff42151c6bcba
4
+ data.tar.gz: d701f78f818f4b49fbcc047094eadd872e01c5dcd2372516bfaed1098bfa9bf5
5
5
  SHA512:
6
- metadata.gz: a123990197a2754d9039c7c12f9ed8432165ded5f1c8c1b10c6f150d03907ecd09bc0baf50d40e853ccbad4c340b0694718ea74759b6dacb7fe3796212e8c703
7
- data.tar.gz: c0510eee21f156b9f9d7e0110da16372a4c8418774144294efa7fcfcb7b049da72b42f6d683b1a8043e91b2f5ce66d201f3ec84173eead40d5bdab36d8d41ca2
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.
@@ -6,25 +6,26 @@ Each file is `NN-short-name.md` where `NN` is the priority order.
6
6
  ## Priorities
7
7
 
8
8
  ### P0 — Correctness gaps (open bugs / silent failures)
9
- - [01 — CBDT/CBLC GID-stable propagation](01-cbdt-cblc-gid-stable-propagation.md)
10
- - [02 — Collection-mode outline-priority regression](02-collection-outline-priority.md)
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
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
- - [x] ~~[07 — CPAL v1 header fields](07-cpal-v1-header-fields.md)~~ ✓ Done
19
+ - [x] ~~[07 — CPAL v1 header fields](07-cpal-v1-header-fields.md)~~ ✓ Done (v0.4.25)
20
+ - [x] ~~[08 — CFF standard string table](08-cff-standard-string-table.md)~~ ✓ Partial (SID 0-95, ASCII subset)
19
21
  - [06 — CFF2 blend/vsindex operators](06-cff2-blend-vsindex-operators.md)
20
- - [08 — CFF standard string table](08-cff-standard-string-table.md)
21
22
  - [09 — Type 1 seac expansion](09-type1-seac-expansion.md)
22
23
  - [10 — UFO image set + feature writers](10-ufo-image-set-feature-writers.md)
23
24
  - [11 — kern groups.plist support](11-kern-groups-plist.md)
24
25
 
25
26
  ### P3 — Code-quality cleanup
26
- - [x] ~~[13 — Split `OctokitFetcher` out of `fixture_downloader.rb`](13-split-octokit-fetcher.md)~~ ✓ Done (commit `4c68f2a`)
27
- - [12 — `cbdt_fixture.rb` full BinData conversion](12-cbdt-fixture-bindata-conversion.md)
27
+ - [x] ~~[13 — Split `OctokitFetcher` out of `fixture_downloader.rb`](13-split-octokit-fetcher.md)~~ ✓ Done (v0.4.24)
28
+ - [x] ~~[12 — `cbdt_fixture.rb` full BinData conversion](12-cbdt-fixture-bindata-conversion.md)~~ ✓ Partial (head/hhea/post converted; os2/name/hmtx/cmap remain)
28
29
  - [14 — Rubocop baseline chip (per-namespace)](14-rubocop-baseline-chip.md)
29
30
 
30
31
  ## Convention
@@ -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
@@ -80,6 +80,8 @@ module Fontisan
80
80
  # @param source [Stitcher::Source] the CBDT source
81
81
  # @param target [Ufo::Font] target UFO font to receive placeholders
82
82
  def add_placeholder_glyphs(source, target)
83
+ @placeholder_names = {}
84
+
83
85
  ufo = source.font.is_a?(Ufo::Font) ? source.font : nil
84
86
  if ufo
85
87
  ufo.glyphs.each_value do |g|
@@ -104,43 +106,28 @@ module Fontisan
104
106
  glyph.width = 0
105
107
  gid_cps[gid].each { |cp| glyph.add_unicode(cp) }
106
108
  target.layers.default_layer.add(glyph)
109
+ @placeholder_names[gid] = name
107
110
  end
108
111
  end
109
112
 
110
- # Propagate the raw CBDT/CBLC tables from the CBDT source into
111
- # the compiled font at `path`, rewriting the file in place.
113
+ # Propagate CBDT/CBLC tables from the CBDT source into the
114
+ # compiled font at `path`, rewriting the file in place.
112
115
  #
113
116
  # Reads every table from the compiled font as raw bytes (bypassing
114
117
  # BinData #table because some tables — notably CFF2 — don't yet
115
- # have round-trippable BinData models), splices in CBDT/CBLC,
116
- # and rewrites the file.
117
- #
118
- # No-op when called with a non-CBDT source or nil.
118
+ # have round-trippable BinData models), splices in CBDT/CBLC
119
+ # rebuilt with compiled-font GIDs, and rewrites the file.
119
120
  #
120
- # == Limitation: GID stability
121
+ # The CBDT/CBLC rebuild uses Subset::TableStrategy::ColorBitmapSubsetter
122
+ # to remap every bitmap block from source GID to compiled GID.
123
+ # This ensures CBLC's IndexSubTableArray references the compiled
124
+ # font's actual GIDs, not the source's.
121
125
  #
122
- # The propagation copies CBDT/CBLC bytes verbatim. CBLC indexes
123
- # glyphs by SOURCE GID the GIDs of the CBDT donor. The compiled
124
- # font's GIDs may differ because:
126
+ # Falls back to raw-byte copy when the GID mapping can't be
127
+ # resolved (no placeholder names recorded, or compiled post table
128
+ # is v3.0 with no glyph names).
125
129
  #
126
- # 1. +add_placeholder_glyphs+ renames CBDT placeholders via
127
- # UniqueGlyphName when their default "gid{N}" name collides
128
- # with outline glyphs sharing the same donor-gid scheme
129
- # (see Layer's naming contract).
130
- # 2. The compiler assigns GIDs in target-namespace order, which
131
- # is not the same as source-GID order once renaming happens.
132
- #
133
- # The bitmaps line up correctly only when the CBDT source and
134
- # every outline source cover disjoint codepoint ranges (the
135
- # Essenfont TTC case: emoji vs CJK Ext G). When ranges overlap,
136
- # CBLC's source-GID-indexed bitmaps may point at the wrong
137
- # compiled glyphs and the colour rendering for affected
138
- # codepoints will fall back to outlines.
139
- #
140
- # A proper fix requires a CBLC rebuild pass: walk the compiled
141
- # font's cmap to find the new GID for every CBDT-covered source
142
- # glyph, then rewrite CBLC's IndexSubTableArray + IndexSubTable
143
- # offsets to match. Tracked as a follow-up.
130
+ # No-op when called with a non-CBDT source or nil.
144
131
  #
145
132
  # @param source [Stitcher::Source, nil] the CBDT source
146
133
  # @param path [String] compiled font file to rewrite
@@ -149,25 +136,77 @@ module Fontisan
149
136
 
150
137
  compiled = FontLoader.load(path)
151
138
 
152
- # Read every table as raw bytes straight from the file's table
153
- # directory. We deliberately bypass #table (which parses via
154
- # BinData) because some tables — notably CFF2 — don't yet have
155
- # round-trippable BinData models; calling #table on them returns
156
- # nil and would silently drop them from the rewritten font.
157
139
  tables = {}
158
140
  compiled.table_names.each do |tag|
159
141
  raw = compiled.table_data[tag]
160
142
  tables[tag] = raw if raw
161
143
  end
162
144
 
163
- cbdt_bytes = source.raw_table_bytes("CBDT")
164
- cblc_bytes = source.raw_table_bytes("CBLC")
165
- tables["CBDT"] = cbdt_bytes if cbdt_bytes
166
- tables["CBLC"] = cblc_bytes if cblc_bytes
145
+ rebuilt = rebuild_color_tables(source, compiled)
146
+ tables["CBDT"] = rebuilt[:cbdt] if rebuilt[:cbdt]
147
+ tables["CBLC"] = rebuilt[:cblc] if rebuilt[:cblc]
167
148
 
168
149
  sfnt = tables.key?("CFF ") || tables.key?("CFF2") ? 0x4F54544F : 0x00010000
169
150
  FontWriter.write_to_file(tables, path, sfnt_version: sfnt)
170
151
  end
152
+
153
+ private
154
+
155
+ # Rebuild CBDT/CBLC bytes with compiled-font GIDs instead of
156
+ # source GIDs. Delegates to Subset::TableStrategy::ColorBitmapSubsetter
157
+ # which already implements the offset-remap algorithm for the
158
+ # subsetter — the Stitcher case is the same algorithm with a
159
+ # different GID mapping (source → compiled instead of source →
160
+ # sequential subset GID).
161
+ #
162
+ # Falls back to raw-byte copy when the GID mapping can't be
163
+ # resolved (no placeholder names recorded, or compiled post/cmap
164
+ # can't reverse-map them).
165
+ def rebuild_color_tables(source, compiled)
166
+ source_cbdt = source.raw_table_bytes("CBDT")
167
+ source_cblc = source.raw_table_bytes("CBLC")
168
+ return {} unless source_cbdt && source_cblc
169
+
170
+ gid_map = resolve_gid_mapping(compiled)
171
+ return raw_bytes_fallback(source_cbdt, source_cblc) unless gid_map
172
+
173
+ mapping = Subset::GlyphMapping.new(mapping: gid_map)
174
+ subsetter = Subset::TableStrategy::ColorBitmapSubsetter.new(
175
+ font: source.font, mapping: mapping,
176
+ ).build
177
+
178
+ { cbdt: subsetter.cbdt_bytes, cblc: subsetter.cblc_bytes }
179
+ end
180
+
181
+ # Build {source_gid => compiled_gid} from the placeholder names
182
+ # recorded during add_placeholder_glyphs. Walks the compiled
183
+ # font's post table to find each placeholder's compiled GID.
184
+ # Returns nil when the mapping can't be resolved (e.g., no
185
+ # placeholders recorded, or post v3.0 with no names).
186
+ def resolve_gid_mapping(compiled)
187
+ return nil unless @placeholder_names && !@placeholder_names.empty?
188
+
189
+ post = compiled.table("post")
190
+ return nil unless post
191
+
192
+ names = post.glyph_names
193
+ return nil unless names
194
+
195
+ name_to_gid = {}
196
+ names.each_with_index { |name, gid| name_to_gid[name] = gid }
197
+
198
+ gid_map = {}
199
+ @placeholder_names.each do |source_gid, placeholder_name|
200
+ compiled_gid = name_to_gid[placeholder_name]
201
+ gid_map[source_gid] = compiled_gid if compiled_gid
202
+ end
203
+
204
+ gid_map.empty? ? nil : gid_map
205
+ end
206
+
207
+ def raw_bytes_fallback(cbdt_bytes, cblc_bytes)
208
+ { cbdt: cbdt_bytes, cblc: cblc_bytes }
209
+ end
171
210
  end
172
211
  end
173
212
  end
@@ -52,12 +52,17 @@ module Fontisan
52
52
  #
53
53
  # @example Create mapping that retains GIDs
54
54
  # mapping = GlyphMapping.new([0, 3, 5, 10], retain_gids: true)
55
- def initialize(old_glyph_ids, retain_gids: false)
55
+ def initialize(old_glyph_ids = [], retain_gids: false, mapping: nil)
56
56
  @old_to_new = {}
57
57
  @new_to_old = {}
58
58
  @retain_gids = retain_gids
59
59
 
60
- build_mappings(old_glyph_ids)
60
+ if mapping
61
+ @old_to_new = mapping
62
+ @new_to_old = mapping.invert
63
+ else
64
+ build_mappings(old_glyph_ids)
65
+ end
61
66
  end
62
67
 
63
68
  # Get new glyph ID for an old glyph ID
@@ -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
@@ -408,30 +408,134 @@ module Fontisan
408
408
  true
409
409
  end
410
410
 
411
+ # Adobe Standard Encoding glyph names that map 1:1 to CFF SIDs
412
+ # 0..95. Source: Adobe CFF Specification 1.0 (TN 5176), Appendix A.
413
+ # These cover the printable ASCII range plus .notdef — the most
414
+ # common glyph names encountered in CFF fonts.
415
+ STANDARD_STRINGS_ASCII = [
416
+ ".notdef", # SID 0
417
+ "space", # SID 1 (code 32)
418
+ "exclam", # SID 2 (code 33)
419
+ "quotedbl", # SID 3 (code 34)
420
+ "numbersign", # SID 4 (code 35)
421
+ "dollar", # SID 5 (code 36)
422
+ "percent", # SID 6 (code 37)
423
+ "ampersand", # SID 7 (code 38)
424
+ "quoteright", # SID 8 (code 39)
425
+ "parenleft", # SID 9 (code 40)
426
+ "parenright", # SID 10 (code 41)
427
+ "asterisk", # SID 11 (code 42)
428
+ "plus", # SID 12 (code 43)
429
+ "comma", # SID 13 (code 44)
430
+ "hyphen", # SID 14 (code 45)
431
+ "period", # SID 15 (code 46)
432
+ "slash", # SID 16 (code 47)
433
+ "zero", # SID 17 (code 48)
434
+ "one", # SID 18 (code 49)
435
+ "two", # SID 19 (code 50)
436
+ "three", # SID 20 (code 51)
437
+ "four", # SID 21 (code 52)
438
+ "five", # SID 22 (code 53)
439
+ "six", # SID 23 (code 54)
440
+ "seven", # SID 24 (code 55)
441
+ "eight", # SID 25 (code 56)
442
+ "nine", # SID 26 (code 57)
443
+ "colon", # SID 27 (code 58)
444
+ "semicolon", # SID 28 (code 59)
445
+ "less", # SID 29 (code 60)
446
+ "equal", # SID 30 (code 61)
447
+ "greater", # SID 31 (code 62)
448
+ "question", # SID 32 (code 63)
449
+ "at", # SID 33 (code 64)
450
+ "A", # SID 34 (code 65)
451
+ "B", # SID 35 (code 66)
452
+ "C", # SID 36 (code 67)
453
+ "D", # SID 37 (code 68)
454
+ "E", # SID 38 (code 69)
455
+ "F", # SID 39 (code 70)
456
+ "G", # SID 40 (code 71)
457
+ "H", # SID 41 (code 72)
458
+ "I", # SID 42 (code 73)
459
+ "J", # SID 43 (code 74)
460
+ "K", # SID 44 (code 75)
461
+ "L", # SID 45 (code 76)
462
+ "M", # SID 46 (code 77)
463
+ "N", # SID 47 (code 78)
464
+ "O", # SID 48 (code 79)
465
+ "P", # SID 49 (code 80)
466
+ "Q", # SID 50 (code 81)
467
+ "R", # SID 51 (code 82)
468
+ "S", # SID 52 (code 83)
469
+ "T", # SID 53 (code 84)
470
+ "U", # SID 54 (code 85)
471
+ "V", # SID 55 (code 86)
472
+ "W", # SID 56 (code 87)
473
+ "X", # SID 57 (code 88)
474
+ "Y", # SID 58 (code 89)
475
+ "Z", # SID 59 (code 90)
476
+ "bracketleft", # SID 60 (code 91)
477
+ "backslash", # SID 61 (code 92)
478
+ "bracketright", # SID 62 (code 93)
479
+ "asciicircum", # SID 63 (code 94)
480
+ "underscore", # SID 64 (code 95)
481
+ "quoteleft", # SID 65 (code 96)
482
+ "a", # SID 66 (code 97)
483
+ "b", # SID 67 (code 98)
484
+ "c", # SID 68 (code 99)
485
+ "d", # SID 69 (code 100)
486
+ "e", # SID 70 (code 101)
487
+ "f", # SID 71 (code 102)
488
+ "g", # SID 72 (code 103)
489
+ "h", # SID 73 (code 104)
490
+ "i", # SID 74 (code 105)
491
+ "j", # SID 75 (code 106)
492
+ "k", # SID 76 (code 107)
493
+ "l", # SID 77 (code 108)
494
+ "m", # SID 78 (code 109)
495
+ "n", # SID 79 (code 110)
496
+ "o", # SID 80 (code 111)
497
+ "p", # SID 81 (code 112)
498
+ "q", # SID 82 (code 113)
499
+ "r", # SID 83 (code 114)
500
+ "s", # SID 84 (code 115)
501
+ "t", # SID 85 (code 116)
502
+ "u", # SID 86 (code 117)
503
+ "v", # SID 87 (code 118)
504
+ "w", # SID 88 (code 119)
505
+ "x", # SID 89 (code 120)
506
+ "y", # SID 90 (code 121)
507
+ "z", # SID 91 (code 122)
508
+ "braceleft", # SID 92 (code 123)
509
+ "bar", # SID 93 (code 124)
510
+ "braceright", # SID 94 (code 125)
511
+ "asciitilde", # SID 95 (code 126)
512
+ ].freeze
513
+
514
+ # First SID outside the ASCII subset. SIDs in
515
+ # EXTENDED_STRINGS_START..390 cover ligatures, currency symbols,
516
+ # Cyrillic (afii*), etc. Resolving those requires the full
517
+ # Adobe CFF spec Appendix A table; tracked as a follow-up to
518
+ # avoid embedding unverified data.
519
+ EXTENDED_STRINGS_START = STANDARD_STRINGS_ASCII.length
520
+
411
521
  private
412
522
 
413
- # Get a standard CFF string by SID
414
- #
415
- # This is a placeholder that returns a generic string.
416
- # A complete implementation would include all 391 standard strings
417
- # from CFF spec Appendix A.
523
+ # Get a standard CFF string by SID (0-390).
418
524
  #
419
- # TODO: Implement complete standard string table in follow-up task
525
+ # The ASCII subset (SID 0-95) is fully covered. Extended SIDs
526
+ # (96-390) cover ligatures, currency, Cyrillic, etc., and
527
+ # require the full Adobe CFF spec Appendix A table to fill in
528
+ # correctly. Until that follow-up lands, extended SIDs return
529
+ # nil so callers can detect the gap and fall back to the
530
+ # custom string index.
420
531
  #
421
532
  # @param sid [Integer] String ID (0-390)
422
- # @return [String] Standard string
533
+ # @return [String, nil] standard string, or nil for uncovered SIDs
423
534
  def standard_string(sid)
424
- # Placeholder implementation
425
- # Full implementation should include all standard strings
426
- # from CFF specification Appendix A
427
- case sid
428
- when 0 then ".notdef"
429
- when 1 then "space"
430
- when 2 then "exclam"
431
- # ... (388 more standard strings)
432
- else
433
- ".notdef" # Fallback
434
- end
535
+ return nil if sid.negative?
536
+ return STANDARD_STRINGS_ASCII[sid] if sid < EXTENDED_STRINGS_START
537
+
538
+ nil
435
539
  end
436
540
 
437
541
  # Get the Charset for a specific font
@@ -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.26"
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.26
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