fontisan 0.4.22 → 0.4.24

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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +240 -24
  3. data/Gemfile +6 -0
  4. data/Rakefile +9 -6
  5. data/benchmark/compile_benchmark.rb +13 -5
  6. data/docs/.vitepress/config.ts +1 -0
  7. data/docs/STITCHER_GUIDE.adoc +47 -3
  8. data/docs/api/layer.md +89 -0
  9. data/docs/cli/subset.md +25 -6
  10. data/docs/guide/cli/subset.md +31 -0
  11. data/docs/guide/color-fonts/bitmaps.md +17 -0
  12. data/lib/fontisan/cli.rb +2 -1
  13. data/lib/fontisan/collection/writer.rb +2 -1
  14. data/lib/fontisan/commands/validate_collection_command.rb +7 -1
  15. data/lib/fontisan/commands.rb +2 -1
  16. data/lib/fontisan/config/subset_profiles.yml +2 -0
  17. data/lib/fontisan/converters/collection_converter.rb +6 -1
  18. data/lib/fontisan/converters/svg_generator.rb +3 -1
  19. data/lib/fontisan/stitcher/cbdt_propagator.rb +44 -22
  20. data/lib/fontisan/stitcher/collection_result.rb +2 -1
  21. data/lib/fontisan/stitcher/glyph_cloner.rb +41 -0
  22. data/lib/fontisan/stitcher/glyph_copier.rb +5 -34
  23. data/lib/fontisan/stitcher/partition_strategy/by_block.rb +3 -1
  24. data/lib/fontisan/stitcher/partition_strategy/by_script.rb +6 -2
  25. data/lib/fontisan/stitcher/source.rb +2 -1
  26. data/lib/fontisan/stitcher/unique_glyph_name.rb +46 -0
  27. data/lib/fontisan/stitcher.rb +4 -1
  28. data/lib/fontisan/subset/shared_state.rb +42 -0
  29. data/lib/fontisan/subset/subset_context.rb +20 -0
  30. data/lib/fontisan/subset/table_strategy/cbdt.rb +30 -0
  31. data/lib/fontisan/subset/table_strategy/cblc.rb +27 -0
  32. data/lib/fontisan/subset/table_strategy/cmap.rb +146 -0
  33. data/lib/fontisan/subset/table_strategy/color_bitmap_placement.rb +20 -0
  34. data/lib/fontisan/subset/table_strategy/color_bitmap_strike_plan.rb +45 -0
  35. data/lib/fontisan/subset/table_strategy/color_bitmap_subset_plan.rb +62 -0
  36. data/lib/fontisan/subset/table_strategy/color_bitmap_subsetter.rb +182 -0
  37. data/lib/fontisan/subset/table_strategy/color_bitmap_subtable_plan.rb +59 -0
  38. data/lib/fontisan/subset/table_strategy/glyf.rb +29 -0
  39. data/lib/fontisan/subset/table_strategy/glyf_loca_builder.rb +146 -0
  40. data/lib/fontisan/subset/table_strategy/head.rb +42 -0
  41. data/lib/fontisan/subset/table_strategy/hhea.rb +67 -0
  42. data/lib/fontisan/subset/table_strategy/hmtx.rb +52 -0
  43. data/lib/fontisan/subset/table_strategy/loca.rb +41 -0
  44. data/lib/fontisan/subset/table_strategy/maxp.rb +23 -0
  45. data/lib/fontisan/subset/table_strategy/name.rb +19 -0
  46. data/lib/fontisan/subset/table_strategy/os2.rb +21 -0
  47. data/lib/fontisan/subset/table_strategy/pass_through.rb +20 -0
  48. data/lib/fontisan/subset/table_strategy/post.rb +37 -0
  49. data/lib/fontisan/subset/table_strategy.rb +75 -0
  50. data/lib/fontisan/subset/table_subsetter.rb +49 -616
  51. data/lib/fontisan/subset.rb +3 -0
  52. data/lib/fontisan/svg/standalone_glyph.rb +4 -1
  53. data/lib/fontisan/svg_to_glyf/assembler.rb +6 -3
  54. data/lib/fontisan/svg_to_glyf/geometry/transform_parser.rb +11 -4
  55. data/lib/fontisan/svg_to_glyf/geometry.rb +4 -2
  56. data/lib/fontisan/tables/cbdt.rb +66 -121
  57. data/lib/fontisan/tables/cblc.rb +110 -231
  58. data/lib/fontisan/tables/cblc_big_glyph_metrics.rb +28 -0
  59. data/lib/fontisan/tables/cblc_bitmap_size.rb +67 -0
  60. data/lib/fontisan/tables/cblc_glyph_bitmap_location.rb +28 -0
  61. data/lib/fontisan/tables/cblc_index_subtable.rb +104 -0
  62. data/lib/fontisan/tables/cblc_index_subtable_array_entry.rb +20 -0
  63. data/lib/fontisan/tables/cblc_index_subtable_format_parser.rb +150 -0
  64. data/lib/fontisan/tables/cblc_index_subtable_header.rb +18 -0
  65. data/lib/fontisan/tables/cblc_sbit_line_metrics.rb +28 -0
  66. data/lib/fontisan/tables/cff/cff2_charstring_builder.rb +11 -5
  67. data/lib/fontisan/tables/cff.rb +2 -1
  68. data/lib/fontisan/tables/cff2/index_builder.rb +2 -1
  69. data/lib/fontisan/tables.rb +12 -0
  70. data/lib/fontisan/ufo/cli.rb +4 -1
  71. data/lib/fontisan/ufo/compile/cbdt_cblc.rb +2 -1
  72. data/lib/fontisan/ufo/compile/cff.rb +6 -2
  73. data/lib/fontisan/ufo/compile/cmap.rb +11 -5
  74. data/lib/fontisan/ufo/compile/cpal.rb +2 -1
  75. data/lib/fontisan/ufo/compile/feature_writers/gdef.rb +3 -1
  76. data/lib/fontisan/ufo/compile/feature_writers/mark_family_base.rb +3 -1
  77. data/lib/fontisan/ufo/compile/feature_writers.rb +2 -1
  78. data/lib/fontisan/ufo/compile/filters/cubic_to_quadratic.rb +4 -2
  79. data/lib/fontisan/ufo/compile/filters/remove_overlaps.rb +2 -1
  80. data/lib/fontisan/ufo/compile/fvar.rb +7 -3
  81. data/lib/fontisan/ufo/compile/glyf_loca.rb +4 -2
  82. data/lib/fontisan/ufo/compile/gvar.rb +5 -2
  83. data/lib/fontisan/ufo/compile/head.rb +2 -1
  84. data/lib/fontisan/ufo/compile/item_variation_store.rb +2 -1
  85. data/lib/fontisan/ufo/compile/name.rb +3 -1
  86. data/lib/fontisan/ufo/compile/sbix.rb +2 -1
  87. data/lib/fontisan/ufo/compile/stat.rb +6 -3
  88. data/lib/fontisan/ufo/convert/from_bin_data.rb +6 -2
  89. data/lib/fontisan/ufo/convert/to_dfont.rb +6 -2
  90. data/lib/fontisan/ufo/convert/to_otc.rb +4 -1
  91. data/lib/fontisan/ufo/convert/to_postscript.rb +8 -3
  92. data/lib/fontisan/ufo/convert/to_woff.rb +8 -3
  93. data/lib/fontisan/ufo/convert/to_woff2.rb +8 -3
  94. data/lib/fontisan/ufo/convert.rb +4 -1
  95. data/lib/fontisan/ufo/glyph_exists_error.rb +23 -0
  96. data/lib/fontisan/ufo/info.rb +4 -2
  97. data/lib/fontisan/ufo/layer.rb +42 -2
  98. data/lib/fontisan/ufo/plist.rb +7 -2
  99. data/lib/fontisan/ufo/reader.rb +3 -1
  100. data/lib/fontisan/ufo/writer.rb +13 -5
  101. data/lib/fontisan/ufo.rb +2 -1
  102. data/lib/fontisan/version.rb +1 -1
  103. data/lib/fontisan/woff2/collection_decoder.rb +4 -1
  104. data/lib/fontisan/woff2/collection_encoder.rb +4 -1
  105. data/lib/fontisan/woff2/glyf_canonicalizer.rb +2 -1
  106. data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +5 -2
  107. data/lib/fontisan/woff2/sfnt_checksum.rb +2 -1
  108. data/lib/fontisan.rb +0 -1
  109. metadata +36 -4
  110. data/lib/fontisan/tasks/fixture_downloader.rb +0 -162
  111. data/lib/fontisan/tasks.rb +0 -11
