fontisan 0.4.45 → 0.4.47

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +1 -2
  3. data/lib/fontisan/cli.rb +1 -1
  4. data/lib/fontisan/commands/export_command.rb +3 -3
  5. data/lib/fontisan/export/exporter.rb +1 -1
  6. data/lib/fontisan/export/table_serializer.rb +36 -38
  7. data/lib/fontisan/pipeline/transformation_pipeline.rb +10 -8
  8. data/lib/fontisan/pipeline.rb +1 -1
  9. data/lib/fontisan/stitcher/partition_strategy/by_block.rb +1 -2
  10. data/lib/fontisan/tables/cff/standard_strings_data.rb +1 -2
  11. data/lib/fontisan/tables/glyf.rb +4 -6
  12. data/lib/fontisan/tables/gpos/glyph_filter.rb +87 -0
  13. data/lib/fontisan/tables/gpos/kerning_pairs.rb +215 -0
  14. data/lib/fontisan/tables/gpos/pair_position.rb +274 -0
  15. data/lib/fontisan/tables/gpos.rb +23 -0
  16. data/lib/fontisan/tables/post.rb +2 -4
  17. data/lib/fontisan/validators/opentype_validator.rb +13 -3
  18. data/lib/fontisan/variable/instancer.rb +22 -192
  19. data/lib/fontisan/variation/instance_generator.rb +3 -149
  20. data/lib/fontisan/variation/instancing/axis_space.rb +136 -0
  21. data/lib/fontisan/variation/instancing/glyph_outline.rb +204 -0
  22. data/lib/fontisan/variation/instancing/gvar_reader.rb +146 -0
  23. data/lib/fontisan/variation/instancing/item_variation_store.rb +92 -0
  24. data/lib/fontisan/variation/instancing/name_rewriter.rb +90 -0
  25. data/lib/fontisan/variation/instancing/true_type_instancer.rb +318 -0
  26. data/lib/fontisan/variation/instancing.rb +20 -0
  27. data/lib/fontisan/variation/variable_svg_generator.rb +19 -1
  28. data/lib/fontisan/variation.rb +1 -0
  29. data/lib/fontisan/version.rb +1 -1
  30. metadata +12 -3
  31. data/fontisan.gemspec +0 -48
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Variation
5
+ module Instancing
6
+ # Evaluates HVAR advance-width deltas (ItemVariationStore plus the
7
+ # optional DeltaSetIndexMap) at a normalized location.
8
+ class ItemVariationStore
9
+ # @param hvar [String] raw HVAR table
10
+ def initialize(hvar)
11
+ @hvar = hvar
12
+ store_offset, @advance_map_offset = hvar[4, 8].unpack("NN")
13
+ @store = hvar.byteslice(store_offset..)
14
+ region_list_offset, data_count = @store[2, 6].unpack("Nn")
15
+ @regions = parse_regions(region_list_offset)
16
+ @data_offsets = @store[8, data_count * 4].unpack("N*")
17
+ end
18
+
19
+ # @return [Float] advance-width delta for +gid+
20
+ def advance_delta(gid, coords)
21
+ outer, inner = delta_index(gid)
22
+ delta(outer, inner, coords)
23
+ end
24
+
25
+ private
26
+
27
+ def parse_regions(offset)
28
+ axis_count, region_count = @store[offset, 4].unpack("nn")
29
+ Array.new(region_count) do |r|
30
+ pos = offset + 4 + (r * axis_count * 6)
31
+ @store[pos, axis_count * 6].unpack("s>*").map { |v| v / 16384.0 }
32
+ .each_slice(3).to_a
33
+ end
34
+ end
35
+
36
+ def delta_index(gid)
37
+ return [0, gid] if @advance_map_offset.zero?
38
+
39
+ format, entry_format = @hvar[@advance_map_offset, 2].unpack("CC")
40
+ if format.zero?
41
+ count = @hvar[@advance_map_offset + 2, 2].unpack1("n")
42
+ pos = @advance_map_offset + 4
43
+ else
44
+ count = @hvar[@advance_map_offset + 2, 4].unpack1("N")
45
+ pos = @advance_map_offset + 6
46
+ end
47
+ inner_bits = (entry_format & 0x0F) + 1
48
+ size = ((entry_format & 0x30) >> 4) + 1
49
+ entry_gid = [gid, count - 1].min
50
+ entry = @hvar[pos + (entry_gid * size), size].bytes.inject(0) { |a, b| (a << 8) | b }
51
+ [entry >> inner_bits, entry & ((1 << inner_bits) - 1)]
52
+ end
53
+
54
+ def delta(outer, inner, coords)
55
+ offset = @data_offsets[outer]
56
+ return 0.0 unless offset
57
+
58
+ item_count, word_field, region_count = @store[offset, 6].unpack("nnn")
59
+ return 0.0 if inner >= item_count
60
+
61
+ long = word_field.anybits?(0x8000)
62
+ word_count = word_field & 0x7FFF
63
+ indexes = @store[offset + 6, region_count * 2].unpack("n*")
64
+ big = long ? 4 : 2
65
+ small = long ? 2 : 1
66
+ row_size = (word_count * big) + ((region_count - word_count) * small)
67
+ pos = offset + 6 + (region_count * 2) + (inner * row_size)
68
+ words = @store[pos, word_count * big].unpack(long ? "l>*" : "s>*")
69
+ rest = @store[pos + (word_count * big), (region_count - word_count) * small]
70
+ .unpack(long ? "s>*" : "c*")
71
+ (words + rest).each_with_index.sum do |d, i|
72
+ d * region_scalar(@regions[indexes[i]], coords)
73
+ end
74
+ end
75
+
76
+ def region_scalar(region, coords)
77
+ scalar = 1.0
78
+ region.each_with_index do |(lower, peak, upper), i|
79
+ v = coords[i]
80
+ next if peak.zero? || lower > peak || peak > upper
81
+ next if lower.negative? && upper.positive?
82
+ next if v == peak
83
+ return 0.0 if v <= lower || upper <= v
84
+
85
+ scalar *= v < peak ? (v - lower) / (peak - lower) : (upper - v) / (upper - peak)
86
+ end
87
+ scalar
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Variation
5
+ module Instancing
6
+ # Rewrites a name table so it describes one instance of a family.
7
+ class NameRewriter
8
+ Record = Struct.new(:platform, :encoding, :language, :name_id, :text)
9
+
10
+ # @param name_data [String] raw name table
11
+ def initialize(name_data)
12
+ @records = parse(name_data)
13
+ end
14
+
15
+ def lookup(name_id)
16
+ same = @records.select { |r| r.name_id == name_id }
17
+ rec = same.find { |r| r.platform == 3 && r.language == 0x409 } ||
18
+ same.find { |r| r.platform == 3 } || same.first
19
+ rec&.text
20
+ end
21
+
22
+ # Names the instance +label+ (e.g. "Light") of the family.
23
+ def rename(label)
24
+ family = lookup(16) || lookup(1) || "Font"
25
+ legacy = ["Regular", "Bold", "Italic", "Bold Italic"].include?(label)
26
+ full = "#{family} #{label}".strip
27
+ ps = "#{family}-#{label}".delete(" ")
28
+ set(1, legacy ? family : full)
29
+ set(2, legacy ? label : "Regular")
30
+ set(3, "#{lookup(5) || ''};#{ps}".delete_prefix(";"))
31
+ set(4, full)
32
+ set(6, ps)
33
+ set(16, family)
34
+ set(17, label)
35
+ @records.reject! { |r| r.name_id == 25 }
36
+ self
37
+ end
38
+
39
+ def to_binary
40
+ recs = @records.sort_by { |r| [r.platform, r.encoding, r.language, r.name_id] }
41
+ strings = +"".b
42
+ entries = recs.map do |r|
43
+ bin = encode(r)
44
+ off = strings.bytesize
45
+ strings << bin
46
+ [r.platform, r.encoding, r.language, r.name_id, bin.bytesize, off]
47
+ end
48
+ header = [0, recs.size, 6 + (recs.size * 12)].pack("nnn")
49
+ header + entries.map { |e| e.pack("n6") }.join + strings
50
+ end
51
+
52
+ private
53
+
54
+ def set(name_id, text)
55
+ same = @records.select { |r| r.name_id == name_id }
56
+ same.each { |r| r.text = text }
57
+ return if same.any? { |r| r.platform == 3 }
58
+
59
+ @records << Record.new(3, 1, 0x409, name_id, text)
60
+ end
61
+
62
+ def parse(data)
63
+ count, str_off = data[2, 4].unpack("nn")
64
+ recs = Array.new(count) do |i|
65
+ plat, enc, lang, nid, len, off = data[6 + (i * 12), 12].unpack("n6")
66
+ raw = data[str_off + off, len]
67
+ Record.new(plat, enc, lang, nid, decode(plat, raw))
68
+ end
69
+ recs.reject { |r| r.language >= 0x8000 }
70
+ end
71
+
72
+ def decode(platform, raw)
73
+ if [0, 3].include?(platform)
74
+ raw.dup.force_encoding("UTF-16BE").encode("UTF-8", invalid: :replace)
75
+ else
76
+ raw.dup.force_encoding("ISO-8859-1").encode("UTF-8")
77
+ end
78
+ end
79
+
80
+ def encode(rec)
81
+ if [0, 3].include?(rec.platform)
82
+ rec.text.encode("UTF-16BE").b
83
+ else
84
+ rec.text.encode("ISO-8859-1", invalid: :replace, undef: :replace).b
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Variation
5
+ module Instancing
6
+ # Produces a static TrueType font from a gvar-based variable font by
7
+ # applying gvar deltas (with IUP for untouched points) to glyf, HVAR or
8
+ # phantom-point deltas to hmtx, and updating OS/2 and name for the
9
+ # chosen location. cvar, GPOS/GDEF variations and MVAR are not applied.
10
+ class TrueTypeInstancer
11
+ DROPPED = %w[fvar gvar avar HVAR VVAR MVAR STAT cvar DSIG].freeze
12
+ WIDTH_CLASSES = [50, 62.5, 75, 87.5, 100, 112.5, 125, 150, 200].freeze
13
+
14
+ # @param tables [Hash{String=>String}] raw tables of the variable font
15
+ # @param coordinates [Hash{String=>Numeric}] axis tag => user value
16
+ def initialize(tables, coordinates)
17
+ @tables = tables
18
+ @coordinates = coordinates
19
+ check_font!
20
+ @space = AxisSpace.new(tables["fvar"], tables["avar"])
21
+ @location = @space.validate(coordinates)
22
+ @norm = @space.normalize(@location)
23
+ end
24
+
25
+ # @return [Hash{String=>String}] raw tables of the static instance
26
+ def call
27
+ num_glyphs = @tables["maxp"][4, 2].unpack1("n")
28
+ read_metrics(num_glyphs)
29
+ glyphs = Array.new(num_glyphs) { |gid| instance_glyph(gid) }
30
+ resolve_bounds(glyphs)
31
+ out = @tables.except(*DROPPED)
32
+ write_glyf_loca(out, glyphs)
33
+ write_hmtx(out, glyphs)
34
+ update_head_hhea(out, glyphs)
35
+ update_os2(out)
36
+ update_name(out)
37
+ out
38
+ end
39
+
40
+ private
41
+
42
+ Glyph = Struct.new(:outline, :points, :advance, :bbox, :data)
43
+
44
+ def check_font!
45
+ unless @tables["fvar"]
46
+ raise MissingVariationTableError.new(table: "fvar")
47
+ end
48
+ return if @tables["gvar"] && @tables["glyf"]
49
+
50
+ raise MissingVariationTableError.new(
51
+ table: "gvar",
52
+ message: "Static instancing needs TrueType outlines with gvar " \
53
+ "(CFF2 variable fonts are not supported yet)",
54
+ )
55
+ end
56
+
57
+ def read_metrics(num_glyphs)
58
+ nhm = @tables["hhea"][34, 2].unpack1("n")
59
+ hmtx = @tables["hmtx"]
60
+ @advances = []
61
+ @lsbs = []
62
+ num_glyphs.times do |gid|
63
+ if gid < nhm
64
+ @advances << hmtx[gid * 4, 2].unpack1("n")
65
+ @lsbs << hmtx[(gid * 4) + 2, 2].unpack1("s>")
66
+ else
67
+ @advances << @advances.last
68
+ @lsbs << hmtx[(nhm * 4) + ((gid - nhm) * 2), 2].unpack1("s>")
69
+ end
70
+ end
71
+ long = @tables["head"][50, 2].unpack1("s>") == 1
72
+ @loca = long ? @tables["loca"].unpack("N*") : @tables["loca"].unpack("n*").map { |v| v * 2 }
73
+ @gvar = GvarReader.new(@tables["gvar"])
74
+ @hvar = @tables["HVAR"] ? ItemVariationStore.new(@tables["HVAR"]) : nil
75
+ end
76
+
77
+ def raw_glyph(gid)
78
+ @tables["glyf"].byteslice(@loca[gid]...@loca[gid + 1]) || "".b
79
+ end
80
+
81
+ def instance_glyph(gid)
82
+ outline = GlyphOutline.parse(raw_glyph(gid))
83
+ orig = outline.points
84
+ n = orig.size
85
+ xmin = outline.empty? ? 0 : raw_glyph(gid)[2, 2].unpack1("s>")
86
+ pp1x = xmin - @lsbs[gid]
87
+ total = Array.new(n + 4) { [0.0, 0.0] }
88
+ @gvar.tuples_for(gid, n + 4).each do |t|
89
+ s = tuple_scalar(t)
90
+ next if s.zero?
91
+
92
+ dx, dy = expand_deltas(t, outline, orig)
93
+ (n + 4).times do |i|
94
+ total[i][0] += s * dx[i]
95
+ total[i][1] += s * dy[i]
96
+ end
97
+ end
98
+ pts = orig.each_with_index.map do |(x, y), i|
99
+ [round(x + total[i][0]), round(y + total[i][1])]
100
+ end
101
+ advance = if @hvar
102
+ @advances[gid] + round(@hvar.advance_delta(gid, @norm))
103
+ else
104
+ round(pp1x + @advances[gid] + total[n + 1][0]) -
105
+ round(pp1x + total[n][0])
106
+ end
107
+ Glyph.new(outline, pts, [advance, 0].max, nil, nil)
108
+ end
109
+
110
+ def round(value)
111
+ (value + 0.5).floor
112
+ end
113
+
114
+ def tuple_scalar(tuple)
115
+ scalar = 1.0
116
+ tuple.peak.each_with_index do |peak, i|
117
+ next if peak.zero?
118
+
119
+ v = @norm[i]
120
+ next if v == peak
121
+
122
+ lo, hi = if tuple.intermediate?
123
+ [tuple.start[i], tuple.end[i]]
124
+ else
125
+ [0.0, peak].minmax
126
+ end
127
+ next if lo > peak || peak > hi || (lo.negative? && hi.positive?)
128
+ return 0.0 if v <= lo || hi <= v
129
+
130
+ scalar *= v < peak ? (v - lo) / (peak - lo) : (hi - v) / (hi - peak)
131
+ end
132
+ scalar
133
+ end
134
+
135
+ # Full per-point delta arrays (length n + 4), IUP applied when only
136
+ # some points were given.
137
+ def expand_deltas(tuple, outline, orig)
138
+ total = orig.size + 4
139
+ return [tuple.dx, tuple.dy] if tuple.points.nil?
140
+
141
+ dx = Array.new(total)
142
+ dy = Array.new(total)
143
+ tuple.points.each_with_index do |p, i|
144
+ next if p >= total
145
+
146
+ dx[p] = tuple.dx[i]
147
+ dy[p] = tuple.dy[i]
148
+ end
149
+ unless outline.composite?
150
+ iup(orig.map(&:first), dx, outline.end_pts)
151
+ iup(orig.map(&:last), dy, outline.end_pts)
152
+ end
153
+ [dx.map { |v| v || 0 }, dy.map { |v| v || 0 }]
154
+ end
155
+
156
+ def iup(coords, deltas, ends)
157
+ start = 0
158
+ ends.each do |last|
159
+ touched = (start..last).select { |i| deltas[i] }
160
+ if touched.empty?
161
+ (start..last).each { |i| deltas[i] = 0 }
162
+ elsif touched.size == 1
163
+ (start..last).each { |i| deltas[i] = deltas[touched.first] }
164
+ else
165
+ touched.each_with_index do |t, k|
166
+ nxt = touched[(k + 1) % touched.size]
167
+ i = t
168
+ loop do
169
+ i = i == last ? start : i + 1
170
+ break if i == nxt
171
+
172
+ deltas[i] = iup_value(coords[i], coords[t], deltas[t],
173
+ coords[nxt], deltas[nxt])
174
+ end
175
+ end
176
+ end
177
+ start = last + 1
178
+ end
179
+ end
180
+
181
+ def iup_value(coord, ref1, delta1, ref2, delta2)
182
+ return (delta1 == delta2 ? delta1 : 0) if ref1 == ref2
183
+
184
+ ref1, delta1, ref2, delta2 = ref2, delta2, ref1, delta1 if ref1 > ref2
185
+ return delta1 if coord <= ref1
186
+ return delta2 if coord >= ref2
187
+
188
+ delta1 + ((coord - ref1) * (delta2 - delta1) / (ref2 - ref1).to_f)
189
+ end
190
+
191
+ # Bounding boxes; composites derive theirs from their components.
192
+ def resolve_bounds(glyphs)
193
+ glyphs.each_index { |gid| bbox_for(glyphs, gid, []) }
194
+ end
195
+
196
+ def bbox_for(glyphs, gid, stack)
197
+ g = glyphs[gid]
198
+ return g.bbox if g.bbox
199
+ return g.bbox = [0, 0, 0, 0] if g.outline.empty? || stack.include?(gid)
200
+
201
+ if g.outline.composite?
202
+ corners = []
203
+ g.outline.components.each_with_index do |c, i|
204
+ sub = bbox_for(glyphs, c.gid, stack + [gid])
205
+ next if glyphs[c.gid].outline.empty?
206
+
207
+ corners.concat(transform_corners(sub, c, g.points[i]))
208
+ end
209
+ g.bbox = corners.empty? ? [0, 0, 0, 0] : g.outline.bounds_of(corners).map(&:round)
210
+ else
211
+ g.bbox = g.outline.bounds_of(g.points)
212
+ end
213
+ end
214
+
215
+ def transform_corners(bbox, comp, offset)
216
+ xmin, ymin, xmax, ymax = bbox
217
+ t = comp.transform
218
+ a, b, c, d = case t.bytesize
219
+ when 2 then s = t.unpack1("s>") / 16384.0
220
+ [s, 0, 0, s]
221
+ when 4 then sx, sy = t.unpack("s>s>").map { |v| v / 16384.0 }
222
+ [sx, 0, 0, sy]
223
+ when 8 then xs, s01, s10, ys = t.unpack("s>4").map { |v| v / 16384.0 }
224
+ [xs, s01, s10, ys]
225
+ else [1, 0, 0, 1]
226
+ end
227
+ [[xmin, ymin], [xmin, ymax], [xmax, ymin], [xmax, ymax]].map do |x, y|
228
+ [(a * x) + (c * y) + offset[0], (b * x) + (d * y) + offset[1]]
229
+ end
230
+ end
231
+
232
+ def write_glyf_loca(out, glyphs)
233
+ glyf = +"".b
234
+ loca = [0]
235
+ glyphs.each do |g|
236
+ data = g.outline.serialize(g.points, g.bbox)
237
+ data << "\0".b until (data.bytesize % 4).zero?
238
+ glyf << data
239
+ loca << glyf.bytesize
240
+ end
241
+ out["glyf"] = glyf
242
+ out["loca"] = loca.pack("N*")
243
+ head = out["head"].dup
244
+ head[50, 2] = [1].pack("s>")
245
+ out["head"] = head
246
+ end
247
+
248
+ def write_hmtx(out, glyphs)
249
+ hmtx = +"".b
250
+ glyphs.each_with_index do |g, gid|
251
+ lsb = g.outline.empty? ? @lsbs[gid] : g.bbox[0]
252
+ g.data = [g.advance, lsb]
253
+ hmtx << [g.advance, lsb].pack("ns>")
254
+ end
255
+ out["hmtx"] = hmtx
256
+ hhea = out["hhea"].dup
257
+ hhea[34, 2] = [glyphs.size].pack("n")
258
+ out["hhea"] = hhea
259
+ end
260
+
261
+ def update_head_hhea(out, glyphs)
262
+ drawn = glyphs.reject { |g| g.outline.empty? }
263
+ return if drawn.empty?
264
+
265
+ head = out["head"].dup
266
+ head[36, 8] = [drawn.map { |g| g.bbox[0] }.min, drawn.map { |g| g.bbox[1] }.min,
267
+ drawn.map { |g| g.bbox[2] }.max, drawn.map { |g| g.bbox[3] }.max].pack("s>4")
268
+ out["head"] = head
269
+ hhea = out["hhea"].dup
270
+ min_lsb = drawn.map { |g| g.data[1] }.min
271
+ min_rsb = drawn.map { |g| g.data[0] - g.data[1] - (g.bbox[2] - g.bbox[0]) }.min
272
+ max_ext = drawn.map { |g| g.data[1] + (g.bbox[2] - g.bbox[0]) }.max
273
+ hhea[10, 8] = [glyphs.map(&:advance).max, min_lsb, min_rsb, max_ext].pack("ns>s>s>")
274
+ out["hhea"] = hhea
275
+ end
276
+
277
+ def update_os2(out)
278
+ return unless out["OS/2"]
279
+
280
+ os2 = out["OS/2"].dup
281
+ if @location.key?("wght")
282
+ os2[4, 2] = [@location["wght"].round.clamp(1, 1000)].pack("n")
283
+ end
284
+ if @location.key?("wdth")
285
+ nearest = WIDTH_CLASSES.each_index.min_by { |i| (WIDTH_CLASSES[i] - @location["wdth"]).abs }
286
+ os2[6, 2] = [nearest + 1].pack("n")
287
+ end
288
+ out["OS/2"] = os2
289
+ end
290
+
291
+ def update_name(out)
292
+ return unless out["name"]
293
+
294
+ rewriter = NameRewriter.new(out["name"])
295
+ out["name"] = rewriter.rename(instance_label(rewriter)).to_binary
296
+ end
297
+
298
+ def instance_label(rewriter)
299
+ match = AxisSpace.named_instances(@tables["fvar"]).find do |inst|
300
+ @space.tags.each_with_index.all? do |tag, i|
301
+ (inst[:values][i] - @location[tag]).abs < 1e-6
302
+ end
303
+ end
304
+ named = match && rewriter.lookup(match[:name_id])
305
+ return named if named
306
+
307
+ parts = @space.axes.reject { |a| @location[a.tag] == a.default }
308
+ .map { |a| "#{a.tag.capitalize}#{format_value(@location[a.tag])}" }
309
+ parts.empty? ? "Regular" : parts.join(" ")
310
+ end
311
+
312
+ def format_value(value)
313
+ value == value.to_i ? value.to_i.to_s : value.to_s
314
+ end
315
+ end
316
+ end
317
+ end
318
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Autoload hub for the Fontisan::Variation::Instancing namespace.
4
+
5
+ module Fontisan
6
+ module Variation
7
+ # Static instancing of TrueType variable fonts: gvar/HVAR delta
8
+ # application, glyf/hmtx rebuilding and OS/2 + name updates.
9
+ module Instancing
10
+ autoload :AxisSpace, "fontisan/variation/instancing/axis_space"
11
+ autoload :GlyphOutline, "fontisan/variation/instancing/glyph_outline"
12
+ autoload :GvarReader, "fontisan/variation/instancing/gvar_reader"
13
+ autoload :ItemVariationStore,
14
+ "fontisan/variation/instancing/item_variation_store"
15
+ autoload :NameRewriter, "fontisan/variation/instancing/name_rewriter"
16
+ autoload :TrueTypeInstancer,
17
+ "fontisan/variation/instancing/true_type_instancer"
18
+ end
19
+ end
20
+ end
@@ -107,6 +107,20 @@ module Fontisan
107
107
  # Returns all axes at their default values.
