fontisan 0.4.46 → 0.4.48

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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/BENCHMARKS.md +56 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Cargo.lock +550 -0
  5. data/Cargo.toml +12 -0
  6. data/Gemfile +10 -0
  7. data/README.adoc +52 -0
  8. data/Rakefile +5 -1
  9. data/benchmark/baseline.json +5 -0
  10. data/benchmark/ci_gate.rb +217 -0
  11. data/benchmark/corpus/harness.rb +175 -0
  12. data/benchmark/corpus/ops.rb +150 -0
  13. data/benchmark/corpus/run.rb +182 -0
  14. data/benchmark/corpus/scan_child.rb +8 -0
  15. data/benchmark/corpus/tiers.rb +176 -0
  16. data/benchmark/tier_baseline.json +12 -0
  17. data/docs/.vitepress/config.ts +2 -1
  18. data/docs/guide/engines.md +117 -0
  19. data/docs/reference-facade.md +130 -0
  20. data/ext/fontisan_cdylib/Cargo.lock +313 -0
  21. data/ext/fontisan_cdylib/Cargo.toml +18 -0
  22. data/ext/fontisan_cdylib/build.rs +22 -0
  23. data/ext/fontisan_cdylib/extconf.rb +36 -0
  24. data/ext/fontisan_cdylib/src/lib.rs +13 -0
  25. data/ext/fontisan_native/Cargo.toml +21 -0
  26. data/ext/fontisan_native/build.rs +22 -0
  27. data/ext/fontisan_native/clippy.toml +10 -0
  28. data/ext/fontisan_native/extconf.rb +59 -0
  29. data/ext/fontisan_native/src/lib.rs +463 -0
  30. data/lib/fontisan/audit/checks/cmap_check.rb +7 -0
  31. data/lib/fontisan/audit/checks/hinting_check.rb +1 -1
  32. data/lib/fontisan/audit/checks/maxp_check.rb +1 -1
  33. data/lib/fontisan/audit/checks/opentype_conformance_check.rb +5 -2
  34. data/lib/fontisan/base_collection.rb +30 -0
  35. data/lib/fontisan/byte_ops.rb +219 -0
  36. data/lib/fontisan/cli.rb +5 -5
  37. data/lib/fontisan/commands/audit_command.rb +38 -43
  38. data/lib/fontisan/commands/convert_command.rb +53 -0
  39. data/lib/fontisan/commands/instance_command.rb +75 -0
  40. data/lib/fontisan/commands/subset_command.rb +43 -0
  41. data/lib/fontisan/commands/unpack_command.rb +56 -0
  42. data/lib/fontisan/converters/cff_table_builder.rb +1 -1
  43. data/lib/fontisan/converters/collection_converter.rb +13 -5
  44. data/lib/fontisan/converters/format_converter.rb +1 -1
  45. data/lib/fontisan/converters/woff2_encoder.rb +8 -6
  46. data/lib/fontisan/converters/woff_writer.rb +6 -5
  47. data/lib/fontisan/engine.rb +350 -0
  48. data/lib/fontisan/ffi/client.rb +121 -0
  49. data/lib/fontisan/ffi.rb +343 -0
  50. data/lib/fontisan/ffi_platform.rb +24 -0
  51. data/lib/fontisan/font_loader.rb +5 -1
  52. data/lib/fontisan/font_writer.rb +13 -1
  53. data/lib/fontisan/native.rb +231 -0
  54. data/lib/fontisan/pipeline/transformation_pipeline.rb +10 -8
  55. data/lib/fontisan/pipeline.rb +1 -1
  56. data/lib/fontisan/reference/table_digests.rb +251 -0
  57. data/lib/fontisan/reference.rb +619 -0
  58. data/lib/fontisan/scan.rb +372 -0
  59. data/lib/fontisan/sfnt_directory.rb +41 -0
  60. data/lib/fontisan/sfnt_font.rb +16 -6
  61. data/lib/fontisan/subset/builder.rb +2 -1
  62. data/lib/fontisan/tables/cff/charstring.rb +16 -0
  63. data/lib/fontisan/tables/cff.rb +15 -5
  64. data/lib/fontisan/tables/cmap.rb +11 -1
  65. data/lib/fontisan/tables/glyf.rb +4 -6
  66. data/lib/fontisan/tables/gpos.rb +7 -0
  67. data/lib/fontisan/tables/gsub.rb +7 -0
  68. data/lib/fontisan/tables/layout_common.rb +172 -0
  69. data/lib/fontisan/tables/name.rb +10 -4
  70. data/lib/fontisan/utilities/brotli_wrapper.rb +39 -4
  71. data/lib/fontisan/validators/opentype_validator.rb +15 -3
  72. data/lib/fontisan/variable/instancer.rb +23 -192
  73. data/lib/fontisan/variable/static_font_builder.rb +4 -1
  74. data/lib/fontisan/variable/table_updater.rb +4 -2
  75. data/lib/fontisan/variation/instance_generator.rb +3 -149
  76. data/lib/fontisan/variation/instancing/axis_space.rb +136 -0
  77. data/lib/fontisan/variation/instancing/glyph_outline.rb +204 -0
  78. data/lib/fontisan/variation/instancing/gvar_reader.rb +146 -0
  79. data/lib/fontisan/variation/instancing/item_variation_store.rb +92 -0
  80. data/lib/fontisan/variation/instancing/name_rewriter.rb +90 -0
  81. data/lib/fontisan/variation/instancing/true_type_instancer.rb +326 -0
  82. data/lib/fontisan/variation/instancing.rb +20 -0
  83. data/lib/fontisan/variation/variable_svg_generator.rb +19 -1
  84. data/lib/fontisan/variation.rb +1 -0
  85. data/lib/fontisan/version.rb +1 -1
  86. data/lib/fontisan/woff2/collection_decoder.rb +15 -1
  87. data/lib/fontisan/woff2/collection_encoder.rb +29 -6
  88. data/lib/fontisan/woff2/encoder_rules.rb +12 -4
  89. data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +1 -1
  90. data/lib/fontisan/woff2_collection.rb +7 -5
  91. data/lib/fontisan/woff2_font.rb +12 -3
  92. data/lib/fontisan/woff_container.rb +83 -0
  93. data/lib/fontisan/woff_font.rb +24 -20
  94. data/lib/fontisan.rb +24 -1
  95. data/rakelib/bench.rake +9 -0
  96. data/rakelib/cdylib.rake +39 -0
  97. data/rakelib/corpus.rake +272 -0
  98. data/rakelib/corpus_convert.rake +43 -0
  99. data/rakelib/corpus_goldens.rake +22 -0
  100. data/rakelib/corpus_malformed.rake +20 -0
  101. data/rakelib/corpus_scan.rake +24 -0
  102. data/rakelib/java.rake +61 -0
  103. data/rakelib/native.rake +43 -0
  104. data/scripts/build-woff2.sh +57 -0
  105. data/scripts/corpus/generate_convert_goldens.rb +478 -0
  106. data/scripts/corpus/generate_goldens.rb +282 -0
  107. data/scripts/corpus/generate_malformed_goldens.rb +254 -0
  108. data/scripts/corpus/generate_scan_goldens.rb +98 -0
  109. data/scripts/tier_fallback.rb +219 -0
  110. data/scripts/tier_smoke.rb +64 -0
  111. metadata +75 -3
