fontisan 0.4.22 → 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 +5 -0
  3. data/lib/fontisan/config/subset_profiles.yml +2 -0
  4. data/lib/fontisan/stitcher/cbdt_propagator.rb +44 -22
  5. data/lib/fontisan/stitcher/glyph_cloner.rb +41 -0
  6. data/lib/fontisan/stitcher/glyph_copier.rb +3 -33
  7. data/lib/fontisan/stitcher/unique_glyph_name.rb +46 -0
  8. data/lib/fontisan/stitcher.rb +2 -0
  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 +35 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 208fa8e2c44bdea2e800ecb13639d50e06e6914406822f6e3c92152d21414fd7
4
- data.tar.gz: cdce57d3a157d5a5549b45104f93de38263697989bae6a66572907bc54b58bb0
3
+ metadata.gz: c7e00355898ff37e38b4948f9a607bf95b96b73df17225c81ebe0b6f6b757b1d
4
+ data.tar.gz: 9009d6fb96c8168a098236d7b80355749d6c65463376119695ab2a65c20de01d
5
5
  SHA512:
6
- metadata.gz: c87e9ec10e9cadd168dda76b38ef225c1e286bc63501b75268a7cd29fcdfbef3d86abacac211052d529d153aebf1f8db29aaab7445c51a5a83e3eb091f752ab4
7
- data.tar.gz: 4632d882fe8ab4897ed32722ec17efb942819ded33e2005bf51c3032384466164d2c2c09a1a8f4388f1889b5c169f42b9dc14ae5cafce72ef5680c94d2333c02
6
+ metadata.gz: 0c82bc6f0d6a871e84d85e4ac6bd51260a39f2b1d373b3055801354c89d99eda1ce3f78ba62ce640e5193eededeba9c8f5101146378124b5d5ac8e5c52bb60c0
7
+ data.tar.gz: 3e957b040dd2660ce7b8ddea3d4592cfef37ad8d434dd5e43d1be4417459992150fcd414f7547ead7f89a9ad8fd4ea8e01a847eb57ef352a70335e691cb386d6
data/.rubocop_todo.yml CHANGED
@@ -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:
@@ -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:
@@ -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
@@ -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])
@@ -93,7 +93,7 @@ module Fontisan
93
93
  original = source.glyph_for_gid(donor_gid)
94
94
  return unless original
95
95
 
96
- copy = clone_glyph(original, name: name)
96
+ copy = GlyphCloner.clone(original, name: name)
97
97
  copy.add_unicode(codepoint) if codepoint
98
98
  target_font.layers.default_layer.add(copy)
99
99
  end
@@ -104,36 +104,6 @@ module Fontisan
104
104
  glyph = target_font.glyph(glyph_name)
105
105
  glyph.add_unicode(codepoint) unless glyph.unicodes.include?(codepoint)
106
106
  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
107
  end
138
108
  end
