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,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ # Routing rules, result rebuilding and error rebuilding shared by the engine
5
+ # tiers that serve convert, extract, instance and subset
6
+ # ({Fontisan::Native}, {Fontisan::Ffi}). Included into their singleton
7
+ # classes; the methods are private to the tier.
8
+ module ByteOps
9
+ # Convert target codes of the engine (extension and C ABI alike).
10
+ TARGET_CODES = { ttf: 0, otf: 1, woff: 2, woff2: 3, ttc: 4, otc: 5 }.freeze
11
+ SINGLE_TARGETS = %i[ttf otf woff woff2].freeze
12
+ COLLECTION_TARGETS = %i[ttc otc woff2].freeze
13
+ CONVERT_OPTIONS = %i[to output timestamp quiet].freeze
14
+ SUBSET_PROFILES = %w[web pdf minimal].freeze
15
+ MAX_CODEPOINT = 0x10FFFF
16
+
17
+ BYTE_OPERATIONS = %i[convert extract instance subset].freeze
18
+
19
+ # "<ruby class>: <message>": how the engine reports a Ruby exception it
20
+ # mirrors (the byte operations carry one class and one message, no chain).
21
+ FAILURE_MESSAGE = /\A([A-Z]\w*(?:::\w+)*): (.*)\z/m
22
+ # The engine's text for a file that is no font; Ruby's is InvalidFontError.
23
+ UNRECOGNISED_FORMAT_MESSAGE = "Conversion failed: unrecognised font format"
24
+
25
+ private
26
+
27
+ # Whether the engine honours +options+ for +operation+ (one of
28
+ # {BYTE_OPERATIONS}); the first reuse point for tier routing.
29
+ def byte_options?(operation, options, path)
30
+ case operation
31
+ when :convert then convert_options?(options, path)
32
+ when :extract then extract_options?(options)
33
+ when :instance then instance_options?(options, path)
34
+ when :subset then subset_options?(options, path)
35
+ else false
36
+ end
37
+ end
38
+
39
+ # A collection: `ttcf`, or a WOFF2 whose flavor is `ttcf`.
40
+ def collection_file?(path)
41
+ head = File.binread(path, 8) or return false
42
+ head.start_with?("ttcf") || (head.start_with?("wOF2") && head.byteslice(4, 4) == "ttcf")
43
+ rescue SystemCallError
44
+ false
45
+ end
46
+
47
+ # `.otc` decision (card 2f): Ruby picks a collection's source type from
48
+ # the input path's extension (`.otc` vs anything else), and the engine
49
+ # only sees bytes and assumes `.ttc`. No hint is passed: `.otc`
50
+ # inputs stay on the Ruby tier.
51
+ def otc_hint?(path)
52
+ File.extname(path).casecmp?(".otc")
53
+ end
54
+
55
+ # Single target `to:` (no multi-format), a format pair the engine
56
+ # serves, no `coordinates:` / `preserve_hints` / compression knobs.
57
+ def convert_options?(options, path)
58
+ return false unless options.keys.all? { |key| CONVERT_OPTIONS.include?(key) }
59
+ return false unless options[:output].is_a?(String) || options[:output].respond_to?(:to_path)
60
+ return false unless timestamp_option?(options[:timestamp])
61
+
62
+ target = convert_target(options[:to]) or return false
63
+ return false unless path.is_a?(String)
64
+ return false if otc_hint?(path)
65
+
66
+ (collection_file?(path) ? COLLECTION_TARGETS : SINGLE_TARGETS).include?(target)
67
+ end
68
+
69
+ def convert_target(value)
70
+ return nil unless value.is_a?(Symbol) || value.is_a?(String)
71
+
72
+ target = value.to_s.downcase.to_sym
73
+ TARGET_CODES.key?(target) ? target : nil
74
+ end
75
+
76
+ def timestamp_option?(value)
77
+ value.nil? || value.is_a?(Integer) || value.respond_to?(:to_time)
78
+ end
79
+
80
+ def extract_options?(options)
81
+ options.keys.sort == %i[index output_dir] && options[:index].is_a?(Integer) &&
82
+ !options[:index].negative? && options[:output_dir].respond_to?(:to_str)
83
+ end
84
+
85
+ def instance_options?(options, path)
86
+ return false unless options.keys.all? { |key| %i[coords timestamp].include?(key) }
87
+ return false unless options[:coords].is_a?(Hash) && !path.nil? && !collection_file?(path)
88
+
89
+ options[:coords].all? { |tag, value| tag.to_s.bytesize == 4 && numeric?(value) }
90
+ end
91
+
92
+ def subset_options?(options, path)
93
+ return false unless options.keys.all? { |key| %i[unicodes text profile timestamp].include?(key) }
94
+ return false if path.nil? || collection_file?(path)
95
+ return false unless SUBSET_PROFILES.include?(options.fetch(:profile, "web"))
96
+
97
+ text = options[:text]
98
+ return false unless text.nil? || text.is_a?(String)
99
+
100
+ Array(options[:unicodes]).all? { |cp| cp.is_a?(Integer) && cp.between?(0, MAX_CODEPOINT) }
101
+ end
102
+
103
+ def numeric?(value)
104
+ Float(value)
105
+ true
106
+ rescue ArgumentError, TypeError
107
+ false
108
+ end
109
+
110
+ def finish_convert(path, target, output, bytes)
111
+ File.binwrite(output, bytes)
112
+ result = { success: true, input_path: path, output_path: output }
113
+ result.merge!(convert_formats(path, target))
114
+ result.merge(input_size: File.size(path), output_size: bytes.bytesize,
115
+ **convert_tail(path))
116
+ end
117
+
118
+ def convert_formats(path, target)
119
+ return { source_format: single_format(path), target_format: target } unless collection_file?(path)
120
+
121
+ source = File.binread(path, 4) == "wOF2" ? :woff2_collection : :ttc
122
+ { source_format: source, target_format: target == :woff2 ? :woff2_collection : target }
123
+ end
124
+
125
+ # Collections report `num_fonts`; single fonts the variation strategy
126
+ # (an fvar font is converted as an instance, the rest are preserved).
127
+ def convert_tail(path)
128
+ return { num_fonts: collection_font_count(path) } if collection_file?(path)
129
+
130
+ { variation_strategy: variable_font?(path) ? :instance : :preserve }
131
+ end
132
+
133
+ def single_format(path)
134
+ FontLoader.detect_format(path)
135
+ end
136
+
137
+ # A WOFF2 collection is counted from its directory, not decoded: the
138
+ # decode needs `brotli`, which the java platform (JRuby) does not have, and
139
+ # the engine has already served the convert.
140
+ def collection_font_count(path)
141
+ if File.binread(path, 4) == "wOF2"
142
+ return Woff2::CollectionDecoder.new(File.binread(path)).font_count
143
+ end
144
+
145
+ FontLoader.load_collection(path).num_fonts
146
+ end
147
+
148
+ def variable_font?(path)
149
+ return woff2_tags(path).include?("fvar") if File.binread(path, 4) == "wOF2"
150
+
151
+ font = FontLoader.load(path, mode: :metadata, lazy: true)
152
+ font.has_table?("fvar")
153
+ ensure
154
+ font.close if font.respond_to?(:close)
155
+ end
156
+
157
+ # Table tags from the WOFF2 table directory, which is stored uncompressed
158
+ # (no `brotli` needed, unlike loading the font).
159
+ def woff2_tags(path)
160
+ File.open(path, "rb") do |io|
161
+ header = Woff2::Woff2Header.read(io)
162
+ Woff2Font.read_table_directory_from_io(io, header).map(&:tag)
163
+ end
164
+ end
165
+
166
+ def unix_seconds(timestamp)
167
+ timestamp.respond_to?(:to_time) ? timestamp.to_time.to_i : Integer(timestamp)
168
+ end
169
+
170
+ # Raise the Ruby tier's exception: class, message and the cause chain
171
+ # (each cause is raised so that its own #cause is set).
172
+ def raise_failure(details, byte_operation: false)
173
+ class_name, message, causes = details
174
+ cause = nil
175
+ causes.reverse_each { |name, text| cause = build_exception(name, text, cause) }
176
+ klass, message = byte_failure(class_name, message) if byte_operation
177
+ raise build_instance(klass || exception_class(class_name), message), cause: cause
178
+ end
179
+
180
+ # The Ruby class and message of a failed byte operation, so that a tier
181
+ # never changes what a caller can rescue or match: an engine error that
182
+ # names the Ruby exception it mirrors ("RuntimeError: ...") is unwrapped,
183
+ # and the engine's unrecognised-format error becomes the loader's.
184
+ def byte_failure(class_name, message)
185
+ if class_name == "Fontisan::Error" && (match = FAILURE_MESSAGE.match(message))
186
+ class_name = match[1]
187
+ message = match[2]
188
+ end
189
+ return [InvalidFontError, FontLoader::UNKNOWN_FORMAT_MESSAGE] if message == UNRECOGNISED_FORMAT_MESSAGE
190
+
191
+ [exception_class(class_name), message]
192
+ end
193
+
194
+ # `klass.new(message)`, or the `message:` keyword form some Fontisan
195
+ # errors use (MissingVariationTableError); never a failed constructor.
196
+ def build_instance(klass, message)
197
+ klass.new(message)
198
+ rescue ArgumentError
199
+ begin
200
+ klass.new(message: message)
201
+ rescue ArgumentError
202
+ Fontisan::Error.new(message)
203
+ end
204
+ end
205
+
206
+ def build_exception(name, text, cause)
207
+ raise build_instance(exception_class(name), text), cause: cause
208
+ rescue Exception => e # rubocop:disable Lint/RescueException
209
+ e
210
+ end
211
+
212
+ def exception_class(name)
213
+ klass = Object.const_get(name)
214
+ klass.is_a?(Class) && klass <= Exception ? klass : Fontisan::Error
215
+ rescue NameError
216
+ Fontisan::Error
217
+ end
218
+ end
219
+ end
data/lib/fontisan/cli.rb CHANGED
@@ -226,7 +226,7 @@ module Fontisan
226
226
  # @param font_file [String] Path to the font file
