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
@@ -68,6 +68,178 @@ module Fontisan
68
68
  array :lookup_list_indices, type: :uint16,
69
69
  initial_length: -> { lookup_index_count }
70
70
  end
71
+
72
+ # Scripts paired with their feature tags, from one parse of the
73
+ # ScriptList and FeatureList instead of re-reading both per script.
74
+ #
75
+ # Gives exactly the answers of calling `scripts` and then
76
+ # `features(script_tag:)` for each (the first record of a tag decides;
77
+ # any short read yields []), but its total LangSys work is capped at
78
+ # {WORK_PER_BYTE} x the table length, so a table whose scripts each
79
+ # re-walk thousands of overlapping LangSys arrays cannot take minutes.
80
+ # Scripts reached after the budget is gone read as failed ([]), the
81
+ # same outcome as any other bad read. Mirrors the native tiers'
82
+ # `LayoutTable::script_features`.
83
+ class ScriptFeatures
84
+ # Work units per byte of table; a well-formed font spends a small
85
+ # fraction of one.
86
+ WORK_PER_BYTE = 2
87
+
88
+ # A short read or an exhausted budget: the script reads as [].
89
+ class Failed < StandardError; end
90
+
91
+ # @param table [Gsub, Gpos]
92
+ def initialize(table)
93
+ @table = table
94
+ @data = table.table_data
95
+ @budget = @data.bytesize * WORK_PER_BYTE
96
+ end
97
+
98
+ # @return [Hash{String=>Array<String>}] script tag => feature tags,
99
+ # in record order
100
+ def call
101
+ return {} if @table.script_list_offset.zero?
102
+
103
+ list_data = slice_from(@data, @table.script_list_offset - 10)
104
+ return {} if list_data.nil? || list_data.empty?
105
+
106
+ records = ScriptList.read(list_data).script_records
107
+ features = feature_tags
108
+ records.each_with_object({}) do |record, result|
109
+ tag = record.script_tag
110
+ next if result.key?(tag)
111
+
112
+ result[tag] = script_feature_tags(list_data, record.script_offset,
113
+ features)
114
+ end
115
+ rescue StandardError
116
+ {}
117
+ end
118
+
119
+ private
120
+
121
+ # Ruby `String#[start..]`: negative starts count from the end.
122
+ def slice_from(string, start)
123
+ string[start..]
124
+ end
125
+
126
+ # FeatureList tags, or nil when every script must read as [].
127
+ def feature_tags
128
+ return nil if @table.script_list_offset.zero? ||
129
+ @table.feature_list_offset.zero?
130
+
131
+ data = slice_from(@data, @table.feature_list_offset - 10)
132
+ return nil if data.nil? || data.empty?
133
+
134
+ FeatureList.read(data).feature_tags
135
+ rescue StandardError
136
+ nil
137
+ end
138
+
139
+ def script_feature_tags(list_data, offset, features)
140
+ return [] unless features
141
+
142
+ indices = feature_indices(list_data, offset)
143
+ indices.filter_map { |index| features[index] }.uniq
144
+ rescue StandardError
145
+ []
146
+ end
147
+
148
+ # Feature indices of one Script table: its default LangSys and every
149
+ # LangSys record, deduplicated in first-seen order.
150
+ def feature_indices(list_data, offset)
151
+ data = slice_from(list_data, offset)
152
+ return [] if data.nil? || data.empty?
153
+
154
+ default_offset, count = read_header(data)
155
+ spend(count)
156
+ offsets = lang_sys_offsets(data, count)
157
+ collector = IndexCollector.new(data)
158
+ ([default_offset] + offsets).each do |lang_offset|
159
+ next if lang_offset.zero? || lang_offset >= data.bytesize
160
+
161
+ collector.add(lang_offset) { |units| spend(units) }
162
+ end
163
+ collector.indices
164
+ end
165
+
166
+ def read_header(data)
167
+ raise Failed if data.bytesize < 4
168
+
169
+ data.unpack("nn")
170
+ end
171
+
172
+ def lang_sys_offsets(data, count)
173
+ raise Failed if data.bytesize < 4 + (6 * count)
174
+
175
+ Array.new(count) { |i| data.byteslice(8 + (6 * i), 2).unpack1("n") }
176
+ end
177
+
178
+ def spend(units)
179
+ raise Failed if units > @budget
180
+
181
+ @budget -= units
182
+ end
183
+ end
184
+
185
+ # Distinct feature indices of many LangSys tables in first-seen order,
186
+ # reading every u16 word once. A LangSys index array is a run of words at
187
+ # a fixed byte parity inside the Script table, so hostile overlapping
188
+ # arrays would otherwise re-read the same words again and again; a word
189
+ # already collected is skipped (a "next unread slot" union-find per
190
+ # parity), which leaves the result and its order unchanged.
191
+ class IndexCollector
192
+ attr_reader :indices
193
+
194
+ def initialize(data)
195
+ @data = data
196
+ @skip = [{}, {}]
197
+ @seen = {}
198
+ @indices = []
199
+ end
200
+
201
+ # LangSys at +offset+: reserved, required index, count, count x u16.
202
+ # Yields the work units it costs (1 per table, 1 per word read).
203
+ def add(offset)
204
+ raise ScriptFeatures::Failed if @data.bytesize < offset + 6
205
+
206
+ count = @data.byteslice(offset + 4, 2).unpack1("n")
207
+ raise ScriptFeatures::Failed if @data.bytesize < offset + 6 + (2 * count)
208
+
209
+ yield(1)
210
+ first = offset + 6
211
+ parity = first & 1
212
+ slot = find(parity, first >> 1)
213
+ limit = (first + (2 * count)) >> 1
214
+ while slot < limit
215
+ yield(1)
216
+ collect(@data.byteslice((slot * 2) + parity, 2).unpack1("n"))
217
+ @skip[parity][slot] = slot + 1
218
+ slot = find(parity, slot + 1)
219
+ end
220
+ end
221
+
222
+ private
223
+
224
+ def collect(index)
225
+ return if @seen.key?(index)
226
+
227
+ @seen[index] = true
228
+ @indices << index
229
+ end
230
+
231
+ def find(parity, slot)
232
+ skip = @skip[parity]
233
+ root = slot
234
+ root = skip[root] while skip.key?(root)
235
+ while slot != root
236
+ following = skip[slot]
237
+ skip[slot] = root
238
+ slot = following
239
+ end
240
+ root
241
+ end
242
+ end
71
243
  end
