fontisan 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b68479ccaac604474e929d9dc4b3de24ec1c79f53e187fd7394cafdf3e02a11
4
- data.tar.gz: 27c7dd8fd5afed7656aaaba9a57ea52c78144d66a4346bd4d4b0285c6327c176
3
+ metadata.gz: 999173c7fe3caa0568c7bc29b87184b1f167c5885b5e66c9c7c7f0f884d2523e
4
+ data.tar.gz: a9bf5e401b1b08672ced56f71b7ff5d6ab6b60e83b4ba1932ef43582c5d3a528
5
5
  SHA512:
6
- metadata.gz: 55d3133ec31fb397c671039a8181c5182b62a9cac0108df9a45d551b5def47475d2edbef7e9d921727ad812734351234da82f71c7b8fd1314a16dab83261aa6a
7
- data.tar.gz: 4a59af9b12d398575f45018511eeeaea0e49b827efd223f3052ff48f12053087f0e0749498a6be3049f5adb8c880e8461c28167ae7601a7d3dbcaa403e7380e1
6
+ metadata.gz: 9b770784dbac538d4d3998b1e908eedd5cb010f72897a0dc94b28e32f1150c6fdb99d384703760360dd1b01855de725ef786c34b173854ca8fd869b23ad6a8d6
7
+ data.tar.gz: 5b64092d77f6c19fc2e40fe40114722652000c49a36b22ae0dacccb86236d5529fe7d9c183bc4bd3141998d44a59b44ae2d5954bf675bb86fb489ff380a7b490
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `fvar` (Font Variations) table from a
7
+ # list of axis definitions and named instances.
8
+ #
9
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/fvar
10
+ module Fvar
11
+ VERSION_MAJOR = 1
12
+ VERSION_MINOR = 0
13
+ AXIS_SIZE = 20
14
+ INSTANCE_SIZE = 4 # + 4 per axis
15
+ FIXED_SHIFT = 16
16
+ FIXED_ONE = 1 << FIXED_SHIFT
17
+
18
+ # @param font [Fontisan::Ufo::Font]
19
+ # @param axes [Array<Hash>] axis definitions
20
+ # @param instances [Array<Hash>] named instance definitions
21
+ # @return [String, nil] fvar table bytes, or nil if no axes
22
+ def self.build(font, axes: nil, instances: nil)
23
+ axes ||= font.info.respond_to?(:axes) ? font.info.axes : nil
24
+ instances ||= font.info.respond_to?(:named_instances) ? font.info.named_instances : nil
25
+
26
+ axes ||= []
27
+ return nil if axes.empty?
28
+
29
+ axes_bytes = build_axes(axes)
30
+ instances_bytes = build_instances(instances || [], axes.size)
31
+ header = build_header(axes.size, axes_bytes, instances_bytes, instances)
32
+ header + axes_bytes + instances_bytes
33
+ end
34
+
35
+ # ---------- header ----------
36
+
37
+ # Header is 16 bytes:
38
+ # majorVersion (2) + minorVersion (2) + axesArrayOffset (2)
39
+ # + reserved (2) + axisCount (2) + axisSize (2)
40
+ # + instanceCount (2) + instanceSize (2)
41
+ def self.build_header(axis_count, axes_bytes, _instances_bytes, instances)
42
+ axes_offset = 16
43
+ axes_offset + axes_bytes.bytesize
44
+
45
+ [
46
+ VERSION_MAJOR,
47
+ VERSION_MINOR,
48
+ axes_offset,
49
+ 0, # reserved
50
+ axis_count,
51
+ AXIS_SIZE,
52
+ instances&.size || 0,
53
+ (instances&.size || 0).zero? ? 0 : (INSTANCE_SIZE + axis_count * 4),
54
+ ].pack("nnnnnnnn")
55
+ end
56
+
57
+ # ---------- axes ----------
58
+
59
+ # Each axis record is 20 bytes:
60
+ # axisTag (4) + minValue (4) + defaultValue (4) + maxValue (4)
61
+ # + flags (2) + axisNameID (2)
62
+ def self.build_axes(axes)
63
+ io = +""
64
+ axes.each do |axis|
65
+ tag = (axis[:tag] || axis["tag"] || "wght").to_s.ljust(4, " ")[0, 4]
66
+ min = fixed_value(axis[:min] || axis["min"] || 100)
67
+ default = fixed_value(axis[:default] || axis["default"] || 400)
68
+ max = fixed_value(axis[:max] || axis["max"] || 900)
69
+ flags = axis[:flags] || axis["flags"] || 0
70
+ name_id = axis[:name_id] || axis["name_id"] || 0
71
+
72
+ io << tag
73
+ io << [min, default, max, flags, name_id].pack("NNNnn")
74
+ end
75
+ io
76
+ end
77
+
78
+ # ---------- instances ----------
79
+
80
+ # Each instance record: subfamilyNameID (2) + flags (2) + per-axis coords
81
+ def self.build_instances(instances, axis_count)
82
+ return +"" if instances.empty?
83
+
84
+ io = +""
85
+ instances.each do |inst|
86
+ name_id = inst[:name_id] || inst["name_id"] || 0
87
+ flags = inst[:flags] || inst["flags"] || 0
88
+ coords = inst[:coords] || inst["coords"] || []
89
+ padded = coords.first(axis_count) + Array.new([axis_count - coords.size, 0].max, 0)
90
+ padded = padded.first(axis_count).map { |c| fixed_value(c) }
91
+
92
+ io << [name_id, flags].pack("nn")
93
+ io << padded.pack("N*")
94
+ end
95
+ io
96
+ end
97
+
98
+ # ---------- helpers ----------
99
+
100
+ # Convert a float to OpenType Fixed 16.16 (int32).
101
+ def self.fixed_value(value)
102
+ (value.to_f * FIXED_ONE).to_i
103
+ end
104
+ private_class_method :build_header, :build_axes, :build_instances,
105
+ :fixed_value
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `gvar` (Glyph Variation Data) table from
7
+ # per-glyph deltas computed between a default master and one or
8
+ # more extreme masters.
9
+ #
10
+ # This is the hardest table in the variable-font specification.
11
+ # It stores, for every glyph, how each outline point moves
12
+ # between the default master and each extreme master.
13
+ #
14
+ # This builder produces a minimal-but-valid gvar:
15
+ # - One tuple per master (no shared tuples)
16
+ # - Explicit per-point deltas (no IUP compression)
17
+ # - Delta values encoded as int8 when possible, int16 otherwise
18
+ #
19
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/gvar
20
+ module Gvar
21
+ VERSION_MAJOR = 1
22
+ VERSION_MINOR = 0
23
+
24
+ # TupleIndex flags
25
+ TUPLE_SHARED = 0x8000
26
+ TUPLE_PRIVATE = 0x4000
27
+ TUPLE_AXIS_COUNT_MASK = 0x0FFF
28
+
29
+ # Delta encoding flags (in the delta data header)
30
+ DELTAS_ARE_ZERO = 0x80
31
+ DELTAS_ARE_INT8 = 0x40
32
+ POINT_COUNT_MASK = 0x3FFF
33
+
34
+ # @param default_glyphs [Array<Fontisan::Ufo::Glyph>] default master
35
+ # @param masters [Array<Hash>] extreme masters
36
+ # Each hash: { axes: { tag: peak_value }, glyphs: [Glyph, ...] }
37
+ # @param axis_count [Integer] number of axes
38
+ # @return [String] gvar table bytes
39
+ def self.build(default_glyphs:, masters:, axis_count:)
40
+ glyph_count = default_glyphs.size
41
+ return build_empty(glyph_count) if masters.empty? || glyph_count.zero?
42
+
43
+ # Compute per-glyph variation data
44
+ glyph_data = Array.new(glyph_count) do |gid|
45
+ build_glyph_variation(default_glyphs[gid], masters, gid, axis_count)
46
+ end
47
+
48
+ assemble(glyph_count, axis_count, glyph_data)
49
+ end
50
+
51
+ # ---------- per-glyph variation ----------
52
+
53
+ # Build the tuple variation data for a single glyph.
54
+ # Returns the serialized bytes (tuple headers + delta data).
55
+ # Returns empty string if the glyph has no variation.
56
+ def self.build_glyph_variation(default_glyph, masters, gid, axis_count)
57
+ return +"" unless default_glyph
58
+
59
+ tuples = []
60
+ masters.each do |master|
61
+ master_glyph = master[:glyphs]&.dig(gid)
62
+ next unless master_glyph
63
+
64
+ deltas = compute_deltas(default_glyph, master_glyph)
65
+ next if deltas.all? { |d| d[0].zero? && d[1].zero? }
66
+
67
+ tuples << {
68
+ peak: master[:axes] || {},
69
+ deltas: deltas,
70
+ }
71
+ end
72
+
73
+ return +"" if tuples.empty?
74
+
75
+ serialize_tuples(tuples, axis_count)
76
+ end
77
+
78
+ # Compute per-point deltas (dx, dy) between default and master.
79
+ # @return [Array<[Integer, Integer]>] deltas for each point
80
+ def self.compute_deltas(default_glyph, master_glyph)
81
+ default_points = default_glyph.contours.flat_map(&:points)
82
+ master_points = master_glyph.contours.flat_map(&:points)
83
+
84
+ count = [default_points.size, master_points.size].min
85
+ Array.new(count) do |i|
86
+ dx = master_points[i].x.to_i - default_points[i].x.to_i
87
+ dy = master_points[i].y.to_i - default_points[i].y.to_i
88
+ [dx, dy]
89
+ end
90
+ end
91
+
92
+ # ---------- tuple serialization ----------
93
+
94
+ def self.serialize_tuples(tuples, axis_count)
95
+ tuple_entries = tuples.map { |t| serialize_tuple(t, axis_count) }
96
+ tuple_count = tuple_entries.size
97
+
98
+ io = +""
99
+ io << [tuple_count].pack("n") # tupleVariationCount
100
+ io << [4 + tuple_count * 4].pack("n") # dataOffset (after header + tuple headers)
101
+ # Wait — dataOffset is from start of per-glyph data to the delta data area.
102
+ # The tuple headers come first, then the delta data.
103
+ # Let me recalculate:
104
+ tuple_headers_size = tuple_entries.sum { |e| e[:header].bytesize }
105
+ data_offset = 4 + tuple_headers_size
106
+
107
+ io = +""
108
+ io << [tuple_count].pack("n")
109
+ io << [data_offset].pack("n")
110
+
111
+ tuple_entries.each do |e|
112
+ io << e[:header]
113
+ io << e[:data]
114
+ end
115
+
116
+ io
117
+ end
118
+
119
+ def self.serialize_tuple(tuple, axis_count)
120
+ peak = tuple[:peak]
121
+ deltas = tuple[:deltas]
122
+
123
+ # Encode delta data
124
+ all_fit_int8 = deltas.all? { |dx, dy| dx.between?(-127, 127) && dy.between?(-127, 127) }
125
+
126
+ point_count = deltas.size
127
+ flags = point_count & POINT_COUNT_MASK
128
+ flags |= DELTAS_ARE_INT8 if all_fit_int8
129
+
130
+ data = +""
131
+ data << [flags].pack("n")
132
+
133
+ if all_fit_int8
134
+ deltas.each do |dx, dy|
135
+ data << [dx & 0xFF, dy & 0xFF].pack("CC")
136
+ end
137
+ else
138
+ deltas.each do |dx, dy|
139
+ data << [dx & 0xFFFF, dy & 0xFFFF].pack("nn")
140
+ end
141
+ end
142
+
143
+ # Encode tuple header
144
+ tuple_index = TUPLE_PRIVATE | (axis_count & TUPLE_AXIS_COUNT_MASK)
145
+
146
+ header = +""
147
+ header << [data.bytesize].pack("n") # variationDataSize
148
+ header << [tuple_index].pack("n") # tupleIndex
149
+
150
+ # Peak axis coordinates (F2DOT14 per axis)
151
+ axis_tags = peak.keys.sort
152
+ axis_tags.first(axis_count).each do |tag|
153
+ header << [f2dot14(peak[tag] || 0)].pack("n")
154
+ end
155
+
156
+ { header: header, data: data }
157
+ end
158
+
159
+ # ---------- gvar table assembly ----------
160
+
161
+ def self.assemble(glyph_count, axis_count, glyph_data)
162
+ # Build the glyph variation data offset array
163
+ offsets = [0]
164
+ current = 0
165
+ glyph_data.each do |data|
166
+ current += data.bytesize
167
+ offsets << current
168
+ end
169
+
170
+ # Offset size: uint16 (flags bit 0 = 0) or uint32 (bit 0 = 1)
171
+ use_long = offsets.last > 0xFFFF
172
+ flags = use_long ? 1 : 0
173
+
174
+ # Build header (20 bytes for v1)
175
+ header = +""
176
+ header << [VERSION_MAJOR, VERSION_MINOR].pack("nn") # version
177
+ header << [axis_count].pack("n") # axisCount
178
+ header << [0].pack("n") # sharedTupleCount
179
+ header << [0].pack("N") # offsetToSharedTuples
180
+ header << [glyph_count].pack("n") # glyphCount
181
+ header << [flags].pack("n") # flags (bit 0 = offset size)
182
+
183
+ header_size = header.bytesize
184
+ offset_entry_size = use_long ? 4 : 2
185
+ offset_array_size = (glyph_count + 1) * offset_entry_size
186
+ header_size + offset_array_size
187
+
188
+ # Offset array (relative to data_start)
189
+ if use_long
190
+ header + offsets.pack("N*") + glyph_data.join
191
+ else
192
+ header + offsets.pack("n*") + glyph_data.join
193
+ end
194
+ end
195
+
196
+ def self.build_empty(glyph_count)
197
+ # Minimal gvar with no variation data
198
+ header = [VERSION_MAJOR, VERSION_MINOR, 0, 0, 0, glyph_count, 1, glyph_count * 1 + 16].pack("nnnnnnNN")
199
+ offsets = Array.new(glyph_count + 1, 0).pack("C*")
200
+ header + offsets
201
+ end
202
+
203
+ # ---------- helpers ----------
204
+
205
+ def self.f2dot14(value)
206
+ (value.to_f * 16384).to_i
207
+ end
208
+
209
+ def self.byte_size_for(max_value)
210
+ return 1 if max_value <= 0xFF
211
+ return 2 if max_value <= 0xFFFF
212
+ return 3 if max_value <= 0xFFFFFF
213
+
214
+ 4
215
+ end
216
+ private_class_method :build_glyph_variation, :compute_deltas,
217
+ :serialize_tuples, :serialize_tuple,
218
+ :assemble, :build_empty, :f2dot14, :byte_size_for
219
+ end
220
+ end
221
+ end
222
+ end
@@ -33,7 +33,9 @@ module Fontisan
33
33
  autoload :TtfCompiler, "fontisan/ufo/compile/ttf_compiler"