227
227
  def subset(font_file)
228
228
  command = Commands::SubsetCommand.new(font_file, options)
229
- result = command.run
229
+ result = command.run_routed
230
230
 
231
231
  unless options[:quiet]
232
232
  puts "Subset font created:"
@@ -422,7 +422,7 @@ module Fontisan
422
422
  convert_options[:options] = conv_options if conv_options
423
423
 
424
424
  command = Commands::ConvertCommand.new(font_file, convert_options)
425
- command.run
425
+ command.run_routed
426
426
  rescue Errno::ENOENT, Error => e
427
427
  handle_error(e)
428
428
  end
@@ -443,7 +443,7 @@ module Fontisan
443
443
  option :opsz, type: :numeric,
444
444
  desc: "Optical size axis value"
445
445
  option :named_instance, type: :string,
446
- desc: "Use named instance (e.g., 'Bold', 'Light')",
446
+ desc: "Use named instance by fvar index (e.g., 0, 1; see --list-instances)",
447
447
  aliases: "-n"
448
448
  option :list_instances, type: :boolean, default: false,
449
449
  desc: "List available named instances",
@@ -463,7 +463,7 @@ module Fontisan
463
463
  # fontisan instance variable.ttf --wght=700 --output=bold.ttf
464
464
  #
465
465
  # @example Use named instance