72
244
  end
73
245
  end
@@ -108,8 +108,14 @@ module Fontisan
108
108
  array :name_records, type: :name_record, initial_length: :record_count
109
109
  rest :string_storage
110
110
 
111
- # Cache for decoded names
112
- attr_accessor :decoded_names_cache
111
+ # Cache for decoded names. Created on first use: do_read does not set
112
+ # it up when the table is empty, and english_name then raised
113
+ # NoMethodError on nil.
114
+ attr_writer :decoded_names_cache
115
+
116
+ def decoded_names_cache
117
+ @decoded_names_cache ||= {}
118
+ end
113
119
 
114
120
  # Hook that gets called after all fields are read
115
121
  def after_read_hook
@@ -168,7 +174,7 @@ module Fontisan
168
174
  # @return [String, nil] The decoded string or nil if not found
169
175
  def english_name(name_id)
170
176
  # Check cache first
171
- return @decoded_names_cache[name_id] if @decoded_names_cache.key?(name_id)
177
+ return decoded_names_cache[name_id] if decoded_names_cache.key?(name_id)
172
178
 
173
179
  # Find record (don't decode yet)
174
180
  record = find_name_record(
@@ -187,7 +193,7 @@ module Fontisan
187
193
 
188
194
  # Decode only this one record
189
195
  decoded = decode_name_record(record)
190
- @decoded_names_cache[name_id] = decoded
196
+ decoded_names_cache[name_id] = decoded
191
197
  decoded
192
198
  end
193
199
 
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "brotli"
4
-
5
3
  module Fontisan
6
4
  module Utilities
7
5
  # Wrapper for Brotli compression with consistent settings
@@ -19,6 +17,15 @@ module Fontisan
19
17
  # @example Decompress data
20
18
  # decompressed = BrotliWrapper.decompress(compressed_data)
21
19
  class BrotliWrapper
20
+ # Raised when the optional `brotli` C extension cannot be loaded (the
21
+ # java-platform gem omits it; WOFF2 is then served by the :ffi tier).
22
+ class Unavailable < Fontisan::Error; end
23
+
24
+ MISSING_MESSAGE = "WOFF2 needs the `brotli` gem, which is not available " \
25
+ "(java-platform fontisan omits it). Use " \
26
+ "FONTISAN_ENGINE=ffi or auto with the bundled cdylib, " \
27
+ "or install the brotli gem."
28
+
22
29
  # Default compression quality (0-11, higher = better but slower)
23
30
  # Quality 11 gives best compression for WOFF2
24
31
  DEFAULT_QUALITY = 11
@@ -29,6 +36,30 @@ module Fontisan
29
36
  # Maximum quality level
30
37
  MAX_QUALITY = 11
31
38
 
39
+ # Whether the `brotli` gem loads here. Memoised; never raises.
40
+ def self.available?
41
+ return @available if defined?(@available)
42
+
43
+ @available = begin
44
+ require "brotli"
45
+ true
46
+ rescue LoadError
47
+ false
48
+ end
49
+ end
50
+
51
+ # Forget the memoised probe (specs).
52
+ def self.reset!
53
+ remove_instance_variable(:@available) if defined?(@available)
54
+ end
55
+
56
+ # The ::Brotli module, or {Unavailable} with a clear message.
57
+ def self.library
58
+ raise Unavailable, MISSING_MESSAGE unless available?
59
+
60
+ ::Brotli
61
+ end
62
+
32
63
  # Compress data using Brotli
33
64
  #
34
65
  # @param data [String] Data to compress
@@ -48,7 +79,9 @@ module Fontisan
48
79
  validate_data!(data)
49
80
 
50
81
  begin
51
- Brotli.deflate(data, quality: quality, mode: mode)
82
+ library.deflate(data, quality: quality, mode: mode)
83
+ rescue Unavailable
84
+ raise
52
85
  rescue StandardError => e
53
86
  raise Fontisan::Error,
54
87
  "Brotli compression failed: #{e.message}"
@@ -67,7 +100,9 @@ module Fontisan
67
100
  validate_data!(data)
68
101
 
69
102
  begin
70
- Brotli.inflate(data)
103
+ library.inflate(data)
104
+ rescue Unavailable
105
+ raise
71
106
  rescue StandardError => e
72
107
  raise Fontisan::Error,
73
108
  "Brotli decompression failed: #{e.message}"
@@ -44,7 +44,7 @@ module Fontisan
44
44
  glyf = font.table("glyf")
45
45
  next true unless glyf # Skip if CFF font
46
46
 
47
- loca = font.table("loca")
47
+ loca = parsed_loca(font)
48
48
  head = font.table("head")
49
49
  maxp = font.table("maxp")
50
50
  glyf.all_glyphs_accessible?(loca, head, maxp.num_glyphs)
@@ -55,7 +55,7 @@ module Fontisan
55
55
  glyf = font.table("glyf")
56
56
  next true unless glyf
57
57
 
58
- loca = font.table("loca")
58
+ loca = parsed_loca(font)
59
59
  head = font.table("head")
60
60
  maxp = font.table("maxp")
61
61
  glyf.no_clipped_glyphs?(loca, head, maxp.num_glyphs)
@@ -66,7 +66,7 @@ module Fontisan
66
66
  glyf = font.table("glyf")
67
67
  next true unless glyf
68
68
 
69
- loca = font.table("loca")
69
+ loca = parsed_loca(font)
70
70
  head = font.table("head")
71
71
  maxp = font.table("maxp")
72
72
 
@@ -90,6 +90,8 @@ module Fontisan
90
90
  # Check 35: Cmap glyph indices must be valid
91
91
  check_structure :cmap_glyph_indices, severity: :error do |font|
92
92
  cmap = font.table("cmap")
93
+ next true unless cmap
94
+
93
95
  maxp = font.table("maxp")
94
96
  cmap.valid_glyph_indices?(maxp.num_glyphs)
95
97
  end
@@ -101,6 +103,16 @@ module Fontisan
101
103
  true # Placeholder - actual checksum validation if desired
102
104
  end
103
105
  end
106
+
107
+ # loca must be parsed with head/maxp context before glyf can read it.
108
+ def parsed_loca(font)
109
+ loca = font.table("loca")
110
+ return loca if loca.parsed?
111
+
112
+ loca.parse_with_context(font.table("head").index_to_loc_format,
113
+ font.table("maxp").num_glyphs)
114
+ loca
115
+ end
104
116
  end
105
117
  end
106
118
  end
@@ -25,12 +25,6 @@ module Fontisan
25
25
  # @return [Object] The variable font object
26
26
  attr_reader :font
27
27
 
28
- # @return [DeltaApplicator] Delta applicator
29
- attr_reader :delta_applicator
30
-
31
- # @return [StaticFontBuilder] Static font builder
32
- attr_reader :static_font_builder
33
-
34
28
  # Initialize the instancer
35
29
  #
36
30
  # @param font [TrueTypeFont, OpenTypeFont] Variable font object
@@ -38,9 +32,6 @@ module Fontisan
38
32
  def initialize(font)
39
33
  @font = font
40
34
  validate_variable_font!
41
-
42
- @delta_applicator = DeltaApplicator.new(font)
43
- @static_font_builder = StaticFontBuilder.new(font)
44
35
  end
45
36
 
46
37
  # Generate static font instance at specified coordinates
@@ -53,21 +44,11 @@ module Fontisan
53
44
  #
54
45
  # @example
55
46
  # binary = instancer.instance({ "wght" => 700, "wdth" => 100 })
56
- def instance(user_coords, options = {})
57
- # Apply deltas to get all varied data
58
- delta_result = @delta_applicator.apply(user_coords)
59
-
60
- # Collect varied metrics for all glyphs
61
- varied_metrics = collect_varied_glyph_metrics(
62
- delta_result[:normalized_coords],
63
- delta_result[:region_scalars],
64
- )
65
-
66
- # Extract font-level metrics
67
- font_metrics = delta_result[:font_metrics]
68
-
69
- # Build static font
70
- @static_font_builder.build(varied_metrics, font_metrics, options)
47
+ def instance(user_coords, _options = {})
48
+ Fontisan::Engine.record_ruby(:instance)
49
+ tables = Variation::Instancing::TrueTypeInstancer
50
+ .new(@font.table_data, user_coords).call
51
+ FontWriter.write_font(tables)
71
52
  end
72
53
 
73
54
  # Generate static font instance and write to file
@@ -117,23 +98,16 @@ module Fontisan
117
98
  # @return [Array<Hash>] Array of named instance information
118
99
  # [{ name: "Bold", coords: { "wght" => 700 } }, ...]
119
100
  def named_instances
120
- fvar = load_fvar_table
121
- return [] unless fvar
122
-
123
- # Get name table for instance names
124
- name_table = load_name_table
125
-
126
- fvar.instances.map do |instance|
127
- coords = extract_instance_coords(instance, fvar)
128
- name = if name_table
129
- get_instance_name(instance[:name_id],
130
- name_table)
131
- end
132
-
101
+ fvar = @font.table_data["fvar"]
102
+ names = if @font.table_data["name"]
103
+ Variation::Instancing::NameRewriter.new(@font.table_data["name"])
104
+ end
105
+ tags = axis_tags
106
+ Variation::Instancing::AxisSpace.named_instances(fvar).map do |inst|
133
107
  {
134
- name_id: instance[:name_id],
135
- name: name || "Instance #{instance[:name_id]}",
136
- coordinates: coords,
108
+ name_id: inst[:name_id],
109
+ name: names&.lookup(inst[:name_id]) || "Instance #{inst[:name_id]}",
110
+ coordinates: tags.zip(inst[:values]).to_h,
137
111
  }
138
112
  end
139
113
  end
@@ -142,21 +116,21 @@ module Fontisan
142
116
  #
143
117
  # @return [Boolean] True if variable font
144
118
  def variable_font?
145
- @delta_applicator.variable_font?
119
+ true
146
120
  end
147
121
 
148
122
  # Get available axis information
149
123
  #
150
- # @return [Hash] Axis information
124
+ # @return [Array<Variation::Instancing::AxisSpace::Axis>]
151
125
  def axes
152
- @delta_applicator.axes
126
+ axis_space.axes
153
127
  end
154
128
 
155
129
  # Get available axis tags
156
130
  #
157
131
  # @return [Array<String>] Array of axis tags
158
132
  def axis_tags
159
- @delta_applicator.axis_tags
133
+ axis_space.tags
160
134
  end
161
135
 
162
136
  private
@@ -165,60 +139,16 @@ module Fontisan
165
139
  #
166
140
  # @raise [ArgumentError] If not a variable font
167
141
  def validate_variable_font!
168
- fvar_data = @font.table_data("fvar")
142
+ fvar_data = @font.table_data["fvar"]
169
143
  return unless fvar_data.nil? || fvar_data.empty?
170
144
 
171
145
  raise ArgumentError, "Font is not a variable font (missing fvar table)"
172
146
  end
173
147
 
174
- # Collect varied metrics for all glyphs
175
- #
176
- # @param normalized_coords [Hash] Normalized coordinates
177
- # @param region_scalars [Array<Float>] Region scalars
178
- # @return [Hash<Integer, Hash>] Varied metrics by glyph ID
179
- def collect_varied_glyph_metrics(normalized_coords, _region_scalars)
180
- varied_metrics = {}
181
-
182
- # Get number of glyphs
183
- maxp = load_maxp_table
184
- return varied_metrics unless maxp
185
-
186
- num_glyphs = maxp.num_glyphs
187
-
188
- # Get original hmtx metrics
189
- hmtx = load_hmtx_table
190
- return varied_metrics unless hmtx
191
-
192
- # For each glyph, calculate varied metrics
193
- num_glyphs.times do |glyph_id|
194
- original_metric = hmtx.metric_for(glyph_id)
195
- next unless original_metric
196
-
197
- # Get deltas from delta applicator
198
- deltas = @delta_applicator.apply_glyph(glyph_id, normalized_coords)
199
- metric_deltas = deltas[:metric_deltas]
200
-
201
- # Calculate varied values
202
- varied_metric = {
203
- advance_width: original_metric[:advance_width],
204
- lsb: original_metric[:lsb],
205
- }
206
-
207
- # Apply horizontal deltas if present
208
- if metric_deltas[:horizontal]
209
- if metric_deltas[:horizontal][:advance_width]
210
- varied_metric[:advance_width] += metric_deltas[:horizontal][:advance_width]
211
- end
212
-
213
- if metric_deltas[:horizontal][:lsb]
214
- varied_metric[:lsb] += metric_deltas[:horizontal][:lsb]
215
- end
216
- end
217
-
218
- varied_metrics[glyph_id] = varied_metric
219
- end
220
-
221
- varied_metrics
148
+ def axis_space
149
+ @axis_space ||= Variation::Instancing::AxisSpace.new(
150
+ @font.table_data["fvar"], @font.table_data["avar"]
151
+ )
222
152
  end
223
153
 
224
154
  # Find coordinates for a named instance
@@ -237,105 +167,6 @@ module Fontisan
237
167
 
238
168
  instance[:coordinates]
239
169
  end
240
-
241
- # Extract coordinates from instance record
242
- #
243
- # @param instance [Hash] Instance record
244
- # @param fvar [Fvar] fvar table
245
- # @return [Hash<String, Float>] Coordinates by axis tag
246
- def extract_instance_coords(instance, fvar)
247
- coords = {}
248
- instance[:coordinates].each_with_index do |value, index|
249
- axis = fvar.axes[index]
250
- coords[axis.axis_tag.to_s] = value if axis
251
- end
252
- coords
253
- end
254
-
255
- # Get instance name from name table
256
- #
257
- # @param name_id [Integer] Name table ID
258
- # @param name_table [Name] name table
259
- # @return [String, nil] Instance name
260
- def get_instance_name(name_id, name_table)
261
- # Try to get English name
262
- record = name_table.records.find do |r|
263
- r.name_id == name_id && r.language_id == 0x0409 # English (US)
264
- end
265
-
266
- record ||= name_table.records.find { |r| r.name_id == name_id }
267
- record&.value
268
- rescue StandardError
269
- nil
270
- end
271
-
272
- # Load fvar table
273
- #
274
- # @return [Fvar, nil] fvar table or nil
275
- def load_fvar_table
276
- data = @font.table_data("fvar")
277
- return nil if data.nil? || data.empty?
278
-
279
- Tables::Fvar.read(data)
280
- rescue StandardError
281
- nil
282
- end
283
-
284
- # Load name table
285
- #
286
- # @return [Name, nil] name table or nil
287
- def load_name_table
288
- data = @font.table_data("name")
289
- return nil if data.nil? || data.empty?
290
-
291
- Tables::Name.read(data)
292
- rescue StandardError
293
- nil
294
- end
295
-
296
- # Load maxp table
297
- #
298
- # @return [Maxp, nil] maxp table or nil
299
- def load_maxp_table
300
- data = @font.table_data("maxp")
301
- return nil if data.nil? || data.empty?
302
-
303
- Tables::Maxp.read(data)
304
- rescue StandardError
305
- nil
306
- end
307
-
308
- # Load hmtx table
309
- #
310
- # @return [Hmtx, nil] hmtx table or nil
311
- def load_hmtx_table
312
- data = @font.table_data("hmtx")
313
- return nil if data.nil? || data.empty?
314
-
315
- hmtx = Tables::Hmtx.read(data)
316
-
317
- # Parse with context
318
- hhea = load_hhea_table
319
- maxp = load_maxp_table
320
- return nil unless hhea && maxp
321
-
322
- hmtx.parse_with_context(hhea.number_of_h_metrics, maxp.num_glyphs)
323
- hmtx
324
- rescue StandardError
325
- nil
326
- end
327
-
328
- # Load hhea table
329
- #
330
- # @return [Hhea, nil] hhea table or nil
331
- def load_hhea_table
332
- data = @font.table_data("hhea")
333
- return nil if data.nil? || data.empty?
334
-
335
- Tables::Hhea.read(data)
336
- rescue StandardError
337
- nil
338
- end
339
170
  end
340
171
  end
341
172
  end
@@ -40,6 +40,8 @@ module Fontisan
40
40
  # { ascent: 2048, descent: -512, line_gap: 0 }
41
41
  # @param options [Hash] Build options
42
42
  # @option options [Boolean] :update_modified Update head modified timestamp
43
+ # @option options [Integer, Time] :timestamp Fixed value to stamp when
44
+ # updating head.modified (nil = current time)
43
45
  # @return [String] Complete static font binary
44
46
  def build(varied_metrics = {}, font_metrics = {}, options = {})
45
47
  # Collect tables for static font
@@ -170,7 +172,8 @@ options = {})
170
172
  if options[:update_modified] == false
171
173
  original_data
172
174
  else
173
- @table_updater.update_head_modified(original_data)
175
+ @table_updater.update_head_modified(original_data,
176
+ options[:timestamp])
174
177
  end
175
178
  end
176
179
 
@@ -157,9 +157,11 @@ module Fontisan
157
157
  # Update head table's modified timestamp
158
158
  #
159
159
  # @param original_data [String] Original head table binary
160
- # @param timestamp [Time] New modification time
160
+ # @param timestamp [Time, Integer, nil] New modification time (Time or
161
+ # Unix seconds); nil uses the current time
161
162
  # @return [String] Updated head table binary
162
- def update_head_modified(original_data, timestamp = Time.now)
163
+ def update_head_modified(original_data, timestamp = nil)
164
+ timestamp ||= Time.now
163
165
  io = StringIO.new(original_data)
164
166
  io.set_encoding(Encoding::BINARY)
165
167