108
108
  #
109
109
  # @return [Hash<String, Float>] Default coordinates
110
+ # Out-of-range requests are pulled to the nearest axis limit and
111
+ # unknown axes dropped, so SVG preview stays tolerant.
112
+ def clamp_to_axes(coords)
113
+ return coords unless @font.has_table?("fvar")
114
+
115
+ by_tag = @font.table("fvar").axes.to_h { |a| [a.axis_tag.to_s, a] }
116
+ coords.each_with_object({}) do |(tag, value), clamped|
117
+ axis = by_tag[tag.to_s]
118
+ next unless axis
119
+
120
+ clamped[tag.to_s] = Float(value).clamp(axis.min_value, axis.max_value)
121
+ end
122
+ end
123
+
110
124
  def default_coordinates
111
125
  return {} unless @font.has_table?("fvar")
112
126
 
@@ -164,7 +178,11 @@ module Fontisan
164
178
  # @return [Hash<String, String>] Instance tables
165
179
  def generate_static_instance
166
180
  # Use coordinates or defaults if none specified
167
- coords = @coordinates.empty? ? default_coordinates : @coordinates
181
+ coords = if @coordinates.empty?
182
+ default_coordinates
183
+ else
184
+ clamp_to_axes(@coordinates)
185
+ end
168
186
 