@@ -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,326 @@
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
+ # Tables read by #call besides the variation tables checked below.
13
+ REQUIRED = %w[head hhea hmtx maxp loca].freeze
14
+ WIDTH_CLASSES = [50, 62.5, 75, 87.5, 100, 112.5, 125, 150, 200].freeze
15
+
16
+ # @param tables [Hash{String=>String}] raw tables of the variable font
17
+ # @param coordinates [Hash{String=>Numeric}] axis tag => user value
18
+ def initialize(tables, coordinates)
19
+ @tables = tables
20
+ @coordinates = coordinates
21
+ check_font!
22
+ @space = AxisSpace.new(tables["fvar"], tables["avar"])
23
+ @location = @space.validate(coordinates)
24
+ @norm = @space.normalize(@location)
25
+ end
26
+
27
+ # @return [Hash{String=>String}] raw tables of the static instance
28
+ def call
29
+ num_glyphs = @tables["maxp"][4, 2].unpack1("n")
30
+ read_metrics(num_glyphs)
31
+ glyphs = Array.new(num_glyphs) { |gid| instance_glyph(gid) }
32
+ resolve_bounds(glyphs)
33
+ out = @tables.except(*DROPPED)
34
+ write_glyf_loca(out, glyphs)
35
+ write_hmtx(out, glyphs)
36
+ update_head_hhea(out, glyphs)
37
+ update_os2(out)
38
+ update_name(out)
39
+ out
40
+ end
41
+
42
+ private
43
+
44
+ Glyph = Struct.new(:outline, :points, :advance, :bbox, :data)
45
+
46
+ def check_font!
47
+ unless @tables["fvar"]
48
+ raise MissingVariationTableError.new(table: "fvar")
49
+ end
50
+ unless @tables["gvar"] && @tables["glyf"]
51
+ raise MissingVariationTableError.new(
52
+ table: "gvar",
53
+ message: "Static instancing needs TrueType outlines with gvar " \
54
+ "(CFF2 variable fonts are not supported yet)",
55
+ )
56
+ end
57
+
58
+ REQUIRED.each do |tag|
59
+ next if @tables[tag]
60
+
61
+ raise MissingTableError, "Font missing required #{tag} table"
62
+ end
63
+ end
64
+
65
+ def read_metrics(num_glyphs)
66
+ nhm = @tables["hhea"][34, 2].unpack1("n")
67
+ hmtx = @tables["hmtx"]
68
+ @advances = []
69
+ @lsbs = []
70
+ num_glyphs.times do |gid|
71
+ if gid < nhm
72
+ @advances << hmtx[gid * 4, 2].unpack1("n")
73
+ @lsbs << hmtx[(gid * 4) + 2, 2].unpack1("s>")
74
+ else
75
+ @advances << @advances.last
76
+ @lsbs << hmtx[(nhm * 4) + ((gid - nhm) * 2), 2].unpack1("s>")
77
+ end
78
+ end
79
+ long = @tables["head"][50, 2].unpack1("s>") == 1
80
+ @loca = long ? @tables["loca"].unpack("N*") : @tables["loca"].unpack("n*").map { |v| v * 2 }
81
+ @gvar = GvarReader.new(@tables["gvar"])
82
+ @hvar = @tables["HVAR"] ? ItemVariationStore.new(@tables["HVAR"]) : nil
83
+ end
84
+
85
+ def raw_glyph(gid)
86
+ @tables["glyf"].byteslice(@loca[gid]...@loca[gid + 1]) || "".b
87
+ end
88
+
89
+ def instance_glyph(gid)
90
+ outline = GlyphOutline.parse(raw_glyph(gid))
91
+ orig = outline.points
92
+ n = orig.size
93
+ xmin = outline.empty? ? 0 : raw_glyph(gid)[2, 2].unpack1("s>")
94
+ pp1x = xmin - @lsbs[gid]
95
+ total = Array.new(n + 4) { [0.0, 0.0] }
96
+ @gvar.tuples_for(gid, n + 4).each do |t|
97
+ s = tuple_scalar(t)
98
+ next if s.zero?
99
+
100
+ dx, dy = expand_deltas(t, outline, orig)
101
+ (n + 4).times do |i|
102
+ total[i][0] += s * dx[i]
103
+ total[i][1] += s * dy[i]
104
+ end
105
+ end
106
+ pts = orig.each_with_index.map do |(x, y), i|
107
+ [round(x + total[i][0]), round(y + total[i][1])]
108
+ end
109
+ advance = if @hvar
110
+ @advances[gid] + round(@hvar.advance_delta(gid, @norm))
111
+ else
112
+ round(pp1x + @advances[gid] + total[n + 1][0]) -
113
+ round(pp1x + total[n][0])
114
+ end
115
+ Glyph.new(outline, pts, [advance, 0].max, nil, nil)
116
+ end
117
+
118
+ def round(value)
119
+ (value + 0.5).floor
120
+ end
121
+
122
+ def tuple_scalar(tuple)
123
+ scalar = 1.0
124
+ tuple.peak.each_with_index do |peak, i|
125
+ next if peak.zero?
126
+
127
+ v = @norm[i]
128
+ next if v == peak
129
+
130
+ lo, hi = if tuple.intermediate?
131
+ [tuple.start[i], tuple.end[i]]
132
+ else
133
+ [0.0, peak].minmax
134
+ end
135
+ next if lo > peak || peak > hi || (lo.negative? && hi.positive?)
136
+ return 0.0 if v <= lo || hi <= v
137
+
138
+ scalar *= v < peak ? (v - lo) / (peak - lo) : (hi - v) / (hi - peak)
139
+ end
140
+ scalar
141
+ end
142
+
143
+ # Full per-point delta arrays (length n + 4), IUP applied when only
144
+ # some points were given.
145
+ def expand_deltas(tuple, outline, orig)
146
+ total = orig.size + 4
147
+ return [tuple.dx, tuple.dy] if tuple.points.nil?
148
+
149
+ dx = Array.new(total)
150
+ dy = Array.new(total)
151
+ tuple.points.each_with_index do |p, i|
152
+ next if p >= total
153
+
154
+ dx[p] = tuple.dx[i]
155
+ dy[p] = tuple.dy[i]
156
+ end
157
+ unless outline.composite?
158
+ iup(orig.map(&:first), dx, outline.end_pts)
159
+ iup(orig.map(&:last), dy, outline.end_pts)
160
+ end
161
+ [dx.map { |v| v || 0 }, dy.map { |v| v || 0 }]
162
+ end
163
+
164
+ def iup(coords, deltas, ends)
165
+ start = 0
166
+ ends.each do |last|
167
+ touched = (start..last).select { |i| deltas[i] }
168
+ if touched.empty?
169
+ (start..last).each { |i| deltas[i] = 0 }
170
+ elsif touched.size == 1
171
+ (start..last).each { |i| deltas[i] = deltas[touched.first] }
172
+ else
173
+ touched.each_with_index do |t, k|
174
+ nxt = touched[(k + 1) % touched.size]
175
+ i = t
176
+ loop do
177
+ i = i == last ? start : i + 1
178
+ break if i == nxt
179
+
180
+ deltas[i] = iup_value(coords[i], coords[t], deltas[t],
181
+ coords[nxt], deltas[nxt])
182
+ end
183
+ end
184
+ end
185
+ start = last + 1
186
+ end
187
+ end
188
+
189
+ def iup_value(coord, ref1, delta1, ref2, delta2)
190
+ return (delta1 == delta2 ? delta1 : 0) if ref1 == ref2
191
+
192
+ ref1, delta1, ref2, delta2 = ref2, delta2, ref1, delta1 if ref1 > ref2
193
+ return delta1 if coord <= ref1
194
+ return delta2 if coord >= ref2
195
+
196
+ delta1 + ((coord - ref1) * (delta2 - delta1) / (ref2 - ref1).to_f)
197
+ end
198
+
199
+ # Bounding boxes; composites derive theirs from their components.
200
+ def resolve_bounds(glyphs)
201
+ glyphs.each_index { |gid| bbox_for(glyphs, gid, []) }
202
+ end
203
+
204
+ def bbox_for(glyphs, gid, stack)
205
+ g = glyphs[gid]
206
+ return g.bbox if g.bbox
207
+ return g.bbox = [0, 0, 0, 0] if g.outline.empty? || stack.include?(gid)
208
+
209
+ if g.outline.composite?
210
+ corners = []
211
+ g.outline.components.each_with_index do |c, i|
212
+ sub = bbox_for(glyphs, c.gid, stack + [gid])
213
+ next if glyphs[c.gid].outline.empty?
214
+
215
+ corners.concat(transform_corners(sub, c, g.points[i]))
216
+ end
217
+ g.bbox = corners.empty? ? [0, 0, 0, 0] : g.outline.bounds_of(corners).map(&:round)
218
+ else
219
+ g.bbox = g.outline.bounds_of(g.points)
220
+ end
221
+ end
222
+
223
+ def transform_corners(bbox, comp, offset)
224
+ xmin, ymin, xmax, ymax = bbox
225
+ t = comp.transform
226
+ a, b, c, d = case t.bytesize
227
+ when 2 then s = t.unpack1("s>") / 16384.0
228
+ [s, 0, 0, s]
229
+ when 4 then sx, sy = t.unpack("s>s>").map { |v| v / 16384.0 }
230
+ [sx, 0, 0, sy]
231
+ when 8 then xs, s01, s10, ys = t.unpack("s>4").map { |v| v / 16384.0 }
232
+ [xs, s01, s10, ys]
233
+ else [1, 0, 0, 1]
234
+ end
235
+ [[xmin, ymin], [xmin, ymax], [xmax, ymin], [xmax, ymax]].map do |x, y|
236
+ [(a * x) + (c * y) + offset[0], (b * x) + (d * y) + offset[1]]
237
+ end
238
+ end
239
+
240
+ def write_glyf_loca(out, glyphs)
241
+ glyf = +"".b
242
+ loca = [0]
243
+ glyphs.each do |g|
244
+ data = g.outline.serialize(g.points, g.bbox)
245
+ data << "\0".b until (data.bytesize % 4).zero?
246
+ glyf << data
247
+ loca << glyf.bytesize
248
+ end
249
+ out["glyf"] = glyf
250
+ out["loca"] = loca.pack("N*")
251
+ head = out["head"].dup
252
+ head[50, 2] = [1].pack("s>")
253
+ out["head"] = head
254
+ end
255
+
256
+ def write_hmtx(out, glyphs)
257
+ hmtx = +"".b
258
+ glyphs.each_with_index do |g, gid|
259
+ lsb = g.outline.empty? ? @lsbs[gid] : g.bbox[0]
260
+ g.data = [g.advance, lsb]
261
+ hmtx << [g.advance, lsb].pack("ns>")
262
+ end
263
+ out["hmtx"] = hmtx
264
+ hhea = out["hhea"].dup
265
+ hhea[34, 2] = [glyphs.size].pack("n")
266
+ out["hhea"] = hhea
267
+ end
268
+
269
+ def update_head_hhea(out, glyphs)
270
+ drawn = glyphs.reject { |g| g.outline.empty? }
271
+ return if drawn.empty?
272
+
273
+ head = out["head"].dup
274
+ head[36, 8] = [drawn.map { |g| g.bbox[0] }.min, drawn.map { |g| g.bbox[1] }.min,
275
+ drawn.map { |g| g.bbox[2] }.max, drawn.map { |g| g.bbox[3] }.max].pack("s>4")
276
+ out["head"] = head
277
+ hhea = out["hhea"].dup
278
+ min_lsb = drawn.map { |g| g.data[1] }.min
279
+ min_rsb = drawn.map { |g| g.data[0] - g.data[1] - (g.bbox[2] - g.bbox[0]) }.min
280
+ max_ext = drawn.map { |g| g.data[1] + (g.bbox[2] - g.bbox[0]) }.max
281
+ hhea[10, 8] = [glyphs.map(&:advance).max, min_lsb, min_rsb, max_ext].pack("ns>s>s>")
282
+ out["hhea"] = hhea
283
+ end
284
+
285
+ def update_os2(out)
286
+ return unless out["OS/2"]
287
+
288
+ os2 = out["OS/2"].dup
289
+ if @location.key?("wght")
290
+ os2[4, 2] = [@location["wght"].round.clamp(1, 1000)].pack("n")
291
+ end
292
+ if @location.key?("wdth")
293
+ nearest = WIDTH_CLASSES.each_index.min_by { |i| (WIDTH_CLASSES[i] - @location["wdth"]).abs }
294
+ os2[6, 2] = [nearest + 1].pack("n")
295
+ end
296
+ out["OS/2"] = os2
297
+ end
298
+
299
+ def update_name(out)
300
+ return unless out["name"]
301
+
302
+ rewriter = NameRewriter.new(out["name"])
303
+ out["name"] = rewriter.rename(instance_label(rewriter)).to_binary
304
+ end
305
+
306
+ def instance_label(rewriter)
307
+ match = AxisSpace.named_instances(@tables["fvar"]).find do |inst|
308
+ @space.tags.each_with_index.all? do |tag, i|
309
+ (inst[:values][i] - @location[tag]).abs < 1e-6
310
+ end
311
+ end
312
+ named = match && rewriter.lookup(match[:name_id])
313
+ return named if named
314
+
315
+ parts = @space.axes.reject { |a| @location[a.tag] == a.default }
316
+ .map { |a| "#{a.tag.capitalize}#{format_value(@location[a.tag])}" }
317
+ parts.empty? ? "Regular" : parts.join(" ")
318
+ end
319
+
320
+ def format_value(value)
321
+ value == value.to_i ? value.to_i.to_s : value.to_s
322
+ end
323
+ end
324
+ end
325
+ end
326
+ 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.48"
5
5
  end