data/docs/cli/subset.md CHANGED
@@ -18,8 +18,23 @@ fontisan subset <font> [options]
18
18
  |--------|-------------|
19
19
  | `--chars TEXT` | Characters to include |
20
20
  | `--file FILE` | File containing characters |
21
+ | `--unicodes RANGE` | Unicode ranges (`"U+0000-007F"`) |
22
+ | `--glyphs LIST` | Glyph names/IDs |
21
23
  | `--output FILE` | Output file path |
22
- | `--format FORMAT` | Output format |
24
+ | `--format FORMAT` | Output format (`ttf`, `woff`, `woff2`, …) |
25
+ | `--profile PROFILE` | Subsetting profile (default: `pdf`) |
26
+ | `--retain-gids` | Preserve original glyph IDs |
27
+
28
+ ## Profiles
29
+
30
+ | Profile | Tables included | Use case |
31
+ |---------|-----------------|----------|
32
+ | `pdf` | core + glyf/loca | PDF embedding (smallest) |
33
+ | `web` | core + glyf/loca + GSUB/GPOS + **CBDT/CBLC** | Web fonts that retain color-emoji bitmaps |
34
+ | `minimal` | core only | Smallest valid font |
35
+ | `full` | every standard table | Lossless re-export |
36
+
37
+ The `web` profile is the one to reach for when the subset must still render color emoji in a browser: it preserves the CBDT (bitmap data) and CBLC (bitmap location) tables, and the paired subsetter rewrites them to keep only the bitmaps for the retained glyphs.
23
38
 
