fontisan 0.4.21 → 0.4.23

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +10 -5
  3. data/lib/fontisan/config/subset_profiles.yml +2 -0
  4. data/lib/fontisan/stitcher/cbdt_propagator.rb +173 -0
  5. data/lib/fontisan/stitcher/glyph_cloner.rb +41 -0
  6. data/lib/fontisan/stitcher/glyph_copier.rb +109 -0
  7. data/lib/fontisan/stitcher/unique_glyph_name.rb +46 -0
  8. data/lib/fontisan/stitcher.rb +14 -159
  9. data/lib/fontisan/subset/shared_state.rb +42 -0
  10. data/lib/fontisan/subset/subset_context.rb +20 -0
  11. data/lib/fontisan/subset/table_strategy/cbdt.rb +30 -0
  12. data/lib/fontisan/subset/table_strategy/cblc.rb +27 -0
  13. data/lib/fontisan/subset/table_strategy/cmap.rb +146 -0
  14. data/lib/fontisan/subset/table_strategy/color_bitmap_placement.rb +20 -0
  15. data/lib/fontisan/subset/table_strategy/color_bitmap_strike_plan.rb +45 -0
  16. data/lib/fontisan/subset/table_strategy/color_bitmap_subset_plan.rb +62 -0
  17. data/lib/fontisan/subset/table_strategy/color_bitmap_subsetter.rb +182 -0
  18. data/lib/fontisan/subset/table_strategy/color_bitmap_subtable_plan.rb +59 -0
  19. data/lib/fontisan/subset/table_strategy/glyf.rb +29 -0
  20. data/lib/fontisan/subset/table_strategy/glyf_loca_builder.rb +145 -0
  21. data/lib/fontisan/subset/table_strategy/head.rb +42 -0
  22. data/lib/fontisan/subset/table_strategy/hhea.rb +67 -0
  23. data/lib/fontisan/subset/table_strategy/hmtx.rb +52 -0
  24. data/lib/fontisan/subset/table_strategy/loca.rb +41 -0
  25. data/lib/fontisan/subset/table_strategy/maxp.rb +23 -0
  26. data/lib/fontisan/subset/table_strategy/name.rb +19 -0
  27. data/lib/fontisan/subset/table_strategy/os2.rb +21 -0
  28. data/lib/fontisan/subset/table_strategy/pass_through.rb +20 -0
  29. data/lib/fontisan/subset/table_strategy/post.rb +37 -0
  30. data/lib/fontisan/subset/table_strategy.rb +75 -0
  31. data/lib/fontisan/subset/table_subsetter.rb +49 -616
  32. data/lib/fontisan/subset.rb +3 -0
  33. data/lib/fontisan/tables/cbdt.rb +66 -121
  34. data/lib/fontisan/tables/cblc.rb +110 -231
  35. data/lib/fontisan/tables/cblc_big_glyph_metrics.rb +28 -0
  36. data/lib/fontisan/tables/cblc_bitmap_size.rb +67 -0
  37. data/lib/fontisan/tables/cblc_glyph_bitmap_location.rb +28 -0
  38. data/lib/fontisan/tables/cblc_index_subtable.rb +104 -0
  39. data/lib/fontisan/tables/cblc_index_subtable_array_entry.rb +20 -0
  40. data/lib/fontisan/tables/cblc_index_subtable_format_parser.rb +150 -0
  41. data/lib/fontisan/tables/cblc_index_subtable_header.rb +18 -0
  42. data/lib/fontisan/tables/cblc_sbit_line_metrics.rb +28 -0
  43. data/lib/fontisan/tables.rb +11 -0
  44. data/lib/fontisan/ufo/glyph_exists_error.rb +23 -0
  45. data/lib/fontisan/ufo/layer.rb +42 -2
  46. data/lib/fontisan/ufo.rb +2 -1
  47. data/lib/fontisan/version.rb +1 -1
  48. metadata +37 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 55632b625474e82c1cf9573ad6fce2de32f5636823701023abee87fc8817d8fc