@@ -59,15 +59,29 @@ module Fontisan
59
59
  # the glyf was transformed.
60
60
  font_entries.map do |fe|
61
61
  font_tables = {}
62
+ glyf_transformed = false
62
63
  fe.table_indices.each do |idx|
63
64
  entry = table_entries.fetch(idx)
64
65
  font_tables[entry.tag] = entry.raw_bytes
66
+ glyf_transformed = !entry.transform_length.nil? if entry.tag == "glyf"
65
67
  end
66
- reconstruct_glyf_loca!(font_tables) if font_tables.key?("glyf")
68
+ reconstruct_glyf_loca!(font_tables) if glyf_transformed
67
69
  { flavor: fe.flavor, tables: font_tables }
68
70
  end
69
71
  end
70
72
 
73
+ # Number of fonts, read from the CollectionDirectory alone: no Brotli
74
+ # inflate, so it works where the `brotli` gem is absent (JRuby).
75
+ #
76
+ # @return [Integer]
77
+ def font_count
78
+ validate_signature!
79
+ validate_collection_flavor!
80
+
81
+ _entries, post_dir_pos = read_table_directory
82
+ read_collection_directory(post_dir_pos).first.length
83
+ end
84
+
71
85
  private
72
86
 
73
87
  def validate_signature!
