fontisan 0.4.46 → 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.
@@ -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.46"
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.46
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-10-15 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
@@ -827,6 +827,13 @@ files:
827
827
  - lib/fontisan/variation/instance_font_wrapper.rb
828
828
  - lib/fontisan/variation/instance_generator.rb
829
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
830
837
  - lib/fontisan/variation/interpolator.rb
831
838
  - lib/fontisan/variation/metrics_adjuster.rb
832
839
  - lib/fontisan/variation/optimizer.rb