4
- data.tar.gz: 5a26b3428874b792c101484dbed0468c25eb9f8eb7644a2c0ffff61c8f2baf6a
3
+ metadata.gz: c7e00355898ff37e38b4948f9a607bf95b96b73df17225c81ebe0b6f6b757b1d
4
+ data.tar.gz: 9009d6fb96c8168a098236d7b80355749d6c65463376119695ab2a65c20de01d
5
5
  SHA512:
6
- metadata.gz: e7334f4be856d9a86712a0215c3671c7148d623649d4b81d232aa5f70b8aee71e1e55047a2780f48a7018721026e13dc99f0fff6dcbeda49801f88a832cbf99e
7
- data.tar.gz: a6432a0a4db97bdfedd597f5f79f40be36b0fd5d450f21e43319023a85b3e331377b2ee7e002fceb9f90c2e9e9a9ee4c1489ebd25e295058e5605d5be0482f57
6
+ metadata.gz: 0c82bc6f0d6a871e84d85e4ac6bd51260a39f2b1d373b3055801354c89d99eda1ce3f78ba62ce640e5193eededeba9c8f5101146378124b5d5ac8e5c52bb60c0
7
+ data.tar.gz: 3e957b040dd2660ce7b8ddea3d4592cfef37ad8d434dd5e43d1be4417459992150fcd414f7547ead7f89a9ad8fd4ea8e01a847eb57ef352a70335e691cb386d6
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config`
3
- # on 2026-07-08 06:50:36 UTC using RuboCop version 1.86.1.
3
+ # on 2026-07-08 08:05:45 UTC using RuboCop version 1.86.1.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
@@ -12,7 +12,7 @@ Gemspec/RequiredRubyVersion:
12
12
  - 'docs/node_modules/speakingurl/speakingurl-rails.gemspec'
13
13
  - 'fontisan.gemspec'
14
14
 
15
- # Offense count: 2110
15
+ # Offense count: 2116
16
16
  # This cop supports safe autocorrection (--autocorrect).
17
17
  # Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings.
18
18
  # URISchemes: http, https
@@ -88,6 +88,11 @@ Lint/UnusedMethodArgument:
88
88
  - 'lib/fontisan/tables/cff2/private_dict_blend_handler.rb'
89
89
  - 'lib/fontisan/variation/table_accessor.rb'
90
90
  - 'lib/fontisan/woff_font.rb'
91
+ # Subset strategies share a uniform (context:, tag:, table:) interface
92
+ # so the dispatcher can call every registered class the same way. Not
93
+ # every strategy reads every arg; the interface contract justifies the
94
+ # unused warnings here.
95
+ - 'lib/fontisan/subset/table_strategy/**/*'
91
96
 
92
97
  # Offense count: 5
93
98
  Lint/UselessConstantScoping:
@@ -95,7 +100,7 @@ Lint/UselessConstantScoping:
95
100
  - 'lib/fontisan/conversion_options.rb'
96
101
  - 'lib/fontisan/type1/charstrings.rb'
97
102
 
98
- # Offense count: 704
103
+ # Offense count: 705
99
104
  # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
100
105
  Metrics/AbcSize:
101
106
  Enabled: false
@@ -221,7 +226,7 @@ RSpec/DescribeMethod:
221
226
  - 'spec/fontisan/type1/seac_expander_spec.rb'
222
227
  - 'spec/fontisan/type1_real_fonts_spec.rb'
223
228
 
224
- # Offense count: 1666
229
+ # Offense count: 1673
225
230
  # Configuration parameters: CountAsOne.
226
231
  RSpec/ExampleLength:
227
232
  Max: 66
@@ -298,7 +303,7 @@ RSpec/MultipleDescribes:
298
303
  - 'spec/fontisan/utils/thread_pool_spec.rb'
299
304
  - 'spec/fontisan/variation/cache_spec.rb'
300
305
 
301
- # Offense count: 2103
306
+ # Offense count: 2105
302
307
  RSpec/MultipleExpectations:
303
308
  Max: 19
304
309
 
@@ -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:
@@ -0,0 +1,173 @@
1
+ # frozen_string: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ # Owns every CBDT-specific concern in the stitch pipeline.
6
+ #
7
+ # The Stitcher has two distinct CBDT responsibilities that benefit
8
+ # from being in one place:
9
+ #
10
+ # 1. Detection: exactly one CBDT source per stitch is supported.
11
+ # Multiple CBDT sources raise MultipleCbdtSourcesError.
12
+ #
13
+ # 2. Action: when a CBDT source exists, the stitcher
14
+ # - injects empty CBDT placeholder glyphs into the target UFO
15
+ # (the bitmap data lives in CBDT/CBLC tables, not in outlines),
16
+ # - propagates the raw CBDT/CBLC tables into the compiled font
17
+ # file so the bitmaps survive the round-trip.
18
+ #
19
+ # Both responsibilities are model-driven from the CBDT Source itself
20
+ # (`Source#bitmap_mode == :cbdt`), so the propagator is stateless
21
+ # given a list of sources. Tests can construct one with synthetic
22
+ # sources directly.
23
+ class CbdtPropagator
24
+ # @param sources [Array<Stitcher::Source>] all sources the stitcher
25
+ # knows about, in label order (the propagator does not care
26
+ # about labels).
27
+ def initialize(sources)
28
+ @sources = sources
29
+ end
30
+
31
+ # @return [Stitcher::Source, nil] the single CBDT source, or nil
32
+ # if there is none.
33
+ # @raise [MultipleCbdtSourcesError] if more than one source is
34
+ # marked :cbdt.
35
+ def cbdt_source
36
+ cbdts = @sources.select { |s| s.bitmap_mode == :cbdt }
37
+ return nil if cbdts.empty?
38
+
39
+ if cbdts.size > 1
40
+ raise MultipleCbdtSourcesError,
41
+ "multiple CBDT sources not supported (found #{cbdts.size})"
42
+ end
43
+
44
+ cbdts.first
45
+ end
46
+
47
+ # Safe variant: returns nil instead of raising when there are
48
+ # multiple CBDT sources. Used inside the per-subfont glyph-copy
49
+ # pass where raising mid-compile would leave partial state.
50
+ #
51
+ # @return [Stitcher::Source, nil]
52
+ def safe_cbdt_source
53
+ cbdt_source
54
+ rescue MultipleCbdtSourcesError
55
+ nil
56
+ end
57
+
58
+ # Inject empty CBDT placeholder glyphs from `source` into the
59
+ # target UFO. One placeholder per gid in the source's maxp/cmap.
60
+ # Outline data is intentionally absent — the bitmap lives in
61
+ # the CBDT table that #propagate_tables_into will copy later.
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
+ #
76
+ # Glyphs are NOT registered with the deduplicator: each CBDT
77
+ # glyph is unique to its source, and deduplication would
78
+ # incorrectly collapse distinct bitmaps.
79
+ #
80
+ # @param source [Stitcher::Source] the CBDT source
81
+ # @param target [Ufo::Font] target UFO font to receive placeholders
82
+ def add_placeholder_glyphs(source, target)
83
+ ufo = source.font.is_a?(Ufo::Font) ? source.font : nil
84
+ if ufo
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
89
+ return
90
+ end
91
+
92
+ maxp = source.font.table("maxp")
93
+ num_glyphs = maxp&.num_glyphs || 0
94
+ cmap = source.font.table("cmap")
95
+ mappings = cmap&.unicode_mappings || {}
96
+
97
+ gid_cps = Hash.new { |h, k| h[k] = [] }
98
+ mappings.each { |cp, gid| gid_cps[gid] << cp }
99
+
100
+ num_glyphs.times do |gid|
101
+ base_name = gid.zero? ? ".notdef" : "gid#{gid}"
102
+ name = UniqueGlyphName.in(target, base_name)
103
+ glyph = Ufo::Glyph.new(name: name)
104
+ glyph.width = 0
105
+ gid_cps[gid].each { |cp| glyph.add_unicode(cp) }
106
+ target.layers.default_layer.add(glyph)
107
+ end
108
+ end
109
+
110
+ # Propagate the raw CBDT/CBLC tables from the CBDT source into
111
+ # the compiled font at `path`, rewriting the file in place.
112
+ #
113
+ # Reads every table from the compiled font as raw bytes (bypassing
114
+ # 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.
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
+ #
145
+ # @param source [Stitcher::Source, nil] the CBDT source
146
+ # @param path [String] compiled font file to rewrite
147
+ def propagate_tables_into(source, path)
148
+ return unless source
149
+
150
+ compiled = FontLoader.load(path)
151
+
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
+ tables = {}
158
+ compiled.table_names.each do |tag|
159
+ raw = compiled.table_data[tag]
160
+ tables[tag] = raw if raw
161
+ end
162
+
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
167
+
168
+ sfnt = tables.key?("CFF ") || tables.key?("CFF2") ? 0x4F54544F : 0x00010000
169
+ FontWriter.write_to_file(tables, path, sfnt_version: sfnt)
170
+ end
171
+ end
172
+ end
173
+ 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
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ # Owns the mechanical concerns of copying glyphs from donor sources
6
+ # into a target UFO font.
7
+ #
8
+ # The Stitcher's per-subfont build needs to:
9
+ # - inject a `.notdef` glyph at GID 0
10
+ # - copy outline glyphs from donor sources, optionally skipping
11
+ # donors the caller names (e.g. the CBDT source, whose glyphs
12
+ # take a different code path)
13
+ # - register each copied glyph with the deduplicator so visually
14
+ # identical glyphs from different donors share a gid
15
+ #
16
+ # GlyphCopier centralizes that logic in one stateless-per-call
17
+ # collaborator. The Stitcher constructs one with a deduplicator and
18
+ # calls its methods in the order the cmap contract requires
19
+ # (see Fontisan::Ufo::Compile::Cmap for first-wins semantics).
20
+ class GlyphCopier
21
+ # @param deduplicator [Stitcher::Deduplicator, nil] when non-nil,
22
+ # visually identical glyphs share a gid in the target. Pass nil
23
+ # to disable deduplication.
24
+ def initialize(deduplicator)
25
+ @deduplicator = deduplicator
26
+ end
27
+
28
+ # Inject a `.notdef` glyph at GID 0 of `target`.
29
+ #
30
+ # If a binding exists with `donor_gid == 0`, copy the donor's
31
+ # .notdef; otherwise synthesize an empty one. The injected glyph
32
+ # is registered with the deduplicator so subsequent .notdef
33
+ # references deduplicate against it.
34
+ #
35
+ # @param bindings [Array<Hash>] stitch bindings to search
36
+ # @param target [Ufo::Font] target UFO
37
+ def inject_notdef(bindings, target)
38
+ notdef_binding = bindings.find { |b| b[:donor_gid].zero? }
39
+ if notdef_binding
40
+ copy_glyph_into(target, name: ".notdef",
41
+ source: notdef_binding[:source],
42
+ donor_gid: 0)
43
+ else
44
+ target.layers.default_layer.add(Ufo::Glyph.new(name: ".notdef"))
45
+ end
46
+ dedup_target = target.glyphs[".notdef"]
47
+ @deduplicator&.register(dedup_target, ".notdef") if dedup_target
48
+ end
49
+
50
+ # Copy outline glyphs from `bindings` into `target`, skipping any
51
+ # sources the caller names (e.g. the CBDT source).
52
+ #
53
+ # Bindings are sorted by `[codepoint, donor_gid]` for deterministic
54
+ # output. For each binding:
55
+ # - if deduplicator has seen a visually identical glyph that's
56
+ # already in the target, attach the codepoint to that glyph
57
+ # (extra unicode mapping);
58
+ # - otherwise copy the glyph under a unique name and register
59
+ # with the deduplicator.
60
+ #
61
+ # @param bindings [Array<Hash>] stitch bindings
62
+ # @param target [Ufo::Font] target UFO
63
+ # @param skip_sources [Array<Stitcher::Source>] sources whose
64
+ # bindings should be skipped (e.g. CBDT placeholder source)
65
+ def copy_outlines(bindings, target, skip_sources: [])
66
+ sorted_bindings(bindings).each do |binding|
67
+ next if binding[:donor_gid].zero?
68
+ next if skip_sources.any? { |s| s.equal?(binding[:source]) }
69
+
70
+ glyph = binding[:source].glyph_for_gid(binding[:donor_gid])
71
+ next unless glyph
72
+
73
+ canonical = @deduplicator&.find(glyph)
74
+ if canonical && target.glyphs.key?(canonical)
75
+ add_extra_unicode(target, canonical, binding[:codepoint])
76
+ else
77
+ name = UniqueGlyphName.in(target, glyph.name)
78
+ copy_glyph_into(target, name: name, source: binding[:source],
79
+ donor_gid: binding[:donor_gid],
80
+ codepoint: binding[:codepoint])
81
+ @deduplicator&.register(glyph, name)
82
+ end
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def sorted_bindings(bindings)
89
+ bindings.sort_by { |b| [b[:codepoint] || Float::INFINITY, b[:donor_gid]] }
90
+ end
91
+
92
+ def copy_glyph_into(target_font, name:, source:, donor_gid:, codepoint: nil)
93
+ original = source.glyph_for_gid(donor_gid)
94
+ return unless original
95
+
96
+ copy = GlyphCloner.clone(original, name: name)
97
+ copy.add_unicode(codepoint) if codepoint
98
+ target_font.layers.default_layer.add(copy)
99
+ end
100
+
101
+ def add_extra_unicode(target_font, glyph_name, codepoint)
102
+ return unless codepoint
103
+
104
+ glyph = target_font.glyph(glyph_name)
105
+ glyph.add_unicode(codepoint) unless glyph.unicodes.include?(codepoint)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -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
@@ -34,6 +34,10 @@ module Fontisan
34
34
  autoload :SubfontStats, "fontisan/stitcher/collection_result"