@@ -21,10 +21,15 @@ module Fontisan
21
21
  # adjacent (loca immediately follows glyf) in the directory.
22
22
  class CollectionEncoder
23
23
  TTC_FLAVOR = 0x74746366 # 'ttcf'
24
+ # Largest padded glyf a short (uint16, offset / 2) loca can index.
25
+ SHORT_LOCA_GLYF_MAX = 0x1FFFE
24
26
 
25
27
  # @param brotli_quality [Integer] 0-11
26
- def initialize(brotli_quality: 11)
28
+ # @param timestamp [Integer, nil] Unix seconds stamped into every
29
+ # head.modified; nil uses the clock
30
+ def initialize(brotli_quality: 11, timestamp: nil)
27
31
  @brotli_quality = brotli_quality
32
+ @timestamp = timestamp
28
33
  end
29
34
 
30
35
  # Encode an array of fonts as a WOFF2 collection.
@@ -58,7 +63,7 @@ module Fontisan
58
63
  tables[tag] = data
59
64
  end
60
65
 
61
- EncoderRules.apply!(tables)
66
+ EncoderRules.apply!(tables, timestamp: @timestamp)
62
67
  loca_orig_length = apply_glyf_loca_transform!(tables, font)
63
68
 
64
69
  { index:, tables:, loca_orig_length: }
