fontisan 0.4.47 → 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.
- checksums.yaml +4 -4
- data/BENCHMARKS.md +56 -0
- data/CHANGELOG.md +12 -0
- data/Cargo.lock +550 -0
- data/Cargo.toml +12 -0
- data/Gemfile +10 -0
- data/README.adoc +52 -0
- data/Rakefile +5 -1
- data/benchmark/baseline.json +5 -0
- data/benchmark/ci_gate.rb +217 -0
- data/benchmark/corpus/harness.rb +175 -0
- data/benchmark/corpus/ops.rb +150 -0
- data/benchmark/corpus/run.rb +182 -0
- data/benchmark/corpus/scan_child.rb +8 -0
- data/benchmark/corpus/tiers.rb +176 -0
- data/benchmark/tier_baseline.json +12 -0
- data/docs/.vitepress/config.ts +2 -1
- data/docs/guide/engines.md +117 -0
- data/docs/reference-facade.md +130 -0
- data/ext/fontisan_cdylib/Cargo.lock +313 -0
- data/ext/fontisan_cdylib/Cargo.toml +18 -0
- data/ext/fontisan_cdylib/build.rs +22 -0
- data/ext/fontisan_cdylib/extconf.rb +36 -0
- data/ext/fontisan_cdylib/src/lib.rs +13 -0
- data/ext/fontisan_native/Cargo.toml +21 -0
- data/ext/fontisan_native/build.rs +22 -0
- data/ext/fontisan_native/clippy.toml +10 -0
- data/ext/fontisan_native/extconf.rb +59 -0
- data/ext/fontisan_native/src/lib.rs +463 -0
- data/lib/fontisan/audit/checks/cmap_check.rb +7 -0
- data/lib/fontisan/audit/checks/hinting_check.rb +1 -1
- data/lib/fontisan/audit/checks/maxp_check.rb +1 -1
- data/lib/fontisan/audit/checks/opentype_conformance_check.rb +5 -2
- data/lib/fontisan/base_collection.rb +30 -0
- data/lib/fontisan/byte_ops.rb +219 -0
- data/lib/fontisan/cli.rb +5 -5
- data/lib/fontisan/commands/audit_command.rb +38 -43
- data/lib/fontisan/commands/convert_command.rb +53 -0
- data/lib/fontisan/commands/instance_command.rb +75 -0
- data/lib/fontisan/commands/subset_command.rb +43 -0
- data/lib/fontisan/commands/unpack_command.rb +56 -0
- data/lib/fontisan/converters/cff_table_builder.rb +1 -1
- data/lib/fontisan/converters/collection_converter.rb +13 -5
- data/lib/fontisan/converters/format_converter.rb +1 -1
- data/lib/fontisan/converters/woff2_encoder.rb +8 -6
- data/lib/fontisan/converters/woff_writer.rb +6 -5
- data/lib/fontisan/engine.rb +350 -0
- data/lib/fontisan/ffi/client.rb +121 -0
- data/lib/fontisan/ffi.rb +343 -0
- data/lib/fontisan/ffi_platform.rb +24 -0
- data/lib/fontisan/font_loader.rb +5 -1
- data/lib/fontisan/font_writer.rb +13 -1
- data/lib/fontisan/native.rb +231 -0
- data/lib/fontisan/reference/table_digests.rb +251 -0
- data/lib/fontisan/reference.rb +619 -0
- data/lib/fontisan/scan.rb +372 -0
- data/lib/fontisan/sfnt_directory.rb +41 -0
- data/lib/fontisan/sfnt_font.rb +16 -6
- data/lib/fontisan/subset/builder.rb +2 -1
- data/lib/fontisan/tables/cff/charstring.rb +16 -0
- data/lib/fontisan/tables/cff.rb +15 -5
- data/lib/fontisan/tables/cmap.rb +11 -1
- data/lib/fontisan/tables/gpos.rb +7 -0
- data/lib/fontisan/tables/gsub.rb +7 -0
- data/lib/fontisan/tables/layout_common.rb +172 -0
- data/lib/fontisan/tables/name.rb +10 -4
- data/lib/fontisan/utilities/brotli_wrapper.rb +39 -4
- data/lib/fontisan/validators/opentype_validator.rb +2 -0
- data/lib/fontisan/variable/instancer.rb +1 -0
- data/lib/fontisan/variable/static_font_builder.rb +4 -1
- data/lib/fontisan/variable/table_updater.rb +4 -2
- data/lib/fontisan/variation/instancing/true_type_instancer.rb +14 -6
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/collection_decoder.rb +15 -1
- data/lib/fontisan/woff2/collection_encoder.rb +29 -6
- data/lib/fontisan/woff2/encoder_rules.rb +12 -4
- data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +1 -1
- data/lib/fontisan/woff2_collection.rb +7 -5
- data/lib/fontisan/woff2_font.rb +12 -3
- data/lib/fontisan/woff_container.rb +83 -0
- data/lib/fontisan/woff_font.rb +24 -20
- data/lib/fontisan.rb +24 -1
- data/rakelib/bench.rake +9 -0
- data/rakelib/cdylib.rake +39 -0
- data/rakelib/corpus.rake +272 -0
- data/rakelib/corpus_convert.rake +43 -0
- data/rakelib/corpus_goldens.rake +22 -0
- data/rakelib/corpus_malformed.rake +20 -0
- data/rakelib/corpus_scan.rake +24 -0
- data/rakelib/java.rake +61 -0
- data/rakelib/native.rake +43 -0
- data/scripts/build-woff2.sh +57 -0
- data/scripts/corpus/generate_convert_goldens.rb +478 -0
- data/scripts/corpus/generate_goldens.rb +282 -0
- data/scripts/corpus/generate_malformed_goldens.rb +254 -0
- data/scripts/corpus/generate_scan_goldens.rb +98 -0
- data/scripts/tier_fallback.rb +219 -0
- data/scripts/tier_smoke.rb +64 -0
- metadata +68 -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.
|
|
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.
|
|
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.,
|
|
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=
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
116
|
+
head = readable_table(face, Constants::HEAD_TAG)
|
|
117
|
+
report.font_revision = head.font_revision if head
|
|
118
|
+
end
|
|
117
119
|
|
|
118
|
-
|
|
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)
|
|
130
|
-
populate_head_style(report, face)
|
|
131
|
-
populate_fvar_style(report, face)
|
|
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
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
234
|
-
end
|
|
235
|
-
features.sort
|
|
236
|
-
end
|
|
234
|
+
next unless table
|
|
237
235
|
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
[]
|
|
239
|
+
report.opentype_scripts = printable_tags(scripts.sort)
|
|
240
|
+
report.features = printable_tags(features.sort)
|
|
247
241
|
end
|
|
248
242
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|