466
- # fontisan instance variable.ttf --named-instance="Bold" --output=bold.ttf
466
+ # fontisan instance variable.ttf --named-instance=0 --output=bold.ttf
467
467
  #
468
468
  # @example Instance with format conversion
469
469
  # fontisan instance variable.ttf --wght=700 --to=woff2 --output=bold.woff2
@@ -668,7 +668,7 @@ module Fontisan
668
668
  # fontisan unpack family.ttc --output-dir extracted/ --prefix "NotoSans"
669
669
  def unpack(font_file)
670
670
  command = Commands::UnpackCommand.new(font_file, options)
671
- result = command.run
671
+ result = command.run_routed
672
672
 
673
673
  unless options[:quiet]
674
674
  puts "Collection unpacked successfully:"
@@ -103,9 +103,9 @@ module Fontisan
103
103
  # ------------------------------------------------------------------
104
104
 
105
105
  def populate_identity(report, face)
106
- return unless face.has_table?(Constants::NAME_TAG)
106
+ name_table = readable_table(face, Constants::NAME_TAG)
107
+ return unless name_table
107
108
 
108
- name_table = face.table(Constants::NAME_TAG)
109
109
  report.family_name = name_table.english_name(Tables::Name::FAMILY)
110
110
  report.subfamily_name = name_table.english_name(Tables::Name::SUBFAMILY)
111
111
  report.full_name = name_table.english_name(Tables::Name::FULL_NAME)
