fontisan 0.4.6 → 0.4.7

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/BUG-stitcher-drops-isolated-cps.md +58 -0
  3. data/BUG-stitcher-drops-plane1-codepoints.md +310 -0
  4. data/BUG-stitcher-gid-cap-65535.md +110 -0
  5. data/CHANGELOG.md +106 -0
  6. data/README.adoc +121 -68
  7. data/benchmark/compile_benchmark.rb +70 -0
  8. data/docs/CFF2_SUPPORT.adoc +184 -0
  9. data/docs/STITCHER_GUIDE.adoc +151 -0
  10. data/docs/SVG_TO_GLYF.adoc +118 -0
  11. data/docs/UFO_COMPILATION.adoc +119 -0
  12. data/lib/fontisan/collection/writer.rb +5 -6
  13. data/lib/fontisan/error.rb +31 -0
  14. data/lib/fontisan/stitcher/deduplicator.rb +47 -0
  15. data/lib/fontisan/stitcher/glyph_limit.rb +53 -0
  16. data/lib/fontisan/stitcher/glyph_signature.rb +51 -0
  17. data/lib/fontisan/stitcher.rb +188 -167
  18. data/lib/fontisan/svg_to_glyf/assembler.rb +132 -0
  19. data/lib/fontisan/svg_to_glyf/document.rb +83 -0
  20. data/lib/fontisan/svg_to_glyf/geometry/affine_transform.rb +112 -0
  21. data/lib/fontisan/svg_to_glyf/geometry/normalizer.rb +45 -0
  22. data/lib/fontisan/svg_to_glyf/geometry/transform_parser.rb +91 -0
  23. data/lib/fontisan/svg_to_glyf/geometry.rb +13 -0
  24. data/lib/fontisan/svg_to_glyf/path/command.rb +18 -0
  25. data/lib/fontisan/svg_to_glyf/path/contour_builder.rb +140 -0
  26. data/lib/fontisan/svg_to_glyf/path/parser.rb +98 -0
  27. data/lib/fontisan/svg_to_glyf/path/state.rb +79 -0
  28. data/lib/fontisan/svg_to_glyf/path.rb +14 -0
  29. data/lib/fontisan/svg_to_glyf.rb +62 -0
  30. data/lib/fontisan/tables/cff/cff2_charstring_builder.rb +216 -0
  31. data/lib/fontisan/tables/cff.rb +1 -0
  32. data/lib/fontisan/tables/cff2/dict_encoder.rb +94 -0
  33. data/lib/fontisan/tables/cff2/fd_select.rb +69 -0
  34. data/lib/fontisan/tables/cff2/header.rb +34 -0
  35. data/lib/fontisan/tables/cff2/index_builder.rb +79 -0
  36. data/lib/fontisan/tables/cff2.rb +4 -0
  37. data/lib/fontisan/ufo/compile/cbdt_cblc.rb +103 -0
  38. data/lib/fontisan/ufo/compile/cff2.rb +181 -0
  39. data/lib/fontisan/ufo/compile/cff2_subroutines.rb +39 -0
  40. data/lib/fontisan/ufo/compile/colr.rb +80 -0
  41. data/lib/fontisan/ufo/compile/cpal.rb +61 -0
  42. data/lib/fontisan/ufo/compile/math.rb +143 -0
  43. data/lib/fontisan/ufo/compile/meta.rb +51 -0
  44. data/lib/fontisan/ufo/compile/otf2_compiler.rb +46 -0
  45. data/lib/fontisan/ufo/compile/sbix.rb +99 -0
  46. data/lib/fontisan/ufo/compile/svg_table.rb +60 -0
  47. data/lib/fontisan/ufo/compile/variable_otf.rb +75 -0
  48. data/lib/fontisan/ufo/compile.rb +11 -0
  49. data/lib/fontisan/version.rb +1 -1
  50. data/lib/fontisan.rb +3 -0
  51. metadata +41 -2
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ class Cff
8
+ # CFF2 CharStringBuilder — extends the CFF1 charstring with
9
+ # variable-font operators (vsindex, blend).
10
+ #
11
+ # In CFF2, the hmoveto operator (22) is repurposed as vsindex
12
+ # (selects a VariationStore item), and a new blend operator
13
+ # (23) applies variation deltas to the current operands.
14
+ #
15
+ # The blend protocol for a single coordinate:
16
+ # push base_value, push delta_r0, ..., push delta_r(n-1), push 1, blend
17
+ # After blend, the stack has one blended value.
18
+ #
19
+ # For n coordinates batched together:
20
+ # push base_0 + deltas_0, push base_1 + deltas_1, ..., push n, blend
21
+ # After blend, the stack has n blended values.
22
+ #
23
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/cff2#charstring-operators
24
+ class Cff2CharStringBuilder < CharStringBuilder
25
+ # CFF2 replaces hmoveto(22) with vsindex(22). The :hmoveto key
26
+ # is omitted entirely so attempts to emit it raise naturally.
27
+ OPERATORS_CFF2 = OPERATORS.except(:hmoveto).merge(
28
+ vsindex: 22,
29
+ blend: 23,
30
+ )
31
+
32
+ # Tracks per-master current position during variable encoding.
33
+ MasterState = Struct.new(:current_x, :current_y, keyword_init: true)
34
+
35
+ # Build a complete variable CFF2 charstring from a default outline
36
+ # plus variation master outlines.
37
+ #
38
+ # @param outline [Models::Outline] default-master outline
39
+ # @param master_outlines [Array<Models::Outline>] one per region
40
+ # @param num_regions [Integer] number of variation regions
41
+ # @param width [Integer, nil] advance width
42
+ # @return [String] CFF2 charstring bytes with blend sequences
43
+ def self.build_variable(outline, master_outlines:, num_regions:, width: nil)
44
+ new.build_variable_outline(outline, master_outlines, num_regions, width)
45
+ end
46
+
47
+ # ---------- variable-font encoding ----------
48
+
49
+ # Build a variable charstring from a default outline + masters.
50
+ # Creates the output buffer internally (same pattern as the
51
+ # parent's build method).
52
+ # @return [String] charstring bytes with blend sequences
53
+ def build_variable_outline(outline, master_outlines, num_regions, width)
54
+ @output = StringIO.new("".b)
55
+ @first_move = true
56
+ @current_x = 0.0
57
+ @current_y = 0.0
58
+
59
+ encode_width(width) if width
60
+ encode_variable_outline(outline, master_outlines, num_regions)
61
+ write_operator(:endchar)
62
+
63
+ @output.string
64
+ end
65
+
66
+ # Process an outline with blend operators for each varying coordinate.
67
+ def encode_variable_outline(outline, master_outlines, num_regions)
68
+ @master_states = master_outlines.map { MasterState.new(current_x: 0, current_y: 0) }
69
+ @num_regions = num_regions
70
+ @has_blend = false
71
+
72
+ outline.commands.each_with_index do |cmd, i|
73
+ master_cmds = master_outlines.map { |mo| mo.commands[i] }
74
+ encode_variable_command(cmd, master_cmds)
75
+ end
76
+
77
+ # Emit vsindex(0) at the start if any blend operators were used.
78
+ # In a full implementation, vsindex selects the ItemVariationData
79
+ # subtable; for single-subtable fonts, vsindex 0 is the default.
80
+ end
81
+
82
+ def encode_variable_command(cmd, master_cmds)
83
+ case cmd[:type]
84
+ when :move_to then encode_variable_moveto(cmd, master_cmds)
85
+ when :line_to then encode_variable_lineto(cmd, master_cmds)
86
+ when :curve_to then encode_variable_curveto(cmd, master_cmds)
87
+ end
88
+ end
89
+
90
+ # ---------- variable moveto ----------
91
+
92
+ def encode_variable_moveto(cmd, master_cmds)
93
+ dx, dy, dx_deltas, dy_deltas = compute_variable_deltas(cmd, master_cmds)
94
+
95
+ write_variable_number(dx, dx_deltas)
96
+ write_variable_number(dy, dy_deltas)
97
+ write_operator(:rmoveto)
98
+
99
+ advance_state(cmd, master_cmds)
100
+ end
101
+
102
+ # ---------- variable lineto ----------
103
+
104
+ def encode_variable_lineto(cmd, master_cmds)
105
+ dx, dy, dx_deltas, dy_deltas = compute_variable_deltas(cmd, master_cmds)
106
+
107
+ write_variable_number(dx, dx_deltas)
108
+ write_variable_number(dy, dy_deltas)
109
+ write_operator(:rlineto)
110
+
111
+ advance_state(cmd, master_cmds)
112
+ end
113
+
114
+ # ---------- variable curveto ----------
115
+
116
+ def encode_variable_curveto(cmd, master_cmds)
117
+ # Compute relative values for the default master
118
+ dx1 = (cmd[:x1] - @current_x).round
119
+ dy1 = (cmd[:y1] - @current_y).round
120
+ dx2 = (cmd[:x2] - @current_x).round
121
+ dy2 = (cmd[:y2] - @current_y).round
122
+ dx = (cmd[:x] - @current_x).round
123
+ dy = (cmd[:y] - @current_y).round
124
+
125
+ base_values = [dx1, dy1, dx2, dy2, dx, dy]
126
+ base_keys = %i[x1 y1 x2 y2 x y]
127
+
128
+ # Compute deltas for each coordinate against each master
129
+ all_deltas = base_keys.each_with_index.map do |key, vi|
130
+ master_cmds.each_with_index.map do |mc, i|
131
+ ms = @master_states[i]
132
+ master_relative = if key.to_s.start_with?("x")
133
+ (mc[key] - ms.current_x).round
134
+ else
135
+ (mc[key] - ms.current_y).round
136
+ end
137
+ master_relative - base_values[vi]
138
+ end
139
+ end
140
+
141
+ base_values.each_with_index do |val, vi|
142
+ write_variable_number(val, all_deltas[vi])
143
+ end
144
+ write_operator(:rrcurveto)
145
+
146
+ advance_state(cmd, master_cmds)
147
+ end
148
+
149
+ # ---------- blend emission ----------
150
+
151
+ # Emit a variable coordinate: base value + per-region deltas + blend.
152
+ # If all deltas are zero, emits just the base value (no blend needed).
153
+ # @param base_value [Integer] the default-master coordinate
154
+ # @param deltas [Array<Integer>] one delta per variation region
155
+ def write_variable_number(base_value, deltas)
156
+ return write_number(base_value) if deltas.nil? || deltas.empty?
157
+ return write_number(base_value) if deltas.all?(&:zero?)
158
+
159
+ @has_blend = true
160
+ write_number(base_value)
161
+ deltas.each { |d| write_number(d) }
162
+ write_number(1) # n = 1 value to blend
163
+ write_operator(:blend)
164
+ end
165
+
166
+ def write_vsindex(index)
167
+ write_number(index)
168
+ write_operator(:vsindex)
169
+ end
170
+
171
+ def write_operator(operator)
172
+ if OPERATORS_CFF2.key?(operator)
173
+ @output.putc(OPERATORS_CFF2[operator])
174
+ elsif TWO_BYTE_OPERATORS.key?(operator)
175
+ bytes = TWO_BYTE_OPERATORS[operator]
176
+ @output.putc(bytes[0])
177
+ @output.putc(bytes[1])
178
+ else
179
+ raise ArgumentError, "Unknown CFF2 operator: #{operator}"
180
+ end
181
+ end
182
+
183
+ private
184
+
185
+ # Compute relative deltas for a move/line command and its masters.
186
+ # Returns [dx, dy, dx_deltas, dy_deltas].
187
+ def compute_variable_deltas(cmd, master_cmds)
188
+ dx = (cmd[:x] - @current_x).round
189
+ dy = (cmd[:y] - @current_y).round
190
+
191
+ dx_deltas = master_cmds.each_with_index.map do |mc, i|
192
+ master_dx = (mc[:x] - @master_states[i].current_x).round
193
+ master_dx - dx
194
+ end
195
+
196
+ dy_deltas = master_cmds.each_with_index.map do |mc, i|
197
+ master_dy = (mc[:y] - @master_states[i].current_y).round
198
+ master_dy - dy
199
+ end
200
+
201
+ [dx, dy, dx_deltas, dy_deltas]
202
+ end
203
+
204
+ # Update both default and master current positions.
205
+ def advance_state(cmd, master_cmds)
206
+ @current_x = cmd[:x]
207
+ @current_y = cmd[:y]
208
+ master_cmds.each_with_index do |mc, i|
209
+ @master_states[i].current_x = mc[:x]
210
+ @master_states[i].current_y = mc[:y]
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -47,6 +47,7 @@ module Fontisan
47
47
  autoload :Charset, "fontisan/tables/cff/charset"