169
187
  instance_generator = InstanceGenerator.new(@font, coords)
170
188
  instance_generator.generate
@@ -15,6 +15,7 @@ module Fontisan
15
15
  autoload :InstanceGenerator, "fontisan/variation/instance_generator"
16
16
  autoload :InstanceFontWrapper, "fontisan/variation/instance_font_wrapper"
17
17
  autoload :InstanceWriter, "fontisan/variation/instance_writer"
18
+ autoload :Instancing, "fontisan/variation/instancing"
18
19
  autoload :Interpolator, "fontisan/variation/interpolator"
19
20
  autoload :MetricsAdjuster, "fontisan/variation/metrics_adjuster"
20
21
  autoload :Optimizer, "fontisan/variation/optimizer"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.45"
4
+ VERSION = "0.4.47"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontisan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.45
4
+ version: 0.4.47
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-21 00:00:00.000000000 Z
11
+ date: 2026-11-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -322,7 +322,6 @@ files:
322
322
  - docs/public/web-app-manifest-512x512.png
323
323
  - docs/scripts/post-build.mjs
324
324
  - exe/fontisan
325
- - fontisan.gemspec
326
325
  - lib/fontisan.rb
327
326
  - lib/fontisan/audit.rb
328
327
  - lib/fontisan/audit/check.rb