@@ -113,9 +113,17 @@ module Fontisan
113
113
  name_table.english_name(Tables::Name::POSTSCRIPT_NAME)
114
114
  report.version = name_table.english_name(Tables::Name::VERSION)
115
115
 
116
- return unless face.has_table?(Constants::HEAD_TAG)
116
+ head = readable_table(face, Constants::HEAD_TAG)
117
+ report.font_revision = head.font_revision if head
118
+ end
117
119
 
118
- report.font_revision = face.table(Constants::HEAD_TAG).font_revision
120
+ # A table listed in the directory whose data cannot be read (offset or
121
+ # length outside the file) comes back from `face.table` as nil. The
122
+ # audit reports on what is readable, so such a table is treated as
123
+ # absent: its section stays empty instead of raising NoMethodError on
124
+ # nil. The native tier does the same.
125
+ def readable_table(face, tag)
126
+ face.has_table?(tag) ? face.table(tag) : nil
119
127
  end
120
128
 
121
129
  # ------------------------------------------------------------------
@@ -126,13 +134,15 @@ module Fontisan
126
134
  report.is_variable = false
127
135
  report.italic = false
128
136
  report.bold = false
129
- populate_os2_style(report, face) if face.has_table?(Constants::OS2_TAG)
130
- populate_head_style(report, face) if face.has_table?(Constants::HEAD_TAG)
131
- populate_fvar_style(report, face) if face.has_table?(Constants::FVAR_TAG)
137
+ populate_os2_style(report, face)
138
+ populate_head_style(report, face)
139
+ populate_fvar_style(report, face)
132
140
  end
133
141
 
134
142
  def populate_os2_style(report, face)
135
- os2 = face.table(Constants::OS2_TAG)
143
+ os2 = readable_table(face, Constants::OS2_TAG)
144
+ return unless os2
145
+
136
146
  report.weight_class = os2.us_weight_class
137
147
  report.width_class = os2.us_width_class
138
148
  report.italic = os2.fs_selection && (os2.fs_selection & 0x01).positive?
@@ -141,7 +151,9 @@ module Fontisan
141
151
  end
142
152
 
143
153
  def populate_head_style(report, face)
144
- head = face.table(Constants::HEAD_TAG)
154
+ head = readable_table(face, Constants::HEAD_TAG)
155
+ return unless head
156
+
145
157
  mac_style = head.mac_style || 0
146
158
  # head macStyle overrides OS/2 fsSelection only when OS/2 didn't set it
147
159
  report.italic = true if (mac_style & 0x02).positive?
@@ -149,8 +161,10 @@ module Fontisan
149
161
  end
150
162
 
151
163
  def populate_fvar_style(report, face)
164
+ fvar = readable_table(face, Constants::FVAR_TAG)
165
+ return unless fvar
166
+
152
167
  report.is_variable = true
153
- fvar = face.table(Constants::FVAR_TAG)
154
168
  report.axes = fvar.axes.map do |axis|