139
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
@@ -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
@@ -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
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Subset
5
+ module TableStrategy
6
+ # Cmap strategy: rebuild cmap containing only the (codepoint →
7
+ # new GID) mappings for codepoints whose original GID is retained.
8
+ # Emits a format-4 subtable for BMP codepoints and a format-12
9
+ # subtable for supplementary-plane codepoints.
10
+ class Cmap
11
+ # @param context [SubsetContext]
12
+ # @param tag [String] "cmap"
13
+ # @param table [Cmap] parsed cmap table
14
+ # @return [String] binary cmap bytes for the subset
15
+ def self.call(context:, tag:, table:)
16
+ new_mappings = {}
17
+ table.unicode_mappings.each do |char_code, old_gid|
18
+ new_gid = context.mapping.new_id(old_gid)
19
+ new_mappings[char_code] = new_gid if new_gid
20
+ end
21
+ Builder.new(new_mappings).build
22
+ end
23
+
24
+ # Builds a minimal valid cmap table from a {codepoint => gid} map.
25
+ # Extracted as a sibling class so [Cmap] stays focused on
26
+ # subsetting dispatch while the binary arithmetic lives
27
+ # separately.
28
+ class Builder
29
+ def initialize(mappings)
30
+ @mappings = mappings
31
+ end
32
+
33
+ def build
34
+ @mappings = { 0 => 0 } if @mappings.empty?
35
+
36
+ bmp = @mappings.select { |cp, _| cp <= 0xFFFF }
37
+ supp = @mappings.select { |cp, _| cp > 0xFFFF }
38
+
39
+ subtables = []
40
+ records = []
41
+
42
+ unless bmp.empty?
43
+ subtables << build_format_4(bmp)
44
+ idx = subtables.size - 1
45
+ records << [3, 1, idx] # Windows BMP
46
+ records << [0, 3, idx] # Unicode BMP
47
+ end
48
+
49
+ unless supp.empty?
50
+ subtables << build_format_12(@mappings)
51
+ idx = subtables.size - 1
52
+ records << [3, 10, idx] # Windows UCS-4
53
+ records << [0, 4, idx] # Unicode full
54
+ end
55
+
56
+ assemble(records, subtables)
57
+ end
58
+
59
+ private
60
+
61
+ def assemble(records, subtables)
62
+ num_tables = records.size
63
+ header = [0, num_tables].pack("nn")
64
+ subtable_base = 4 + (8 * num_tables)
65
+
66
+ offsets = []
67
+ running = subtable_base
68
+ subtables.each do |st|
69
+ offsets << running
70
+ running += st.bytesize
71
+ end
72
+
73
+ record_bytes = +""
74
+ records.each do |pid, eid, idx|
75
+ record_bytes << [pid, eid, offsets[idx]].pack("nnN")
76
+ end
77
+
78
+ header + record_bytes + subtables.join
79
+ end
80
+
81
+ def build_format_4(bmp_mappings)
82
+ segments = coalesce_segments(bmp_mappings)
83
+ segments << { start_cp: 0xFFFF, end_cp: 0xFFFF, start_gid: 0 }
84
+
85
+ seg_count = segments.size
86
+ seg_count_x2 = seg_count * 2
87
+ search_range = 2**Math.log2(seg_count).floor * 2
88
+ search_range = 2 if search_range < 2
89
+ entry_selector = Math.log2(search_range / 2).to_i
90
+ range_shift = seg_count_x2 - search_range
91
+
92
+ end_codes = segments.map { |s| s[:end_cp] }
93
+ start_codes = segments.map { |s| s[:start_cp] }
94
+ id_deltas = segments.map do |s|
95
+ (s[:start_gid] - s[:start_cp]) & 0xFFFF
96
+ end
97
+ id_range_offsets = [0] * seg_count
98
+
99
+ subtable = +""
100
+ subtable << [4, 0, 0, seg_count_x2,
101
+ search_range, entry_selector, range_shift].pack("n*")
102
+ subtable << end_codes.pack("n*")
103
+ subtable << [0].pack("n") # reservedPad
104
+ subtable << start_codes.pack("n*")
105
+ subtable << id_deltas.pack("n*")
106
+ subtable << id_range_offsets.pack("n*")
107
+
108
+ subtable[2, 2] = [subtable.bytesize].pack("n")
109
+ subtable
110
+ end
111
+
112
+ def build_format_12(all_mappings)
113
+ groups = coalesce_segments(all_mappings)
114
+ num_groups = groups.size
115
+
116
+ subtable = +""
117
+ subtable << [12, 0, 0, 0, num_groups].pack("nnNNN")
118
+ groups.each do |g|
119
+ subtable << [g[:start_cp], g[:end_cp], g[:start_gid]].pack("NNN")
120
+ end
121
+
122
+ subtable[4, 4] = [subtable.bytesize].pack("N")
123
+ subtable
124
+ end
125
+
126
+ def coalesce_segments(mappings)
127
+ sorted = mappings.sort_by { |cp, _| cp }
128
+ segments = []
129
+ current = nil
130
+ sorted.each do |cp, gid|
131
+ if current && cp == current[:end_cp] + 1 &&
132
+ gid == current[:start_gid] + (cp - current[:start_cp])
133
+ current[:end_cp] = cp
134
+ else
135
+ segments << current if current
136
+ current = { start_cp: cp, end_cp: cp, start_gid: gid }
137
+ end
138
+ end
139
+ segments << current if current
140
+ segments
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Per-survivor placement: where the source bitmap lives and where it
4
+ # ends up in the subset CBDT.
5
+ module Fontisan
6
+ module Subset
7
+ module TableStrategy
8
+ ColorBitmapPlacement = Struct.new(:source_gid, :new_gid,
9
+ :source_offset, :byte_length,
10
+ :image_format, :strike_ppem,
11
+ :new_offset,
12
+ keyword_init: true) do
13
+ def initialize(*)
14
+ super
15
+ self.new_offset ||= 0
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Subset
5
+ module TableStrategy
6
+ # Plan for one strike emitted by [ColorBitmapSubsetter]: the source
7
+ # BitmapSize plus the list of contiguous new-gid runs (each run
8
+ # becomes one IndexSubTable in the output CBLC).
9
+ class ColorBitmapStrikePlan
10
+ attr_reader :source_strike, :subtable_plans
11
+
12
+ def initialize(source_strike)
13
+ @source_strike = source_strike
14
+ @subtable_plans = []
15
+ end
16
+
17
+ # Add a placement, grouping into contiguous new-gid runs. A new
18
+ # run starts when the new gid skips, the ppem changes, or the
19
+ # image format changes.
20
+ def add(placement)
21
+ current = @subtable_plans.last
22
+
23
+ if current && placement.new_gid == current.last_new_gid + 1
24
+ current.add(placement)
25
+ else
26
+ sub = ColorBitmapSubtablePlan.new(
27
+ strike_ppem: placement.strike_ppem,
28
+ image_format: placement.image_format,
29
+ )
30
+ sub.add(placement)
31
+ @subtable_plans << sub
32
+ end
33
+ end
34
+
35
+ def empty?
36
+ @subtable_plans.empty?
37
+ end
38
+
39
+ def subtable_count
40
+ @subtable_plans.size
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end