@@ -644,6 +643,9 @@ files:
644
643
  - lib/fontisan/tables/glyf/simple_glyph.rb
645
644
  - lib/fontisan/tables/glyf_table.rb
646
645
  - lib/fontisan/tables/gpos.rb
646
+ - lib/fontisan/tables/gpos/glyph_filter.rb
647
+ - lib/fontisan/tables/gpos/kerning_pairs.rb
648
+ - lib/fontisan/tables/gpos/pair_position.rb
647
649
  - lib/fontisan/tables/gsub.rb
648
650
  - lib/fontisan/tables/gvar.rb
649
651
  - lib/fontisan/tables/head.rb
@@ -825,6 +827,13 @@ files:
825
827
  - lib/fontisan/variation/instance_font_wrapper.rb
826
828
  - lib/fontisan/variation/instance_generator.rb
827
829
  - lib/fontisan/variation/instance_writer.rb
830
+ - lib/fontisan/variation/instancing.rb
831
+ - lib/fontisan/variation/instancing/axis_space.rb
832
+ - lib/fontisan/variation/instancing/glyph_outline.rb
833
+ - lib/fontisan/variation/instancing/gvar_reader.rb
834
+ - lib/fontisan/variation/instancing/item_variation_store.rb
835
+ - lib/fontisan/variation/instancing/name_rewriter.rb
836
+ - lib/fontisan/variation/instancing/true_type_instancer.rb
828
837
  - lib/fontisan/variation/interpolator.rb