155
169
  Models::AuditAxis.new(
156
170
  tag: axis.axis_tag,
@@ -173,8 +187,8 @@ module Fontisan
173
187
  # ------------------------------------------------------------------
174
188
 
175
189
  def populate_coverage(report, face)
176
- if face.has_table?(Constants::CMAP_TAG)
177
- cmap = face.table(Constants::CMAP_TAG)
190
+ cmap = readable_table(face, Constants::CMAP_TAG)
191
+ if cmap
178
192
  mappings = cmap.unicode_mappings || {}
179
193
  report.total_codepoints = mappings.size
180
194
  report.cmap_subtables = cmap.subtable_formats
@@ -208,48 +222,29 @@ module Fontisan
208
222
  # OpenType layout (GSUB/GPOS)
209
223
  # ------------------------------------------------------------------
210
224
 
225
+ # GSUB and GPOS are each read once through the budgeted
226
+ # `script_features`, which serves both the scripts and their features.
211
227
  def populate_layout(report, face)
212
- report.opentype_scripts = collect_scripts(face)
213
- report.features = collect_features(face)
214
- end
215
-
216
- def collect_scripts(face)
217
228
  scripts = Set.new
218
- %w[GSUB GPOS].each do |tag|
219
- next unless face.has_table?(tag)
220
-
221
- table = face.table(tag)
222
- scripts.merge(table.scripts) if table
223
- end
224
- scripts.sort
225
- end
226
-
227
- def collect_features(face)
228
229
  features = Set.new
229
230
  %w[GSUB GPOS].each do |tag|
230
231
  next unless face.has_table?(tag)
231
232
 
232
233
  table = face.table(tag)
233
- features.merge(collect_features_from_table(table)) if table
234
- end
235
- features.sort
236
- end
234
+ next unless table
237
235
 
238
- def collect_features_from_table(layout_table)
239
- scripts = begin
240
- layout_table.scripts
241
- rescue StandardError
242
- []
236
+ scripts.merge(table.scripts)
237
+ table.script_features.each_value { |tags| features.merge(tags) }
243
238
  end
244
- scripts.flat_map { |script| features_for_script(layout_table, script) }
245
- rescue StandardError
246
- []
239
+ report.opentype_scripts = printable_tags(scripts.sort)
240
+ report.features = printable_tags(features.sort)
247
241
  end
248
242
 
249
- def features_for_script(layout_table, script)
250
- layout_table.features(script_tag: script)
251
- rescue StandardError
252
- []
243
+ # Layout tags come from BinData as ASCII-8BIT. A damaged table can yield
244
+ # bytes that are not UTF-8, which the JSON generator rejects. Each
245
+ # invalid byte becomes U+FFFD, as the native tier's lossy decode does.
246
+ def printable_tags(tags)
247
+ tags.map { |tag| tag.dup.force_encoding(Encoding::UTF_8).scrub }
253
248
  end
254
249
 
255
250
  # ------------------------------------------------------------------
@@ -64,10 +64,13 @@ module Fontisan
64
64
  # @option options [Boolean] :preserve_variation Preserve variation data (default: auto)
65
65
  # @option options [Boolean] :preserve_hints Preserve rendering hints (default: false)
66
66
  # @option options [String] :target_format Target outline format for collections: 'preserve' (default), 'ttf', or 'otf'
67
+ # @option options [Integer] :timestamp Fixed head.modified (Unix seconds)
68
+ # for WOFF2 output; nil (default) stamps the current time
67
69
  # @option options [Boolean] :no_validate Skip output validation
68
70
  # @option options [Boolean] :verbose Verbose output
69
71
  # @option options [ConversionOptions] :options ConversionOptions object
70
72
  def initialize(font_path, options = {})
73
+ check_sfnt_directory!(font_path)
71
74
  super(font_path, options)
72
75
 
73
76
  # Convert string keys to symbols for Thor compatibility. All command
@@ -134,8 +137,57 @@ module Fontisan
134
137
  raise Error, "Conversion failed: #{e.message}"
135
138
  end
136
139
 
140
+ # Execute through the engine selection (the CLI entry): a tier other than
141
+ # Ruby serves the plain single-target conversions it supports, anything
142
+ # else (several targets, instance coordinates, ConversionOptions, hints,
143
+ # collection target format, compression knobs, a font index) runs {#run}
144
+ # on the Ruby tier. A routed conversion prints the same summary lines
145
+ # (minus the variation note) unless +quiet+.
146
+ #
147
+ # @return [Hash] same as {#run} for a single target
148
+ def run_routed
149
+ validate_options!
150
+ gate = routed_options
151
+ return run unless gate && Engine.resolve(:convert, font_path.to_s, gate) != :ruby
152
+
153
+ result = Reference.convert(font_path, **gate)
154
+ print_routed_summary(result) unless @options[:quiet]
155
+ result
156
+ end
157
+
137
158
  private
138
159
 
160
+ # A plain sfnt whose table directory the engine tiers reject fails with
161
+ # their error, before the loader can raise its own (fontist/fontisan#159).
162
+ def check_sfnt_directory!(path)
163
+ path = path.to_path if path.respond_to?(:to_path)
164
+ return unless path.is_a?(String) && File.file?(path)
165
+ return unless %i[ttf otf].include?(FontLoader.detect_format(path))
166
+
167
+ SfntDirectory.check!(File.binread(path))
168
+ rescue SfntDirectory::Malformed => e
169
+ raise Error, "Conversion failed: #{e.message}"
170
+ end
171
+
172
+ # Reference.convert keywords when the call is a plain conversion; else nil.
173
+ def routed_options
174
+ return nil if multi_format? || @coordinates || @instance_index || @conv_options
175
+ return nil if @preserve_hints || !@preserve_variation.nil? || @collection_target_format != "preserve"
176
+ return nil if @options.fetch(:font_index, 0).to_i != 0
177
+ return nil if Converters::FormatConverter.all_strategy_option_names.any? { |name| @options.key?(name) }
178
+ return nil unless @output_path.is_a?(String)
179
+
180
+ { to: @target_formats.first, output: @output_path }
181
+ end
182
+
183
+ def print_routed_summary(result)
184
+ puts "Converting #{File.basename(font_path)} to #{result[:target_format]}..."
185
+ puts "Conversion complete!"
186
+ puts " Input: #{font_path} (#{format_size(result[:input_size])})"
187
+ puts " Output: #{result[:output_path]} (#{format_size(result[:output_size])})"
188
+ puts " Format: #{result[:source_format]} → #{result[:target_format]}"
189
+ end
190
+
139
191
  # @return [Boolean] true when more than one target format was given
140
192
  def multi_format?
141
193
  @target_formats.size > 1
@@ -268,6 +320,7 @@ module Fontisan
268
320
  output: @output_path,
269
321
  target_format: @collection_target_format,
270
322
  verbose: @options[:verbose],
323
+ timestamp: @options[:timestamp],
271
324
  options: @conv_options, # Pass ConversionOptions
272
325
  },