24
39
  ## Examples
25
40
 
@@ -27,13 +42,17 @@ fontisan subset <font> [options]
27
42
  # Subset to specific characters
28
43
  fontisan subset font.ttf --chars "ABCDEF" --output subset.ttf
29
44
 
30
- # Subset from file
31
- fontisan subset font.ttf --file chars.txt --output subset.ttf
45
+ # Web subset that retains color-emoji bitmaps
46
+ fontisan subset NotoColorEmoji.ttf \
47
+ --chars "😀😁😂🤣😊" \
48
+ --profile web \
49
+ --format woff2 \
50
+ --output emoji.woff2
32
51
 
33
- # Subset and convert format
34
- fontisan subset font.ttf --chars "Hello" --format woff2
52
+ # PDF embedding profile (drops CBDT/CBLC, smaller output)
53
+ fontisan subset font.ttf --chars "ABC" --profile pdf --output pdf-subset.ttf
35
54
  ```
36
55
 
37
56
  ## Detailed Documentation
38
57
 
39
- For comprehensive documentation including unicode ranges and advanced subsetting, see the [subset command guide](/guide/cli/subset).
58
+ For comprehensive documentation including unicode ranges, profile selection, and CBDT/CBLC behavior, see the [subset command guide](/guide/cli/subset).
@@ -28,6 +28,7 @@ fontisan subset FONT [options]
28
28
  | `--glyphs LIST` | Glyph names/IDs |
29
29
  | `--output PATH` | Output file |
30
30
  | `--output-format FORMAT` | Output format |
31
+ | `--profile PROFILE` | Subsetting profile (`pdf`, `web`, `minimal`, `full`; default `pdf`) |
31
32
  | `--retain-gids` | Retain glyph IDs |
32
33
 
33
34
  ## Examples
@@ -86,6 +87,36 @@ fontisan subset font.ttf --chars "ABC" --output-format woff2 --output subset.wof
86
87
 
87
88
  ## Subsetting Strategies
88
89
 
90
+ ### Profile selection
91
+
92
+ The `--profile` option controls which font tables the subset retains. The right choice depends on what the subset is for:
93
+
94
+ | Profile | When to use | Notable tables dropped |
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 |
99
+ | `full` | Lossless re-export | (none) |
100
+
101
+ ```bash
102
+ # Web subset that keeps color-emoji bitmaps so 😀 renders in color
103
+ fontisan subset NotoColorEmoji.ttf \
104
+ --chars "😀😁😂🤣😊" \
105
+ --profile web \
106
+ --output-format woff2 \
107
+ --output emoji.woff2
108
+
109
+ # PDF embedding — drops color bitmaps for size
110
+ fontisan subset NotoColorEmoji.ttf \
111
+ --chars "😀" \
112
+ --profile pdf \
113
+ --output emoji-pdf.ttf
114
+ ```
115
+
116
+ #### Web profile and CBDT/CBLC
117
+
118
+ The `web` profile is the only built-in profile that retains CBDT (Color Bitmap Data) and CBLC (Color Bitmap Location) tables. The subsetter walks CBLC to find each retained glyph's bitmap block, rewrites CBDT to keep only those blocks, and rebuilds CBLC's IndexSubTableArray to point at the new offsets. Glyph IDs are remapped through the subset's `GlyphMapping`, so the output CBLC references subset GIDs, not source GIDs.
119
+
89
120
  ### Web Font Optimization
90
121
 
91
122
  ```bash
@@ -144,6 +144,23 @@ fontisan convert bitmap-font.ttf --to ttf --output bitmap-font.ttf
144
144
  fontisan convert bitmap-font.ttf --to ttf --no-bitmaps --output outline-only.ttf