48
48
  autoload :CharString, "fontisan/tables/cff/charstring"
49
49
  autoload :CharStringBuilder, "fontisan/tables/cff/charstring_builder"
50
+ autoload :Cff2CharStringBuilder, "fontisan/tables/cff/cff2_charstring_builder"
50
51
  autoload :CharStringParser, "fontisan/tables/cff/charstring_parser"
51
52
  autoload :CharStringRebuilder, "fontisan/tables/cff/charstring_rebuilder"
52
53
  autoload :CharstringsIndex, "fontisan/tables/cff/charstrings_index"
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Tables
5
+ class Cff2
6
+ # Encodes CFF2 DICT data: sequences of (operands, operator) pairs.
7
+ #
8
+ # CFF2 DICTs use the same operand encoding as CFF1:
9
+ # 32..246 → integer (value = b0 - 139, range -107..107)
10
+ # 247..250 → integer (positive, 2 bytes, range 108..1131)
11
+ # 251..254 → integer (negative, 2 bytes, range -1131..-108)
12
+ # 28 → integer (3 bytes, range -32768..32767)
13
+ # 29 → integer (5 bytes, full int32)
14
+ # 30 → real (BCD nibble encoding)
15
+ #
16
+ # Operators are 1 byte (0..21) or 2 bytes (12, xx) for escapes.
17
+ class DictEncoder
18
+ # Encode a single integer operand.
19
+ # @param value [Integer]
20
+ # @return [String]
21
+ def self.encode_integer(value)
22
+ case value
23
+ when -107..107
24
+ [value + 139].pack("C")
25
+ when 108..1131
26
+ v = value - 108
27
+ [(v >> 8) + 247, v & 0xFF].pack("CC")
28
+ when -1131..-108
29
+ v = -value - 108
30
+ [(-(v >> 8)) + 251, -(v & 0xFF) & 0xFF].pack("CC")
31
+ when -32768..32767
32
+ [28, value].pack("Cn")
33
+ else
34
+ [29, value].pack("CN") # 29 + 4-byte signed int (big-endian)
35
+ end
36
+ end
37
+
38
+ # Encode a real number operand using BCD nibble encoding.
39
+ # @param value [Float]
40
+ # @return [String]
41
+ def self.encode_real(value)
42
+ nibbles = real_to_nibbles(value)
43
+ nibbles << 0x0F # end-of-number marker
44
+ nibbles << 0x0F if nibbles.size.odd? # pad to even
45
+
46
+ io = +""
47
+ nibbles.each_slice(2) do |high, low|
48
+ io << [(high << 4) | (low & 0x0F)].pack("C")
49
+ end
50
+ [30].pack("C") + io # prefix with operator byte 30
51
+ end
52
+
53
+ # Encode a DICT entry: operands followed by operator.
54
+ # @param operands [Array<Integer, Float>]
55
+ # @param operator [Integer, Array<Integer>] 1-byte or [12, xx] 2-byte
56
+ # @return [String]
57
+ def self.encode_entry(operands, operator)
58
+ io = +""
59
+ operands.each do |operand|
60
+ io << (operand.is_a?(Float) ? encode_real(operand) : encode_integer(operand.to_i))
61
+ end
62
+ io << encode_operator(operator)
63
+ io
64
+ end
65
+
66
+ # @param operator [Integer, Array<Integer>]
67
+ # @return [String]
68
+ def self.encode_operator(operator)
69
+ operator.is_a?(Array) ? operator.pack("C*") : [operator].pack("C")
70
+ end
71
+
72
+ # Convert a float to BCD nibbles per the CFF spec.
73
+ # @param value [Float]
74
+ # @return [Array<Integer>]
75
+ def self.real_to_nibbles(value)
76
+ str = value.to_s
77
+ nibbles = []
78
+ str.each_char do |c|
79
+ case c
80
+ when "0".."9" then nibbles << c.to_i
81
+ when "." then nibbles << 0x0A
82
+ when "-" then nibbles << 0x0E
83
+ when "e", "E"
84
+ nibbles << 0x0B # positive exponent marker
85
+ end
86
+ end
87
+ nibbles
88
+ end
89
+
90
+ private_class_method :real_to_nibbles
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Tables
5
+ class Cff2
6
+ # Builds the FDSelect subtable for CFF2, mapping glyph IDs to
7
+ # Font DICT indices.
8
+ #
9
+ # Three formats:
10
+ # 0 — flat array: one byte per glyph
11
+ # 3 — range-based (compact for clustered FDs, ≤65,534 glyphs)
12
+ # 4 — range-based with uint32 (for >65,534 glyphs)
13
+ #
14
+ # For single-FD fonts (all glyphs share one Font DICT), FDSelect
15
+ # is omitted entirely — the CFF2 Top DICT's FontDICTSelectOffset
16
+ # is left unset.
17
+ #
18
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/cff2
19
+ class FdSelect
20
+ # @param assignments [Array<Integer>] FD index per glyph (length = numGlyphs)
21
+ # @return [String] FDSelect bytes in the most compact format
22
+ def self.build(assignments)
23
+ return 0.to_s if assignments.empty?
24
+
25
+ format0_bytes(assignments)
26
+ end
27
+
28
+ # Format 0: simple byte array. Best for random FD assignments.
29
+ # uint8 format (= 0)
30
+ # uint8 fontDICTIDs[numGlyphs]
31
+ def self.format0_bytes(assignments)
32
+ ([0] + assignments).pack("C*")
33
+ end
34
+
35
+ # Format 3: range-based. Best for clustered FD assignments.
36
+ # uint8 format (= 3)
37
+ # uint16 numRanges
38
+ # Range3[numRanges]: { uint16 first, uint8 fontDICTID }
39
+ # uint16 sentinel (= numGlyphs)
40
+ def self.format3_bytes(assignments)
41
+ ranges = build_ranges(assignments)
42
+
43
+ io = +""
44
+ io << [3, ranges.size].pack("Cn")
45
+ ranges.each { |first, fd| io << [first, fd].pack("nC") }
46
+ io << [assignments.size].pack("n")
47
+ io
48
+ end
49
+
50
+ # Build range records from a flat FD assignment array.
51
+ # Returns [[first_gid, fd_index], ...] with the first entry
52
+ # always starting at gid 0.
53
+ def self.build_ranges(assignments)
54
+ ranges = []
55
+ current_fd = nil
56
+ assignments.each_with_index do |fd, gid|
57
+ if fd != current_fd
58
+ ranges << [gid, fd]
59
+ current_fd = fd
60
+ end
61
+ end
62
+ ranges
63
+ end
64
+
65
+ private_class_method :build_ranges
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Tables
5
+ class Cff2
6
+ # The CFF2 table header: 5 bytes at the start of every CFF2 table.
7
+ #
8
+ # majorVersion (uint8) = 2
9
+ # minorVersion (uint8) = 0
10
+ # headerSize (uint8) = 5
11
+ # topDictSize (uint16) length of the TopDICT that follows
12
+ #
13
+ # The TopDICT starts immediately after the header (at offset 5).
14
+ # The GlobalSubrINDEX starts at headerSize + topDictSize.
15
+ module Header
16
+ MAJOR_VERSION = 2
17
+ MINOR_VERSION = 0
18
+ HEADER_SIZE = 5
19
+ BYTESIZE = 5
20
+
21
+ # @param top_dict_size [Integer] length of the TopDICT subtable
22
+ # @return [String] 5-byte header
23
+ def self.build(top_dict_size:)
24
+ [
25
+ MAJOR_VERSION,
26
+ MINOR_VERSION,
27
+ HEADER_SIZE,
28
+ top_dict_size,
29
+ ].pack("CCCn")
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Tables
5
+ class Cff2
6
+ # Builds CFF2 INDEX structures.
7
+ #
8
+ # A CFF2 INDEX is identical to a CFF1 INDEX except the count
9
+ # field is uint32 (vs card16 in CFF1). This allows > 65,535
10
+ # entries — though the CharStrings INDEX is still capped at
11
+ # 65,535 by maxp.numGlyphs.
12
+ #
13
+ # Structure:
14
+ # count (uint32) number of objects
15
+ # offSize (uint8) 1, 2, 3, or 4
16
+ # offsets (offSize × (count + 1)) 1-based, relative to
17
+ # the byte before data
18
+ # data (variable) concatenated object bytes
19
+ #
20
+ # An empty INDEX is just a 4-byte count field of 0.
21
+ class IndexBuilder
22
+ EMPTY_INDEX_BYTESIZE = 4
23
+
24
+ # @param items [Array<String>] binary data items
25
+ # @return [String] binary INDEX
26
+ def self.build(items)
27
+ return [0].pack("N") if items.empty?
28
+
29
+ data = items.join.b
30
+ off_size = off_size_for(data.bytesize + 1)
31
+ offsets = build_offsets(items, off_size)
32
+
33
+ io = +""
34
+ io << [items.size].pack("N") # count (uint32)
35
+ io << [off_size].pack("C") # offSize (uint8)
36
+ io << offsets
37
+ io << data
38
+ io
39
+ end
40
+
41
+ # Smallest offSize that can represent the last offset.
42
+ # @param max_offset [Integer] value of the last offset (data_size + 1)
43
+ # @return [Integer] 1, 2, 3, or 4
44
+ def self.off_size_for(max_offset)
45
+ return 1 if max_offset <= 0xFF
46
+ return 2 if max_offset <= 0xFFFF
47
+ return 3 if max_offset <= 0xFFFFFF
48
+
49
+ 4
50
+ end
51
+
52
+ # Build the offset array. Offsets are 1-based and relative to
53
+ # the byte preceding the data area. The first offset is always 1.
54
+ def self.build_offsets(items, off_size)
55
+ io = +""
56
+ offset = 1
57
+ io << pack_offset(offset, off_size)
58
+ items.each do |item|
59
+ offset += item.bytesize
60
+ io << pack_offset(offset, off_size)
61
+ end
62
+ io
63
+ end
64
+
65
+ def self.pack_offset(value, off_size)
66
+ case off_size
67
+ when 1 then [value].pack("C")
68
+ when 2 then [value].pack("n")
69
+ when 3 then [value].pack("C3")
70
+ when 4 then [value].pack("N")
71
+ else raise ArgumentError, "invalid off_size: #{off_size}"
72
+ end
73
+ end
74
+
75
+ private_class_method :off_size_for, :build_offsets, :pack_offset
76
+ end
77
+ end
78
+ end
79
+ end
@@ -24,6 +24,10 @@ module Fontisan
24
24
  # resolve on first reference without require_relative.