273
326
  )
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
3
4
  require "thor"
4
5
 
5
6
  module Fontisan
@@ -27,10 +28,27 @@ module Fontisan
27
28
  # @example Dry-run to preview
28
29
  # fontisan instance variable.ttf --wght=700 --dry-run
29
30
  class InstanceCommand < BaseCommand
31
+ # Options that make an instance request more than plain axis coordinates.
32
+ NON_PLAIN_OPTIONS = %i[validate list_instances dry_run named_instance
33
+ to optimize progress].freeze
34
+ TRUETYPE_MAGIC = "\x00\x01\x00\x00".b.freeze
35
+
36
+ # The CLI builds the command bare and hands the font to {#execute}, so
37
+ # the font is not loaded here (BaseCommand's constructor loads one).
38
+ #
39
+ # @param font_path [String, nil] unused until {#execute}
40
+ # @param options [Hash] command options
41
+ def initialize(font_path = nil, options = {}) # rubocop:disable Lint/MissingSuper
42
+ @font_path = font_path
43
+ @options = options
44
+ end
45
+
30
46
  # Instance a variable font at specified coordinates
31
47
  #
32
48
  # @param input_path [String] Path to variable font file
33
49
  def execute(input_path, options = {})
50
+ return if write_routed(input_path, options)
51
+
34
52
  # Load variable font
35
53
  font = load_font(input_path)
36
54
 
@@ -72,6 +90,34 @@ module Fontisan
72
90
 
73
91
  private
74
92
 
93
+ # Serve a plain request (axis coordinates, the font's own format, a
94
+ # TrueType-outline sfnt) through the engine selection. Named instances,
95
+ # listing, dry-run, validation, --to, --optimize, --progress, CFF2 or
96
+ # wrapped inputs stay on the Ruby path in {#execute}. The Ruby writer and
97
+ # {Reference.instance} produce identical bytes for these requests.
98
+ #
99
+ # @return [String, nil] the output path when a non-Ruby tier wrote the
100
+ # instance; nil when {#execute} must run the Ruby path
101
+ def write_routed(input_path, options)
102
+ gate = routed_gate(input_path, options)
103
+ return nil unless gate && Engine.resolve(:instance, input_path.to_s, gate) != :ruby
104
+
105
+ output_path = determine_output_path(input_path, options)
106
+ FileUtils.mkdir_p(File.dirname(output_path))
107
+ File.binwrite(output_path, Reference.instance(input_path, **gate))
108
+ puts "Static font instance written to: #{output_path}"
109
+ output_path
110
+ end
111
+
112
+ # Reference.instance keywords for a plain request; nil otherwise.
113
+ def routed_gate(input_path, options)
114
+ return nil if NON_PLAIN_OPTIONS.any? { |key| options[key] }
115
+ return nil unless File.file?(input_path) && File.binread(input_path, 4) == TRUETYPE_MAGIC
116
+
117
+ coords = extract_coordinates(options)
118
+ coords.empty? ? nil : { coords: coords }
119
+ end
120
+
75
121
  # Validate font before generating instance
76
122
  #
77
123
  # @param font [Object] Font object