145
145
  ```
146
146
 
147
+ ### Subsetting color-emoji fonts
148
+
149
+ The `web` subsetting profile retains CBDT and CBLC tables and rewrites them to keep only the bitmaps for retained glyphs. Use it when subsetting a color-emoji source for browser delivery:
150
+
151
+ ```bash
152
+ # Keep only 😀 + 😁 bitmaps; output WOFF2 for web
153
+ fontisan subset NotoColorEmoji.ttf \
154
+ --chars "😀😁" \
155
+ --profile web \
156
+ --output-format woff2 \
157
+ --output emoji-subset.woff2
158
+ ```
159
+
160
+ Other profiles (`pdf`, `minimal`, `full`) drop CBDT/CBLC — pick them when the subset doesn't need color-emoji rendering (e.g. PDF embedding where the subsetter is just selecting glyphs for outlines).
161
+
162
+ See the [subset CLI docs](/cli/subset) and `docs/STITCHER_GUIDE.adoc` (CBDT/CBLC passthrough section) for the full story, including the GID-stability caveat when CBDT and outline donors cover overlapping codepoint ranges in stitch mode.
163
+
147
164
  ## Comparison
148
165
 
149
166
  | Feature | sbix | CBDT/CBLC |
data/lib/fontisan/cli.rb CHANGED
@@ -360,7 +360,8 @@ module Fontisan
360
360
 
361
361
  # Handle --show-options
362
362
  if options[:show_options]
363
- show_recommended_options(source_format, options[:to].is_a?(Array) ? options[:to].first : options[:to])
363
+ show_recommended_options(source_format,
364
+ options[:to].is_a?(Array) ? options[:to].first : options[:to])
364
365
  return
365
366
  end
366
367
 
@@ -95,7 +95,8 @@ module Fontisan
95
95
  #
96
96
  # @return [String] TTC header binary
97
97
  def write_ttc_header
98
- [TTC_TAG, VERSION_1_0_MAJOR, VERSION_1_0_MINOR, @fonts.size].pack("a4nnN")
98
+ [TTC_TAG, VERSION_1_0_MAJOR, VERSION_1_0_MINOR,
99
+ @fonts.size].pack("a4nnN")
99
100
  end
100
101
 
101
102
  # Write offset table
@@ -81,7 +81,13 @@ module Fontisan
81
81
  Check.new(
82
82
  name: :glyph_cap,
83
83
  passed: over.empty?,
84
- message: over.empty? ? nil : "faces over cap: #{over.map { |s| "##{s.index}=#{s.glyph_count}" }.join(', ')}",
84
+ message: if over.empty?
85
+ nil
86
+ else
87
+ "faces over cap: #{over.map do |s|
88
+ "##{s.index}=#{s.glyph_count}"
89
+ end.join(', ')}"
90
+ end,
85
91
  )
86
92
  end
87
93
 
@@ -21,7 +21,8 @@ module Fontisan
21
21
  autoload :TablesCommand, "fontisan/commands/tables_command"
22
22
  autoload :UnicodeCommand, "fontisan/commands/unicode_command"
23
23
  autoload :UnpackCommand, "fontisan/commands/unpack_command"
24
- autoload :ValidateCollectionCommand, "fontisan/commands/validate_collection_command"
24
+ autoload :ValidateCollectionCommand,
25
+ "fontisan/commands/validate_collection_command"
25
26
  autoload :ValidateCommand, "fontisan/commands/validate_command"
26
27
  autoload :VariableCommand, "fontisan/commands/variable_command"
27
28
  end
@@ -55,6 +55,8 @@ web:
55
55
  - glyf
56
56
  - GSUB
57
57
  - GPOS
58
+ - CBDT
59
+ - CBLC
58
60
 
59
61
  # Minimal Profile: Absolute minimum tables for font rendering
60
62
  minimal:
@@ -173,7 +173,12 @@ module Fontisan
173
173
  io.read(8) # signature + flavor
174
174
  io.rewind
175
175
  flavor = File.binread(path, 4, 4)&.unpack1("N")
176
- flavor == Woff2::CollectionDecoder::TTC_FLAVOR ? :woff2_collection : (raise Error, "Not a WOFF2 collection: #{path}")
176
+ if flavor == Woff2::CollectionDecoder::TTC_FLAVOR
177
+ :woff2_collection
178
+ else
179
+ (raise Error,
180
+ "Not a WOFF2 collection: #{path}")
181
+ end
177
182
  elsif Parsers::DfontParser.dfont?(io)
178
183
  :dfont
179
184
  else
@@ -192,7 +192,9 @@ palette_index: 0)
192
192
  def build_glyph_to_codepoints(cmap)
193
193
  return {} unless cmap
194
194
 
195
- cmap.unicode_mappings.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(cp, gid), h|
195
+ cmap.unicode_mappings.each_with_object(Hash.new do |h, k|
196
+ h[k] = []
197
+ end) do |(cp, gid), h|
196
198
  h[gid] << cp
197
199
  end
198
200
  rescue StandardError => e
@@ -60,6 +60,19 @@ module Fontisan
60
60
  # Outline data is intentionally absent — the bitmap lives in
61
61
  # the CBDT table that #propagate_tables_into will copy later.
62
62
  #
63
+ # == Why placeholders are name-deconflicted
64
+ #
65
+ # Placeholders are named after the CBDT source's gid ("gid{N}").
66
+ # Outline donors compiled earlier into the same target use the
67
+ # SAME naming scheme (see Source#extract_truetype_glyph), so a
68
+ # CBDT placeholder at "gid110" would collide with an outline
69
+ # glyph at "gid110" from a different donor. Layer#add raises on
70
+ # conflict (it never silently overwrites — that path previously
71
+ # erased real outline glyphs and dropped their cmap entries,
72
+ # causing the CJK Ext G loss in Essenfont-Regular.ttc). We
73
+ # allocate a unique name via UniqueGlyphName so each placeholder
74
+ # lands at its own GID slot without touching the outlines.
75
+ #
63
76
  # Glyphs are NOT registered with the deduplicator: each CBDT
64
77
  # glyph is unique to its source, and deduplication would
65
78
  # incorrectly collapse distinct bitmaps.
@@ -69,7 +82,10 @@ module Fontisan
69
82
  def add_placeholder_glyphs(source, target)
70
83
  ufo = source.font.is_a?(Ufo::Font) ? source.font : nil
71
84
  if ufo
72
- ufo.glyphs.each_value { |g| target.layers.default_layer.add(clone_glyph(g, name: g.name)) }
85
+ ufo.glyphs.each_value do |g|
86
+ name = UniqueGlyphName.in(target, g.name)
87
+ target.layers.default_layer.add(GlyphCloner.clone(g, name: name))
88
+ end
73
89
  return
74
90
  end
75
91
 
@@ -82,7 +98,8 @@ module Fontisan
82
98
  mappings.each { |cp, gid| gid_cps[gid] << cp }
83
99
 
84
100
  num_glyphs.times do |gid|
85
- name = gid.zero? ? ".notdef" : "gid#{gid}"
101
+ base_name = gid.zero? ? ".notdef" : "gid#{gid}"
102
+ name = UniqueGlyphName.in(target, base_name)
86
103
  glyph = Ufo::Glyph.new(name: name)
87
104
  glyph.width = 0
88
105
  gid_cps[gid].each { |cp| glyph.add_unicode(cp) }
@@ -100,6 +117,31 @@ module Fontisan
100
117
  #
101
118
  # No-op when called with a non-CBDT source or nil.
102
119
  #
120
+ # == Limitation: GID stability
121
+ #
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:
125
+ #
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.
144
+ #
103
145
  # @param source [Stitcher::Source, nil] the CBDT source
104
146
  # @param path [String] compiled font file to rewrite
105
147
  def propagate_tables_into(source, path)
@@ -126,26 +168,6 @@ module Fontisan
126
168
  sfnt = tables.key?("CFF ") || tables.key?("CFF2") ? 0x4F54544F : 0x00010000
127
169
  FontWriter.write_to_file(tables, path, sfnt_version: sfnt)
128
170
  end
129
-
130
- private
131
-
132
- def clone_glyph(original, name:)
133
- copy = Ufo::Glyph.new(name: name)
134
- copy.width = original.width
135
- copy.height = original.height
136
- original.contours.each { |c| copy.add_contour(clone_contour(c)) }
137
- original.components.each { |c| copy.add_component(c) }
138
- original.anchors.each { |a| copy.add_anchor(a) }
139
- original.guidelines.each { |g| copy.add_guideline(g) }
140
- copy
141
- end
142
-
143
- def clone_contour(original)
144
- points = original.points.map do |p|
145
- Ufo::Point.new(x: p.x, y: p.y, type: p.type, smooth: p.smooth)
146
- end
147
- Ufo::Contour.new(points)
148
- end
149
171
  end
150
172
  end
151
173
  end
@@ -9,7 +9,8 @@ module Fontisan
9
9
 
10
10
  # Return value of {Stitcher#write_collection}. Carries the output path,
11
11
  # total bytes, and one {SubfontStats} per declared subfont.
12
- CollectionResult = Struct.new(:path, :bytes, :subfonts, keyword_init: true) do
12
+ CollectionResult = Struct.new(:path, :bytes, :subfonts,
13
+ keyword_init: true) do
13
14
  def face_count
14
15
  subfonts.size
15
16
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ # Deep-clone helper for UFO glyphs. Stateless — every call returns
6
+ # a fresh +Ufo::Glyph+ with copied contours, components, anchors,
7
+ # and guidelines plus the original's width/height.
8
+ #
9
+ # Extracted as a collaborator so +GlyphCopier+ and +CbdtPropagator+
10
+ # share one implementation of the clone algorithm. Both previously
11
+ # defined identical +clone_glyph+ / +clone_contour+ private methods
12
+ # — a DRY violation that risked drifting apart as the glyph model
13
+ # gained fields.
14
+ module GlyphCloner
15
+ # @param original [Ufo::Glyph] glyph to copy
16
+ # @param name [String, nil] name for the copy; defaults to the
17
+ # original's name (used by callers that have already allocated
18
+ # a unique name via +UniqueGlyphName+).
19
+ # @return [Ufo::Glyph] a fresh glyph with the original's geometry
20
+ # and metrics, sharing no mutable state with it.
21
+ def self.clone(original, name: nil)
22
+ copy = Ufo::Glyph.new(name: name || original.name)
23
+ copy.width = original.width
24
+ copy.height = original.height
25
+ original.contours.each { |c| copy.add_contour(clone_contour(c)) }
26
+ original.components.each { |c| copy.add_component(c) }
27
+ original.anchors.each { |a| copy.add_anchor(a) }
28
+ original.guidelines.each { |g| copy.add_guideline(g) }
29
+ copy
30
+ end
31
+
32
+ def self.clone_contour(original)
33
+ points = original.points.map do |p|
34
+ Ufo::Point.new(x: p.x, y: p.y, type: p.type, smooth: p.smooth)
35
+ end
36
+ Ufo::Contour.new(points)
37
+ end
38
+ private_class_method :clone_contour
39
+ end
40
+ end
41
+ end
@@ -1,4 +1,4 @@
1
- # frozen_string: true
1
+ # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
4
  class Stitcher
@@ -74,7 +74,7 @@ module Fontisan
74
74
  if canonical && target.glyphs.key?(canonical)
75
75
  add_extra_unicode(target, canonical, binding[:codepoint])
76
76
  else
77
- name = unique_target_name(target, glyph.name)
77
+ name = UniqueGlyphName.in(target, glyph.name)
78
78
  copy_glyph_into(target, name: name, source: binding[:source],
79
79
  donor_gid: binding[:donor_gid],
80
80
  codepoint: binding[:codepoint])
@@ -89,11 +89,12 @@ module Fontisan
89
89
  bindings.sort_by { |b| [b[:codepoint] || Float::INFINITY, b[:donor_gid]] }
90
90
  end
91
91
 
92
- def copy_glyph_into(target_font, name:, source:, donor_gid:, codepoint: nil)
92
+ def copy_glyph_into(target_font, name:, source:, donor_gid:,
93
+ codepoint: nil)
93
94
  original = source.glyph_for_gid(donor_gid)
94
95
  return unless original
95
96
 
96
- copy = clone_glyph(original, name: name)
97
+ copy = GlyphCloner.clone(original, name: name)
97
98
  copy.add_unicode(codepoint) if codepoint
98
99
  target_font.layers.default_layer.add(copy)
99
100
  end
@@ -104,36 +105,6 @@ module Fontisan
104
105
  glyph = target_font.glyph(glyph_name)
105
106
  glyph.add_unicode(codepoint) unless glyph.unicodes.include?(codepoint)
106
107
  end
107
-
108
- def unique_target_name(target_font, base_name)
109
- return base_name unless target_font.glyphs.key?(base_name)
110
-
111
- suffix = 1
112
- loop do
113
- candidate = "#{base_name}.#{suffix}"
114
- return candidate unless target_font.glyphs.key?(candidate)
115
-
116
- suffix += 1
117
- end
118
- end
119
-
120
- def clone_glyph(original, name:)
121
- copy = Ufo::Glyph.new(name: name)
122
- copy.width = original.width
123
- copy.height = original.height
124
- original.contours.each { |c| copy.add_contour(clone_contour(c)) }
125
- original.components.each { |c| copy.add_component(c) }
126
- original.anchors.each { |a| copy.add_anchor(a) }
127
- original.guidelines.each { |g| copy.add_guideline(g) }
128
- copy
129
- end
130
-
131
- def clone_contour(original)
132
- points = original.points.map do |p|
133
- Ufo::Point.new(x: p.x, y: p.y, type: p.type, smooth: p.smooth)
134
- end
135
- Ufo::Contour.new(points)
136
- end
137
108
  end
138
109
  end
139
110
  end
@@ -403,7 +403,9 @@ module Fontisan
403
403
  private
404
404
 
405
405
  def group_by_block(cp_map)
406
- cp_map.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(cp, label), h|
406
+ cp_map.each_with_object(Hash.new do |h, k|
407
+ h[k] = []
408
+ end) do |(cp, label), h|
407
409
  block_label = find_block_label(cp) || :other
408
410
  h[block_label] << [cp, label]
409
411
  end
@@ -274,14 +274,18 @@ module Fontisan
274
274
  private
275
275
 
276
276
  def group_by_script(cp_map)
277
- cp_map.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(cp, label), h|
277
+ cp_map.each_with_object(Hash.new do |h, k|
278
+ h[k] = []
279
+ end) do |(cp, label), h|
278
280
  script = script_of_codepoint(cp)
279
281
  h[script] << [cp, label]
280
282
  end
281
283
  end
282
284
 
283
285
  def script_of_codepoint(cp)
284
- block_label = ByBlock::BLOCKS.find { |_label, range| range.cover?(cp) }&.first
286
+ block_label = ByBlock::BLOCKS.find do |_label, range|
287
+ range.cover?(cp)
288
+ end&.first
285
289
  return :other unless block_label
286
290
 
287
291
  SCRIPT_OF_BLOCK[block_label] || :other
@@ -321,7 +321,8 @@ module Fontisan
321
321
  compound.components.each do |component|
322
322
  next unless component.args_are_xy?
323
323
 
324
- raw = cache[:glyf].glyph_for(component.glyph_index, cache[:loca], cache[:head])
324
+ raw = cache[:glyf].glyph_for(component.glyph_index, cache[:loca],
325
+ cache[:head])
325
326
  next unless raw
326
327
 
327
328
  matrix = component.transformation_matrix
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ # Stateless glyph-name deconfliction for the Stitcher's target UFO.
6
+ #
7
+ # The Layer's contract (see +Ufo::Layer+) is that two glyphs cannot
8
+ # share a name — +#add+ raises on conflict. Most Stitcher call sites
9
+ # already know the name they want is free (e.g. they just synthesised
10
+ # it). The two that don't — GlyphCopier (outline donors may share
11
+ # post-table names) and CbdtPropagator (placeholders named "gid{N}"
12
+ # collide with outline glyphs sharing the same donor-gid scheme) —
13
+ # ask this helper to allocate a non-colliding name first.
14
+ #
15
+ # Centralising the rule here keeps the deconfliction algorithm in
16
+ # one place: the suffix-search loop, the separator, the order in
17
+ # which candidates are tried. Changing the rule (e.g. to a UUID
18
+ # scheme) is a one-file edit.
19
+ module UniqueGlyphName
20
+ # Separator between the base name and the disambiguating suffix.
21
+ # A literal period so the generated name ("gid110.1") still
22
+ # reads as a variant of the original.
23
+ SUFFIX_SEPARATOR = "."
24
+
25
+ # @param target [Ufo::Font, #glyphs] the target whose +#glyphs+
26
+ # hash is the namespace being deconflicted against. Accepts the
27
+ # UFO font directly so callers don't have to dig out the layer.
28
+ # @param base_name [String, #to_s] the requested name.
29
+ # @return [String] +base_name+ unchanged if no glyph with that
30
+ # name exists in +target.glyphs+; otherwise the first
31
+ # +"base.1"+, +"base.2"+, ... that does not exist.
32
+ def self.in(target, base_name)
33
+ base = base_name.to_s
34
+ return base unless target.glyphs.key?(base)
35
+
36
+ suffix = 1
37
+ loop do
38
+ candidate = "#{base}#{SUFFIX_SEPARATOR}#{suffix}"
39
+ return candidate unless target.glyphs.key?(candidate)
40
+
41
+ suffix += 1
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -35,7 +35,9 @@ module Fontisan
35
35
  autoload :FormatMetadata, "fontisan/stitcher/format_metadata"
36
36
  autoload :PartitionStrategy, "fontisan/stitcher/partition_strategy"
37
37
  autoload :CbdtPropagator, "fontisan/stitcher/cbdt_propagator"
38
+ autoload :GlyphCloner, "fontisan/stitcher/glyph_cloner"
38
39
  autoload :GlyphCopier, "fontisan/stitcher/glyph_copier"
40
+ autoload :UniqueGlyphName, "fontisan/stitcher/unique_glyph_name"
39
41
 
40
42
  # Internal: pairs a compiled loaded font with its stats so
41
43
  # +write_collection+ can build the collection and the result from a
@@ -148,7 +150,8 @@ module Fontisan
148
150
  Dir.mktmpdir do |dir|
149
151
  sub_path = File.join(dir, "sub#{subfont_name}#{metadata.extension}")
150
152
  metadata.compiler_class.new(target).compile(output_path: sub_path)
151
- cbdt_propagator.propagate_tables_into(cbdt_propagator.cbdt_source, sub_path)
153
+ cbdt_propagator.propagate_tables_into(cbdt_propagator.cbdt_source,
154
+ sub_path)
152
155
 
153
156
  loaded = Fontisan::FontLoader.load(sub_path)
154
157
  stats = SubfontStats.new(
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Subset
5
+ # Per-subsetting shared state.
6
+ #
7
+ # Several subset strategies depend on values computed by earlier
8
+ # strategies during the same subsetting run:
9
+ #
10
+ # - `subset_max_advance` is computed by the Hmtx strategy and read
11
+ # by Hhea (because hhea is processed alphabetically before hmtx).
12
+ # - `glyf_data`, `loca_offsets`, and `subset_bbox` are computed by
13
+ # the Glyf strategy and consumed by Loca and Head.
14
+ #
15
+ # Rather than giving each TableStrategy access to the entire
16
+ # TableSubsetter (which would couple them to internals), the
17
+ # cross-strategy state lives here. Each strategy receives a Context
18
+ # exposing [font], [mapping], [options], and this [SharedState].
19
+ class SharedState
20
+ # @return [String, nil] binary glyf data built by the Glyf strategy
21
+ attr_accessor :glyf_data
22
+
23
+ # @return [Array<Integer>, nil] loca offsets produced by the Glyf strategy
24
+ attr_accessor :loca_offsets
25
+
26
+ # @return [Array(Integer, Integer, Integer, Integer), nil] union
27
+ # xMin/yMin/xMax/yMax over the subset's glyphs
28
+ attr_accessor :subset_bbox
29
+
30
+ # @return [Integer] largest advanceWidth seen while subsetting hmtx
31
+ attr_accessor :subset_max_advance
32
+
33
+ # @return [TableStrategy::ColorBitmapSubsetter, nil] cached paired
34
+ # CBDT+CBLC subsetter so both strategies reuse the same pass
35
+ attr_accessor :color_bitmap_subsetter
36
+
37
+ def initialize
38
+ @subset_max_advance = 0
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Subset
5
+ # Value object handed to every TableStrategy. Encapsulates the inputs
6
+ # (font, mapping, options) plus the cross-strategy [SharedState] so
7
+ # strategies don't need a back-reference to the TableSubsetter.
8
+ #
9
+ # @!attribute font
10
+ # @return [SfntFont]
11
+ # @!attribute mapping
12
+ # @return [GlyphMapping]
13
+ # @!attribute options
14
+ # @return [Options]
15
+ # @!attribute state
16
+ # @return [SharedState]
17
+ SubsetContext = Struct.new(:font, :mapping, :options, :state,
18
+ keyword_init: true)
19
+ end
20
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Subset
5
+ module TableStrategy
6
+ # CBDT strategy: delegate to [ColorBitmapSubsetter], which produces
7
+ # both CBDT and CBLC bytes in a single pass. The Cblc strategy
8
+ # reads from the same collaborator so the two tables stay
9
+ # consistent.
10
+ class Cbdt
11
+ # @param context [SubsetContext]
12
+ # @param tag [String] "CBDT"
13
+ # @param table [Cbdt] parsed CBDT table (unused)
14
+ # @return [String] subset CBDT bytes
15
+ def self.call(context:, tag:, table:)
16
+ color_bitmap_subsetter(context).cbdt_bytes
17
+ end
18
+
19
+ # @param context [SubsetContext]
20
+ # @return [ColorBitmapSubsetter] cached on the shared state so the
21
+ # Cblc strategy reuses the same pass
22
+ def self.color_bitmap_subsetter(context)
23
+ context.state.color_bitmap_subsetter ||= ColorBitmapSubsetter.new(font: context.font,
24
+ mapping: context.mapping).build
25
+ end
26
+ private_class_method :color_bitmap_subsetter
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Subset
5
+ module TableStrategy
6
+ # CBLC strategy: delegate to [ColorBitmapSubsetter] (the same
7
+ # collaborator used by the Cbdt strategy) so both tables stay
8
+ # consistent. The subsetter is built lazily on first access and
9
+ # cached on [SharedState].
10
+ class Cblc
11
+ # @param context [SubsetContext]
12
+ # @param tag [String] "CBLC"
13
+ # @param table [Cblc] parsed CBLC table (unused)
14
+ # @return [String] subset CBLC bytes
15
+ def self.call(context:, tag:, table:)
16
+ color_bitmap_subsetter(context).cblc_bytes
17
+ end
18
+
19
+ def self.color_bitmap_subsetter(context)
20
+ context.state.color_bitmap_subsetter ||= ColorBitmapSubsetter.new(font: context.font,
21
+ mapping: context.mapping).build
22
+ end
23
+ private_class_method :color_bitmap_subsetter
24
+ end
25
+ end
26
+ end
27
+ end