@@ -73,12 +78,30 @@ module Fontisan
73
78
  head = font.table("head")
74
79
  return nil unless maxp && head
75
80
 
76
- loca_orig_length = tables["loca"].bytesize
81
+ glyf = tables["glyf"]
82
+ loca = tables["loca"]
83
+ index_format = head.index_to_loc_format
84
+ # The WOFF2 decoder pads every glyph to 4 bytes; a short loca
85
+ # (offset / 2 as uint16) wraps once that padded glyf passes
86
+ # SHORT_LOCA_GLYF_MAX, so such a member is re-emitted with a long loca.
87
+ if index_format.zero? &&
88
+ glyf.bytesize + 3 * maxp.num_glyphs > SHORT_LOCA_GLYF_MAX
89
+ canonical = GlyfCanonicalizer.new(
90
+ glyf_data: glyf, loca_data: loca, num_glyphs: maxp.num_glyphs,
91
+ source_format: 0, target_format: 1
92
+ ).canonical
93
+ glyf = canonical[:glyf]
94
+ loca = canonical[:loca]
95
+ index_format = 1
96
+ EncoderRules.set_head_index_to_loc_format!(tables, 1)
97
+ end
98
+
99
+ loca_orig_length = loca.bytesize
77
100
  tables["glyf"] = GlyfLocaTransform.new(
78
- glyf_data: tables["glyf"],
79
- loca_data: tables["loca"],
101
+ glyf_data: glyf,
102
+ loca_data: loca,
80
103
  num_glyphs: maxp.num_glyphs,
81
- index_format: head.index_to_loc_format,
104
+ index_format:,
82
105
  ).transform
83
106
  tables.delete("loca")
84
107
  loca_orig_length