@@ -164,6 +210,8 @@ module Fontisan
164
210
  # @param output_path [String] Output file path
165
211
  # @param options [Hash] Command options
166
212
  def instance_named(font, instance_index, output_path, options)
213
+ instance_index = parse_instance_index(instance_index, font)
214
+
167
215
  # Generate instance using named instance
168
216
  generator = Variation::InstanceGenerator.new(font)
169
217
  tables = generator.generate_named_instance(instance_index)
@@ -181,6 +229,33 @@ module Fontisan
181
229
  )
182
230
  end
183
231
 
232
+ # Convert the --named-instance value (a string from Thor) to a valid
233
+ # fvar instance index. The generator takes an Integer, so the conversion
234
+ # and range check live here, at the command boundary.
235
+ #
236
+ # @param value [String, Integer] raw option value
237
+ # @param font [Object] Font object
238
+ # @return [Integer] index into the fvar named instances
239
+ # @raise [ArgumentError] when not a non-negative integer or out of range
240
+ def parse_instance_index(value, font)
241
+ index = begin
242
+ Integer(value.to_s, 10)
243
+ rescue ArgumentError
244
+ raise ArgumentError,
245
+ "--named-instance must be an integer index, got #{value.inspect}"
246
+ end
247
+ raise ArgumentError, "--named-instance must be >= 0, got #{index}" if index.negative?
248
+
249
+ fvar = font.table("fvar") if font.respond_to?(:table)
250
+ count = fvar&.instances&.length
251
+ if count && index >= count
252
+ raise ArgumentError,
253
+ "--named-instance #{index} out of range (font has #{count} named instances)"
254
+ end
255
+
256
+ index
257
+ end
258
+
184
259
  # List available named instances
185
260
  #
186
261
  # @param font [Object] Font object
@@ -33,6 +33,8 @@ module Fontisan
33
33
  # )
34
34
  # command.run
35
35
  class SubsetCommand < BaseCommand
36
+ ROUTED_PROFILES = %w[pdf web minimal].freeze
37
+
36
38
  # Initialize subset command
37
39
  #
38
40
  # @param font_path [String] Path to input font file
@@ -86,8 +88,49 @@ module Fontisan
86
88
  "Subsetting failed: #{e.message}"
87
89
  end
88
90
 
91
+ # Execute through the engine selection (the CLI entry): a tier other
92
+ # than Ruby subsets by text or Unicode codepoints with a profile the
93
+ # engine knows, and writes the bytes {#run} writes. Glyph-id input,
94
+ # retained GIDs, dropped hints or names, disabled Unicode-range pruning
95
+ # and verbose output run {#run} on the Ruby tier.
96
+ #
97
+ # @return [Hash] same as {#run}
98
+ def run_routed
99
+ gate = routed_gate
100
+ return run unless gate && Engine.resolve(:subset, font_path.to_s, gate) != :ruby
101
+
102
+ determine_glyph_ids # same errors as the Ruby path when nothing matches
103
+ bytes = Reference.subset(font_path, **gate)
104
+ FileUtils.mkdir_p(File.dirname(@output_path))
105
+ File.binwrite(@output_path, bytes)
106
+ routed_result(bytes, gate[:profile])
107
+ end
108
+
89
109
  private
90
110
 
111
+ # Reference.subset keywords for a plain request; nil otherwise.
112
+ def routed_gate
113
+ profile = options[:profile] || "pdf"
114
+ return nil if options[:glyphs] || options[:retain_gids] || options[:drop_hints] ||
115
+ options[:drop_names] || options[:unicode_ranges] == false || options[:verbose]
116
+ return nil unless ROUTED_PROFILES.include?(profile)
117
+
118
+ input = options[:text] ? { text: options[:text] } : { unicodes: parse_unicode(options[:unicode]) }
119
+ input.merge(profile: profile)
120
+ end
121
+
122
+ def routed_result(bytes, profile)
123
+ subset = FontLoader.load(@output_path)
124
+ {
125
+ input: font_path,
126
+ output: @output_path,
127
+ original_glyphs: font.table("maxp").num_glyphs,
128
+ subset_glyphs: subset.table("maxp").num_glyphs,
129
+ profile: profile,
130
+ size: bytes.bytesize,
131
+ }
132
+ end
133
+
91
134
  # Validate command options
92
135
  #
93
136
  # @raise [ArgumentError] If options are invalid