35
35
  autoload :FormatMetadata, "fontisan/stitcher/format_metadata"
36
36
  autoload :PartitionStrategy, "fontisan/stitcher/partition_strategy"
37
+ autoload :CbdtPropagator, "fontisan/stitcher/cbdt_propagator"
38
+ autoload :GlyphCloner, "fontisan/stitcher/glyph_cloner"
39
+ autoload :GlyphCopier, "fontisan/stitcher/glyph_copier"
40
+ autoload :UniqueGlyphName, "fontisan/stitcher/unique_glyph_name"
37
41
 
38
42
  # Internal: pairs a compiled loaded font with its stats so
39
43
  # +write_collection+ can build the collection and the result from a
@@ -98,7 +102,7 @@ module Fontisan
98
102
  metadata = FormatMetadata.resolve(format)
99
103
  metadata.compiler_class.new(target).compile(output_path: path)
100
104
 
101
- propagate_cbdt_tables(path) if cbdt_source
105
+ cbdt_propagator.propagate_tables_into(cbdt_propagator.cbdt_source, path)
102
106
  path
103
107
  end
104
108
 
@@ -129,25 +133,6 @@ module Fontisan
129
133
  end
130
134
  end
131
135
 
132
- def cbdt_source
133
- cbdts = @sources.values.select { |s| s.bitmap_mode == :cbdt }
134
- if cbdts.size > 1
135
- raise MultipleCbdtSourcesError,
136
- "multiple CBDT sources not supported (found #{cbdts.size})"
137
- end
138
-
139
- cbdts.first
140
- end
141
-
142
- # Safe variant of #cbdt_source: returns nil instead of raising when
143
- # the project has multiple CBDT sources. Used inside the per-subfont
144
- # glyph-copy pass where raising mid-compile would leave partial state.
145
- def safe_cbdt_source
146
- cbdt_source
147
- rescue MultipleCbdtSourcesError
148
- nil
149
- end
150
-
151
136
  def build_target_for(subfont_name)