34
34
  autoload :OtfCompiler, "fontisan/ufo/compile/otf_compiler"
35
35
  autoload :Filters, "fontisan/ufo/compile/filters"
36
+ autoload :Fvar, "fontisan/ufo/compile/fvar"
36
37
  autoload :Gpos, "fontisan/ufo/compile/gpos"
38
+ autoload :Gvar, "fontisan/ufo/compile/gvar"
37
39
  autoload :Head, "fontisan/ufo/compile/head"
38
40
  autoload :Hhea, "fontisan/ufo/compile/hhea"
39
41
  autoload :Maxp, "fontisan/ufo/compile/maxp"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.3"
4
+ VERSION = "0.4.5"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontisan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.3
4
+ version: 0.4.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -563,8 +563,10 @@ files:
563
563
  - lib/fontisan/ufo/compile/filters/decompose_components.rb
564
564
  - lib/fontisan/ufo/compile/filters/flatten_components.rb
565
565
  - lib/fontisan/ufo/compile/filters/reverse_contour_direction.rb
566
+ - lib/fontisan/ufo/compile/fvar.rb
566
567
  - lib/fontisan/ufo/compile/glyf_loca.rb
567
568
  - lib/fontisan/ufo/compile/gpos.rb
569
+ - lib/fontisan/ufo/compile/gvar.rb
568
570
  - lib/fontisan/ufo/compile/head.rb
569
571
  - lib/fontisan/ufo/compile/hhea.rb
570
572
  - lib/fontisan/ufo/compile/hmtx.rb