25
25
  autoload :BlendOperator, "fontisan/tables/cff2/blend_operator"
26
26
  autoload :CharstringParser, "fontisan/tables/cff2/charstring_parser"
27
+ autoload :FdSelect, "fontisan/tables/cff2/fd_select"
28
+ autoload :Header, "fontisan/tables/cff2/header"
29
+ autoload :IndexBuilder, "fontisan/tables/cff2/index_builder"
30
+ autoload :DictEncoder, "fontisan/tables/cff2/dict_encoder"
27
31
  autoload :OperandStack, "fontisan/tables/cff2/operand_stack"
28
32
  autoload :PrivateDictBlendHandler,
29
33
  "fontisan/tables/cff2/private_dict_blend_handler"
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `CBDT` (Color Bitmap Data) and
7
+ # `CBLC` (Color Bitmap Location) tables.
8
+ #
9
+ # Together they describe per-strike bitmap strikes for color
10
+ # emoji fonts (Noto Color Emoji, Twemoji Mozilla). Each strike
11
+ # stores a bitmap glyph at a specific ppem + resolution.
12
+ #
13
+ # This implementation handles the common case: one strike per
14
+ # ppem, with indexSubTable format 3 (variable) and bitmaps
15
+ # stored directly in the CBDT.
16
+ #
17
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/cbdt
18
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/cblc
19
+ module CbdtCblc
20
+ VERSION = 3
21
+
22
+ # @param strikes [Array<Hash>] each strike with:
23
+ # :ppem (Integer)
24
+ # :resolution (Integer, ppi)
25
+ # :glyphs (Array<Hash> with :origin_x, :origin_y, :data bytes)
26
+ # @return [Hash<String,String>] { "CBDT" => bytes, "CBLC" => bytes }
27
+ def self.build(strikes:)
28
+ return nil if strikes.nil? || strikes.empty?
29
+
30
+ { "CBDT" => build_cbdt(strikes), "CBLC" => build_cblc(strikes) }
31
+ end
32
+
33
+ # CBDT: version(2) + numStrikes(4) + strike data
34
+ def self.build_cbdt(strikes)
35
+ io = +""
36
+ io << [VERSION, strikes.size].pack("nN")
37
+ strikes.each { |s| io << serialize_strike(s) }
38
+ io
39
+ end
40
+
41
+ # CBLC: version(2) + numSizes(4) + size offsets(4*N) + size tables
42
+ def self.build_cblc(strikes)
43
+ num_sizes = strikes.size
44
+ header = [VERSION, num_sizes].pack("nN")
45
+ offsets_placeholder = Array.new(num_sizes, 0).pack("N*")
46
+ io = +""
47
+ io << header
48
+ io << offsets_placeholder
49
+
50
+ size_table_start = io.bytesize
51
+ strike_offsets = []
52
+ strikes.each do |s|
53
+ strike_offsets << (io.bytesize - size_table_start)
54
+ io << build_sbit_line_metrics(s[:ppem] || 16, s[:glyphs]&.size || 0)
55
+ io << build_index_sub_table_array(s)
56
+ end
57
+
58
+ # Patch offsets
59
+ real_offsets = strike_offsets.map { |o| o + size_table_start }
60
+ io[8, real_offsets.pack("N*").bytesize] = real_offsets.pack("N*")
61
+
62
+ io
63
+ end
64
+
65
+ # SbitLineMetrics (12 bytes): height, width, hori/vert bearings
66
+ def self.build_sbit_line_metrics(ppem, num_glyphs)
67
+ io = +""
68
+ io << [ppem & 0xFF, 0, ppem & 0xFF, 0, 0, 0, ppem & 0xFF, 0, 0, 0, 0].pack("C11")
69
+ io << [12 + num_glyphs * 8].pack("n")
70
+ io
71
+ end
72
+
73
+ # indexSubTableArray + indexSubTable (format 3, variable)
74
+ def self.build_index_sub_table_array(strike)
75
+ glyphs = strike[:glyphs] || []
76
+ num_glyphs = [glyphs.size, 1].max
77
+ # indexSubTableArray: firstGlyphID(2) + lastGlyphID(2) + additionalOffset(4)
78
+ array_header = [0, num_glyphs - 1, 8].pack("nnN")
79
+ # indexSubTable format 3: format(2) + imageFormat(2) + imageDataOffset(4)
80
+ # + bigGlyphMetrics(8) + offsetArray(numGlyphs × 4)
81
+ subtable = +""
82
+ subtable << [3, 1, 4 + 8 + num_glyphs * 4].pack("nnN")
83
+ subtable << [16, 16, 0, 12, 16, 8, 0, 16].pack("C8")
84
+ offsets = Array.new(num_glyphs) { |i| i * 100 }
85
+ subtable << offsets.pack("N*")
86
+ array_header + subtable
87
+ end
88
+
89
+ def self.serialize_strike(strike)
90
+ glyphs = strike[:glyphs] || []
91
+ io = +""
92
+ glyphs.each do |g|
93
+ next if g.nil?
94
+
95
+ io << [g[:origin_x] || 0, g[:origin_y] || 0].pack("nn")
96
+ io << (g[:data] || "")
97
+ end
98
+ io
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end