152
137
  bindings = @subfonts[subfont_name] || []
153
138
  target = Ufo::Font.new
@@ -165,7 +150,7 @@ module Fontisan
165
150
  Dir.mktmpdir do |dir|
166
151
  sub_path = File.join(dir, "sub#{subfont_name}#{metadata.extension}")
167
152
  metadata.compiler_class.new(target).compile(output_path: sub_path)
168
- propagate_cbdt_tables(sub_path) if cbdt_source
153
+ cbdt_propagator.propagate_tables_into(cbdt_propagator.cbdt_source, sub_path)
169
154
 
170
155
  loaded = Fontisan::FontLoader.load(sub_path)
171
156
  stats = SubfontStats.new(
@@ -178,152 +163,22 @@ module Fontisan
178
163
  end
179
164
 
180
165
  def assign_gids_and_copy_glyphs(bindings, target, deduplicator)
181
- cbdt = safe_cbdt_source
166
+ cbdt = cbdt_propagator.safe_cbdt_source
167
+ copier = GlyphCopier.new(deduplicator)
182
168
 
183
169
  # Glyph ordering matters: Cmap.build uses first-wins semantics
184
170
  # (Cmap.build docstring), so outline donors must be stitched
185
171
  # before CBDT placeholders. Otherwise the empty CBDT placeholder
186
172
  # would land at a lower GID and win the cp→gid mapping for any
187
173
  # codepoint covered by both donors, hiding the real outline glyph.
188
- # CBDT bitmap data still propagates via propagate_cbdt_tables.
189
- add_notdef_from(bindings, target, deduplicator)
190
- add_outline_glyphs(bindings, target, deduplicator,
191
- skip_sources: cbdt ? [cbdt] : [])
192
- add_all_cbdt_glyphs(cbdt, target) if cbdt
193
- end
194
-
195
- def add_outline_glyphs(bindings, target, deduplicator, skip_sources:)
196
- sorted_bindings(bindings).each do |binding|
197
- next if binding[:donor_gid].zero?
198
- next if skip_sources.any? { |s| s.equal?(binding[:source]) }
199
-
200
- glyph = binding[:source].glyph_for_gid(binding[:donor_gid])
201
- next unless glyph
202
-
203
- canonical = deduplicator&.find(glyph)
204
- if canonical && target.glyphs.key?(canonical)
205
- add_extra_unicode(target, canonical, binding[:codepoint])
206
- else
207
- name = unique_target_name(target, glyph.name)
208
- copy_glyph_into(target, name: name, source: binding[:source],
209
- donor_gid: binding[:donor_gid],
210
- codepoint: binding[:codepoint])
211
- deduplicator&.register(glyph, name)
212
- end
213
- end
214
- end
215
-
216
- def sorted_bindings(bindings)
217
- bindings.sort_by { |b| [b[:codepoint] || Float::INFINITY, b[:donor_gid]] }
218
- end
219
-
220
- def add_notdef_from(bindings, target, deduplicator)
221
- notdef_binding = bindings.find { |b| b[:donor_gid].zero? }
222
- if notdef_binding
223
- copy_glyph_into(target, name: ".notdef",
224
- source: notdef_binding[:source],
225
- donor_gid: 0)
226
- else
227
- target.layers.default_layer.add(Ufo::Glyph.new(name: ".notdef"))
228
- end
229
- dedup_target = target.glyphs[".notdef"]
230
- deduplicator&.register(dedup_target, ".notdef") if dedup_target
231
- end
232
-
233
- def copy_glyph_into(target_font, name:, source:, donor_gid:, codepoint: nil)
234
- original = source.glyph_for_gid(donor_gid)
235
- return unless original
236
-
237
- copy = clone_glyph(original, name: name)
238
- copy.add_unicode(codepoint) if codepoint
239
- target_font.layers.default_layer.add(copy)
240
- end
241
-
242
- def add_extra_unicode(target_font, glyph_name, codepoint)
243
- return unless codepoint
244
-
245
- glyph = target_font.glyph(glyph_name)
246
- glyph.add_unicode(codepoint) unless glyph.unicodes.include?(codepoint)
174
+ # CBDT bitmap data still propagates via CbdtPropagator.
175
+ copier.inject_notdef(bindings, target)
176
+ copier.copy_outlines(bindings, target, skip_sources: cbdt ? [cbdt] : [])
177
+ cbdt_propagator.add_placeholder_glyphs(cbdt, target) if cbdt
247
178
  end
248
179
 
249
- def unique_target_name(target_font, base_name)
250
- return base_name unless target_font.glyphs.key?(base_name)
251
-
252
- suffix = 1
253
- loop do
254
- candidate = "#{base_name}.#{suffix}"
255
- return candidate unless target_font.glyphs.key?(candidate)
256
-
257
- suffix += 1
258
- end
259
- end
260
-
261
- def clone_glyph(original, name:)
262
- copy = Ufo::Glyph.new(name: name)
263
- copy.width = original.width
264
- copy.height = original.height
265
- original.contours.each { |c| copy.add_contour(clone_contour(c)) }
266
- original.components.each { |c| copy.add_component(c) }
267
- original.anchors.each { |a| copy.add_anchor(a) }
268
- original.guidelines.each { |g| copy.add_guideline(g) }
269
- copy
270
- end
271
-
272
- def clone_contour(original)
273
- points = original.points.map do |p|
274
- Ufo::Point.new(x: p.x, y: p.y, type: p.type, smooth: p.smooth)
275
- end
276
- Ufo::Contour.new(points)
277
- end
278
-
279
- def propagate_cbdt_tables(path)
280
- source = cbdt_source
281
- return unless source
282
-
283
- compiled = Fontisan::FontLoader.load(path)
284
-
285
- # Read every table as raw bytes straight from the file's table
286
- # directory. We deliberately bypass #table (which parses via
287
- # BinData) because some tables — notably CFF2 — don't yet have
288
- # round-trippable BinData models; calling #table on them returns
289
- # nil and would silently drop them from the rewritten font.
290
- tables = {}
291
- compiled.table_names.each do |tag|
292
- raw = compiled.table_data[tag]
293
- tables[tag] = raw if raw
294
- end
295
-
296
- cbdt_bytes = source.raw_table_bytes("CBDT")
297
- cblc_bytes = source.raw_table_bytes("CBLC")
298
- tables["CBDT"] = cbdt_bytes if cbdt_bytes
299
- tables["CBLC"] = cblc_bytes if cblc_bytes
300
-
301
- sfnt = tables.key?("CFF ") || tables.key?("CFF2") ? 0x4F54544F : 0x00010000
302
- Fontisan::FontWriter.write_to_file(tables, path, sfnt_version: sfnt)
303
- end
304
-
305
- def add_all_cbdt_glyphs(source, target)
306
- ufo = source.font.is_a?(Ufo::Font) ? source.font : nil
307
- if ufo
308
- ufo.glyphs.each_value { |g| target.layers.default_layer.add(clone_glyph(g, name: g.name)) }
309
- return
310
- end
311
-
312
- maxp = source.font.table("maxp")
313
- num_glyphs = maxp&.num_glyphs || 0
314
- cmap = source.font.table("cmap")
315
- mappings = cmap&.unicode_mappings || {}
316
-
317
- gid_cps = Hash.new { |h, k| h[k] = [] }
318
- mappings.each { |cp, gid| gid_cps[gid] << cp }
319
-
320
- num_glyphs.times do |gid|
321
- name = gid.zero? ? ".notdef" : "gid#{gid}"
322
- glyph = Ufo::Glyph.new(name: name)
323
- glyph.width = 0
324
- gid_cps[gid].each { |cp| glyph.add_unicode(cp) }
325
- target.layers.default_layer.add(glyph)
326
- end
180
+ def cbdt_propagator
181
+ @cbdt_propagator ||= CbdtPropagator.new(@sources.values)
327
182
  end
328
183
  end
329
184
  end