829
838
  - lib/fontisan/variation/metrics_adjuster.rb
830
839
  - lib/fontisan/variation/optimizer.rb
data/fontisan.gemspec DELETED
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/fontisan/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "fontisan"
7
- spec.version = Fontisan::VERSION
8
- spec.authors = ["Ribose Inc."]
9
- spec.email = ["open.source@ribose.com"]
10
-
11
- spec.summary = "Font analysis tools and utilities for OpenType fonts"
12
- spec.description = <<~HEREDOC
13
- Fontisan provides font analysis tools and utilities. It is
14
- designed as a pure Ruby implementation with full object-oriented architecture,
15
- supporting extraction of information from OpenType and TrueType fonts (OTF, TTF, OTC, TTC).
16
-
17
- The gem provides both a Ruby library API and a command-line interface,
18
- with structured output formats (YAML, JSON, text).
19
- HEREDOC
20
-
21
- spec.homepage = "https://github.com/fontist/fontisan"
22
- spec.license = "BSD-2-Clause"
23
- spec.required_ruby_version = ">= 3.0.0"
24
-
25
- spec.metadata["homepage_uri"] = spec.homepage
26
- spec.metadata["source_code_uri"] = "https://github.com/fontist/fontisan"
27
- spec.metadata["changelog_uri"] = "https://github.com/fontist/fontisan/blob/main/CHANGELOG.md"
28
- spec.metadata["rubygems_mfa_required"] = "true"
29
-
30
- # Specify which files should be added to the gem when it is released.
31
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
32
- spec.files = Dir.chdir(__dir__) do
33
- `git ls-files -z`.split("\x0").reject do |f|
34
- (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
35
- end
36
- end
37
- spec.bindir = "exe"
38
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
39
- spec.require_paths = ["lib"]
40
-
41
- spec.add_dependency "base64"
42
- spec.add_dependency "bindata", "~> 2.5"
43
- spec.add_dependency "brotli", "~> 0.5"
44
- spec.add_dependency "logger"
45
- spec.add_dependency "lutaml-model", "~> 0.8"
46
- spec.add_dependency "nokogiri", "~> 1.16"
47
- spec.add_dependency "thor", "~> 1.3"
48
- end