fontisan 0.4.6 → 0.4.8
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/BUG-stitcher-drops-isolated-cps.md +58 -0
- data/BUG-stitcher-drops-plane1-codepoints.md +310 -0
- data/BUG-stitcher-gid-cap-65535.md +110 -0
- data/CHANGELOG.md +119 -0
- data/README.adoc +121 -68
- data/benchmark/compile_benchmark.rb +70 -0
- data/docs/.vitepress/config.ts +0 -7
- data/docs/CFF2_SUPPORT.adoc +184 -0
- data/docs/STITCHER_GUIDE.adoc +151 -0
- data/docs/SVG_TO_GLYF.adoc +118 -0
- data/docs/UFO_COMPILATION.adoc +119 -0
- data/docs/cli/index.md +5 -28
- data/docs/index.md +0 -2
- data/lib/fontisan/cli.rb +29 -8
- data/lib/fontisan/collection/reader/stats.rb +23 -0
- data/lib/fontisan/collection/reader.rb +90 -0
- data/lib/fontisan/collection/writer.rb +5 -6
- data/lib/fontisan/collection.rb +1 -0
- data/lib/fontisan/commands/convert_command.rb +96 -18
- data/lib/fontisan/commands/multi_format_output.rb +59 -0
- data/lib/fontisan/commands/validate_collection_command.rb +121 -0
- data/lib/fontisan/commands.rb +2 -0
- data/lib/fontisan/error.rb +56 -0
- data/lib/fontisan/models.rb +0 -1
- data/lib/fontisan/stitcher/collection_result.rb +18 -0
- data/lib/fontisan/stitcher/deduplicator.rb +47 -0
- data/lib/fontisan/stitcher/glyph_limit.rb +53 -0
- data/lib/fontisan/stitcher/glyph_signature.rb +51 -0
- data/lib/fontisan/stitcher/partition_strategy/base.rb +23 -0
- data/lib/fontisan/stitcher/partition_strategy/blueprint.rb +24 -0
- data/lib/fontisan/stitcher/partition_strategy/by_plane.rb +131 -0
- data/lib/fontisan/stitcher/partition_strategy/partition.rb +24 -0
- data/lib/fontisan/stitcher/partition_strategy.rb +22 -0
- data/lib/fontisan/stitcher.rb +223 -168
- data/lib/fontisan/svg_to_glyf/assembler.rb +132 -0
- data/lib/fontisan/svg_to_glyf/document.rb +83 -0
- data/lib/fontisan/svg_to_glyf/geometry/affine_transform.rb +112 -0
- data/lib/fontisan/svg_to_glyf/geometry/normalizer.rb +45 -0
- data/lib/fontisan/svg_to_glyf/geometry/transform_parser.rb +91 -0
- data/lib/fontisan/svg_to_glyf/geometry.rb +13 -0
- data/lib/fontisan/svg_to_glyf/path/command.rb +18 -0
- data/lib/fontisan/svg_to_glyf/path/contour_builder.rb +140 -0
- data/lib/fontisan/svg_to_glyf/path/parser.rb +98 -0
- data/lib/fontisan/svg_to_glyf/path/state.rb +79 -0
- data/lib/fontisan/svg_to_glyf/path.rb +14 -0
- data/lib/fontisan/svg_to_glyf.rb +62 -0
- data/lib/fontisan/tables/cff/cff2_charstring_builder.rb +216 -0
- data/lib/fontisan/tables/cff.rb +1 -0
- data/lib/fontisan/tables/cff2/dict_encoder.rb +94 -0
- data/lib/fontisan/tables/cff2/fd_select.rb +69 -0
- data/lib/fontisan/tables/cff2/header.rb +34 -0
- data/lib/fontisan/tables/cff2/index_builder.rb +79 -0
- data/lib/fontisan/tables/cff2.rb +4 -0
- data/lib/fontisan/ufo/compile/cbdt_cblc.rb +103 -0
- data/lib/fontisan/ufo/compile/cff2.rb +181 -0
- data/lib/fontisan/ufo/compile/cff2_subroutines.rb +39 -0
- data/lib/fontisan/ufo/compile/colr.rb +80 -0
- data/lib/fontisan/ufo/compile/cpal.rb +61 -0
- data/lib/fontisan/ufo/compile/math.rb +143 -0
- data/lib/fontisan/ufo/compile/meta.rb +51 -0
- data/lib/fontisan/ufo/compile/name.rb +2 -2
- data/lib/fontisan/ufo/compile/otf2_compiler.rb +46 -0
- data/lib/fontisan/ufo/compile/sbix.rb +99 -0
- data/lib/fontisan/ufo/compile/svg_table.rb +60 -0
- data/lib/fontisan/ufo/compile/variable_otf.rb +75 -0
- data/lib/fontisan/ufo/compile.rb +11 -0
- data/lib/fontisan/ufo/info.rb +48 -0
- data/lib/fontisan/unicode/plane.rb +56 -0
- data/lib/fontisan/unicode.rb +17 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +5 -2
- metadata +53 -19
- data/docs/cli/audit.md +0 -337
- data/lib/fontisan/cldr/aggregator.rb +0 -33
- data/lib/fontisan/cldr/cache_manager.rb +0 -110
- data/lib/fontisan/cldr/config.rb +0 -59
- data/lib/fontisan/cldr/download_error.rb +0 -9
- data/lib/fontisan/cldr/downloader.rb +0 -79
- data/lib/fontisan/cldr/error.rb +0 -8
- data/lib/fontisan/cldr/index.rb +0 -64
- data/lib/fontisan/cldr/index_builder.rb +0 -72
- data/lib/fontisan/cldr/unicode_set_parser.rb +0 -189
- data/lib/fontisan/cldr/unknown_version_error.rb +0 -9
- data/lib/fontisan/cldr/version_resolver.rb +0 -91
- data/lib/fontisan/cldr.rb +0 -23
- data/lib/fontisan/cli/cldr_cli.rb +0 -85
- data/lib/fontisan/config/cldr.yml +0 -22
- data/lib/fontisan/models/cldr/language_coverage.rb +0 -31
- data/lib/fontisan/models/cldr.rb +0 -12
|
@@ -42,11 +42,20 @@ module Fontisan
|
|
|
42
42
|
# )
|
|
43
43
|
# command.run
|
|
44
44
|
class ConvertCommand < BaseCommand
|
|
45
|
+
# Public readers for the parsed target formats and per-format output
|
|
46
|
+
# paths. Exposed (rather than relying on @ivar access from specs) so
|
|
47
|
+
# multi-format behaviour can be tested through the public surface.
|
|
48
|
+
attr_reader :target_formats, :output_paths
|
|
49
|
+
|
|
45
50
|
# Initialize convert command
|
|
46
51
|
#
|
|
47
52
|
# @param font_path [String] Path to input font file
|
|
48
53
|
# @param options [Hash] Conversion options
|
|
49
|
-
# @option options [String] :to Target format
|
|
54
|
+
# @option options [String, Array<String>] :to Target format(s):
|
|
55
|
+
# ttf, otf, woff, woff2, type1, ttc, otc, dfont, svg. Pass a
|
|
56
|
+
# comma-separated string ("woff,woff2") or an array (["woff",
|
|
57
|
+
# "woff2"]) for multi-format output. Single-font → single-font
|
|
58
|
+
# only; multi-format + collection input is rejected.
|
|
50
59
|
# @option options [String] :output Output file path (required)
|
|
51
60
|
# @option options [Integer] :font_index Index for TTC/OTC (default: 0)
|
|
52
61
|
# @option options [String] :coordinates Coordinate string (e.g., "wght=700,wdth=100")
|
|
@@ -69,8 +78,13 @@ module Fontisan
|
|
|
69
78
|
|
|
70
79
|
@output_path = @options[:output]
|
|
71
80
|
|
|
72
|
-
# Parse target format
|
|
73
|
-
|
|
81
|
+
# Parse target format(s). Always an Array<Symbol>, deduped, order
|
|
82
|
+
# preserved. Single-format callsites get a one-element array.
|
|
83
|
+
@target_formats = parse_target_formats(@options[:to])
|
|
84
|
+
|
|
85
|
+
# Resolve per-format output paths up front so filename ambiguity
|
|
86
|
+
# surfaces before any pipeline work is done.
|
|
87
|
+
@output_paths = resolve_output_paths
|
|
74
88
|
|
|
75
89
|
# Extract ConversionOptions if provided
|
|
76
90
|
@conv_options = extract_conversion_options(@options)
|
|
@@ -92,17 +106,26 @@ module Fontisan
|
|
|
92
106
|
|
|
93
107
|
# Execute the conversion
|
|
94
108
|
#
|
|
95
|
-
# @return [Hash] Result information
|
|
109
|
+
# @return [Hash, Array<Hash>] Result information. Single-format
|
|
110
|
+
# returns one result hash (back-compat). Multi-format returns
|
|
111
|
+
# an array of result hashes, one per target format.
|
|
96
112
|
# @raise [ArgumentError] If output path is not specified
|
|
97
113
|
# @raise [Error] If conversion fails
|
|
98
114
|
def run
|
|
99
115
|
validate_options!
|
|
100
116
|
|
|
101
|
-
# Check if input is a collection
|
|
102
117
|
if collection_file?
|
|
118
|
+
if multi_format?
|
|
119
|
+
raise ArgumentError,
|
|
120
|
+
"Multi-format conversion is not supported for collection " \
|
|
121
|
+
"input. Specify a single target format."
|
|
122
|
+
end
|
|
123
|
+
|
|
103
124
|
convert_collection
|
|
125
|
+
elsif multi_format?
|
|
126
|
+
convert_multi_format
|
|
104
127
|
else
|
|
105
|
-
convert_single_font
|
|
128
|
+
convert_single_font(@target_formats.first, @output_paths.first)
|
|
106
129
|
end
|
|
107
130
|
rescue ArgumentError
|
|
108
131
|
# Let ArgumentError propagate for validation errors
|
|
@@ -113,6 +136,11 @@ module Fontisan
|
|
|
113
136
|
|
|
114
137
|
private
|
|
115
138
|
|
|
139
|
+
# @return [Boolean] true when more than one target format was given
|
|
140
|
+
def multi_format?
|
|
141
|
+
@target_formats.size > 1
|
|
142
|
+
end
|
|
143
|
+
|
|
116
144
|
# Check if input file is a collection
|
|
117
145
|
#
|
|
118
146
|
# @return [Boolean] true if collection
|
|
@@ -123,15 +151,36 @@ module Fontisan
|
|
|
123
151
|
false
|
|
124
152
|
end
|
|
125
153
|
|
|
126
|
-
# Convert
|
|
154
|
+
# Convert to each target format and aggregate results.
|
|
127
155
|
#
|
|
156
|
+
# @return [Array<Hash>]
|
|
157
|
+
def convert_multi_format
|
|
158
|
+
unless @options[:quiet]
|
|
159
|
+
puts "Converting #{File.basename(font_path)} to " \
|
|
160
|
+
"#{@target_formats.join(', ')}..."
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
@target_formats.zip(@output_paths).map do |fmt, out_path|
|
|
164
|
+
result = convert_single_font(fmt, out_path)
|
|
165
|
+
unless @options[:quiet]
|
|
166
|
+
puts " #{fmt}: wrote #{File.basename(out_path)} " \
|
|
167
|
+
"(#{format_size(File.size(out_path))})"
|
|
168
|
+
end
|
|
169
|
+
result
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Convert a single font to a single target format at +out_path+.
|
|
174
|
+
#
|
|
175
|
+
# @param target_format [Symbol]
|
|
176
|
+
# @param out_path [String]
|
|
128
177
|
# @return [Hash] Result information
|
|
129
|
-
def convert_single_font
|
|
130
|
-
puts "Converting #{File.basename(font_path)} to #{
|
|
178
|
+
def convert_single_font(target_format, out_path)
|
|
179
|
+
puts "Converting #{File.basename(font_path)} to #{target_format}..." unless @options[:quiet]
|
|
131
180
|
|
|
132
181
|
# Build pipeline options
|
|
133
182
|
pipeline_options = {
|
|
134
|
-
target_format:
|
|
183
|
+
target_format: target_format,
|
|
135
184
|
validate: @validate,
|
|
136
185
|
verbose: @options[:verbose],
|
|
137
186
|
}
|
|
@@ -160,7 +209,7 @@ module Fontisan
|
|
|
160
209
|
# Use TransformationPipeline for universal conversion
|
|
161
210
|
pipeline = Pipeline::TransformationPipeline.new(
|
|
162
211
|
font_path,
|
|
163
|
-
|
|
212
|
+
out_path,
|
|
164
213
|
pipeline_options,
|
|
165
214
|
)
|
|
166
215
|
|
|
@@ -168,12 +217,12 @@ module Fontisan
|
|
|
168
217
|
|
|
169
218
|
# Display results
|
|
170
219
|
unless @options[:quiet]
|
|
171
|
-
output_size = File.size(
|
|
220
|
+
output_size = File.size(out_path)
|
|
172
221
|
input_size = File.size(font_path)
|
|
173
222
|
|
|
174
223
|
puts "Conversion complete!"
|
|
175
224
|
puts " Input: #{font_path} (#{format_size(input_size)})"
|
|
176
|
-
puts " Output: #{
|
|
225
|
+
puts " Output: #{out_path} (#{format_size(output_size)})"
|
|
177
226
|
puts " Format: #{result[:details][:source_format]} → #{result[:details][:target_format]}"
|
|
178
227
|
|
|
179
228
|
if result[:details][:variation_preserved]
|
|
@@ -186,11 +235,11 @@ module Fontisan
|
|
|
186
235
|
{
|
|
187
236
|
success: true,
|
|
188
237
|
input_path: font_path,
|
|
189
|
-
output_path:
|
|
238
|
+
output_path: out_path,
|
|
190
239
|
source_format: result[:details][:source_format],
|
|
191
240
|
target_format: result[:details][:target_format],
|
|
192
241
|
input_size: File.size(font_path),
|
|
193
|
-
output_size: File.size(
|
|
242
|
+
output_size: File.size(out_path),
|
|
194
243
|
variation_strategy: result[:details][:variation_strategy],
|
|
195
244
|
}
|
|
196
245
|
end
|
|
@@ -200,11 +249,11 @@ module Fontisan
|
|
|
200
249
|
# @return [Hash] Result information
|
|
201
250
|
def convert_collection
|
|
202
251
|
# Determine target collection type from target format
|
|
203
|
-
target_type = collection_type_from_format(@
|
|
252
|
+
target_type = collection_type_from_format(@target_formats.first)
|
|
204
253
|
|
|
205
254
|
unless target_type
|
|
206
255
|
raise ArgumentError,
|
|
207
|
-
"Target format #{@
|
|
256
|
+
"Target format #{@target_formats.first} is not a collection format. " \
|
|
208
257
|
"Use ttc, otc, or dfont for collection conversion."
|
|
209
258
|
end
|
|
210
259
|
|
|
@@ -302,12 +351,41 @@ module Fontisan
|
|
|
302
351
|
"Output path is required. Use --output option."
|
|
303
352
|
end
|
|
304
353
|
|
|
305
|
-
|
|
354
|
+
if @target_formats.empty?
|
|
306
355
|
raise ArgumentError,
|
|
307
356
|
"Target format is required. Use --to option."
|
|
308
357
|
end
|
|
309
358
|
end
|
|
310
359
|
|
|
360
|
+
# Normalize the +--to+ value into a deduplicated Array<Symbol>.
|
|
361
|
+
#
|
|
362
|
+
# Accepts:
|
|
363
|
+
# - "woff" → [:woff]
|
|
364
|
+
# - "woff,woff2" → [:woff, :woff2]
|
|
365
|
+
# - ["woff", "woff2"] → [:woff, :woff2]
|
|
366
|
+
# - ["woff,woff2", "ttf"] → [:woff, :woff2, :ttf]
|
|
367
|
+
# - nil / "" → []
|
|
368
|
+
#
|
|
369
|
+
# @return [Array<Symbol>]
|
|
370
|
+
def parse_target_formats(raw)
|
|
371
|
+
array = Array(raw).flat_map { |s| s.to_s.split(",") }
|
|
372
|
+
.map { |s| s.strip.downcase }
|
|
373
|
+
.reject(&:empty?)
|
|
374
|
+
.map { |s| parse_target_format(s) }
|
|
375
|
+
array.uniq
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Resolve the per-format output paths via {MultiFormatOutput}.
|
|
379
|
+
# Returns nil when no target formats have been parsed yet (the
|
|
380
|
+
# validate_options! path will surface the missing-format error).
|
|
381
|
+
#
|
|
382
|
+
# @return [Array<String>, nil]
|
|
383
|
+
def resolve_output_paths
|
|
384
|
+
return nil if @target_formats.empty? || @output_path.nil?
|
|
385
|
+
|
|
386
|
+
MultiFormatOutput.new(@output_path, @target_formats).paths
|
|
387
|
+
end
|
|
388
|
+
|
|
311
389
|
# Parse target format from string/symbol
|
|
312
390
|
#
|
|
313
391
|
# @param format [String, Symbol, nil] Target format
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Commands
|
|
5
|
+
# Resolves the on-disk output path for each target format in a
|
|
6
|
+
# multi-format conversion (TODO 72).
|
|
7
|
+
#
|
|
8
|
+
# Rules:
|
|
9
|
+
# - One format + path has extension → use the path as-is.
|
|
10
|
+
# - One format + path has no extension → append ".<format>".
|
|
11
|
+
# - Many formats + path has extension → +ArgumentError+ (ambiguous).
|
|
12
|
+
# - Many formats + path has no extension → append ".<format>" per target.
|
|
13
|
+
#
|
|
14
|
+
# Pure value object: no I/O, no mutation. Extracted from
|
|
15
|
+
# {ConvertCommand} so multi-format path resolution can be tested
|
|
16
|
+
# independently of the transformation pipeline.
|
|
17
|
+
class MultiFormatOutput
|
|
18
|
+
# @param base_path [String] user-supplied --output value
|
|
19
|
+
# @param target_formats [Array<Symbol>] non-empty, deduplicated
|
|
20
|
+
def initialize(base_path, target_formats)
|
|
21
|
+
@base_path = base_path
|
|
22
|
+
@target_formats = target_formats
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Array<String>] one resolved path per target format,
|
|
26
|
+
# in the same order as +target_formats+
|
|
27
|
+
# @raise [ArgumentError] if the base path is ambiguous
|
|
28
|
+
def paths
|
|
29
|
+
single? ? [single_format_path] : multi_format_paths
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def single?
|
|
35
|
+
@target_formats.size == 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def single_format_path
|
|
39
|
+
has_extension? ? @base_path : "#{@base_path}.#{@target_formats.first}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def multi_format_paths
|
|
43
|
+
if has_extension?
|
|
44
|
+
raise ArgumentError,
|
|
45
|
+
"Output path #{@base_path.inspect} has an extension but " \
|
|
46
|
+
"#{@target_formats.size} target formats were given " \
|
|
47
|
+
"(#{@target_formats.join(', ')}). Drop the extension or " \
|
|
48
|
+
"specify a single format."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@target_formats.map { |fmt| "#{@base_path}.#{fmt}" }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def has_extension?
|
|
55
|
+
!File.extname(@base_path).strip.downcase.empty?
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Commands
|
|
5
|
+
# Validates the structural integrity of a TTC/OTC/dfont collection
|
|
6
|
+
# (TODO 74). Complements {ValidateCommand}, which runs profile-based
|
|
7
|
+
# checks against a single face. This command runs collection-level
|
|
8
|
+
# checks: face count, per-face glyph cap, optional cmap-union size.
|
|
9
|
+
#
|
|
10
|
+
# Returns an integer exit code (0 = all checks passed, 1 = any check
|
|
11
|
+
# failed) suitable for use as the CLI's exit status.
|
|
12
|
+
#
|
|
13
|
+
# The command is intentionally narrow: it does not subclass
|
|
14
|
+
# {BaseCommand} (which eagerly loads a single font at construction
|
|
15
|
+
# time) because the input here is a collection, not a single face.
|
|
16
|
+
# It owns its own loading via {Collection::Reader}.
|
|
17
|
+
class ValidateCollectionCommand
|
|
18
|
+
# Per-check result. The +message+ is +nil+ on pass.
|
|
19
|
+
#
|
|
20
|
+
# @!attribute [r] name
|
|
21
|
+
# @return [Symbol] check identifier (:face_count, :glyph_cap, :cmap_union)
|
|
22
|
+
# @!attribute [r] passed
|
|
23
|
+
# @return [Boolean]
|
|
24
|
+
# @!attribute [r] message
|
|
25
|
+
# @return [String, nil] human-readable failure detail
|
|
26
|
+
Check = Struct.new(:name, :passed, :message, keyword_init: true) do
|
|
27
|
+
# Predicate form so callers can write +check.passed?+ instead of
|
|
28
|
+
# +check.passed+. Struct does not provide the +?+ suffix
|
|
29
|
+
# automatically.
|
|
30
|
+
def passed?
|
|
31
|
+
passed
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
DEFAULT_MAX_GLYPHS = 65_535
|
|
36
|
+
|
|
37
|
+
# @param input [String] path to a TTC/OTC/dfont
|
|
38
|
+
# @param expected_faces [Integer, nil] required face count, or nil to skip
|
|
39
|
+
# @param max_glyphs [Integer] per-face glyph cap (default 65,535)
|
|
40
|
+
# @param expected_cmap_union [Integer, nil] minimum cmap-union size, or nil to skip
|
|
41
|
+
def initialize(input:, expected_faces: nil, max_glyphs: DEFAULT_MAX_GLYPHS,
|
|
42
|
+
expected_cmap_union: nil)
|
|
43
|
+
@input = input
|
|
44
|
+
@expected_faces = expected_faces
|
|
45
|
+
@max_glyphs = max_glyphs
|
|
46
|
+
@expected_cmap_union = expected_cmap_union
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Integer] 0 if all checks passed, 1 otherwise
|
|
50
|
+
def run
|
|
51
|
+
reader = Collection::Reader.open(@input)
|
|
52
|
+
@checks = [
|
|
53
|
+
check_face_count(reader),
|
|
54
|
+
check_glyph_cap(reader),
|
|
55
|
+
check_cmap_union(reader),
|
|
56
|
+
].compact
|
|
57
|
+
|
|
58
|
+
render_report(reader)
|
|
59
|
+
@checks.all?(&:passed?) ? 0 : 1
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Array<Check>] the most recent run's checks
|
|
63
|
+
attr_reader :checks
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def check_face_count(reader)
|
|
68
|
+
return nil unless @expected_faces
|
|
69
|
+
|
|
70
|
+
actual = reader.face_count
|
|
71
|
+
passed = actual == @expected_faces
|
|
72
|
+
Check.new(
|
|
73
|
+
name: :face_count,
|
|
74
|
+
passed: passed,
|
|
75
|
+
message: passed ? nil : "expected #{@expected_faces} faces, got #{actual}",
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def check_glyph_cap(reader)
|
|
80
|
+
over = reader.stats.select { |s| s.glyph_count > @max_glyphs }
|
|
81
|
+
Check.new(
|
|
82
|
+
name: :glyph_cap,
|
|
83
|
+
passed: over.empty?,
|
|
84
|
+
message: over.empty? ? nil : "faces over cap: #{over.map { |s| "##{s.index}=#{s.glyph_count}" }.join(', ')}",
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def check_cmap_union(reader)
|
|
89
|
+
return nil unless @expected_cmap_union
|
|
90
|
+
|
|
91
|
+
actual = reader.cmap_union.size
|
|
92
|
+
passed = actual >= @expected_cmap_union
|
|
93
|
+
Check.new(
|
|
94
|
+
name: :cmap_union,
|
|
95
|
+
passed: passed,
|
|
96
|
+
message: passed ? nil : "cmap union #{actual} < expected #{@expected_cmap_union}",
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Default rendering. Callers wanting structured output can
|
|
101
|
+
# instantiate the command, call +#run+, then read +#checks+
|
|
102
|
+
# directly instead of relying on stdout.
|
|
103
|
+
def render_report(reader)
|
|
104
|
+
reader.stats.each do |s|
|
|
105
|
+
marker = s.glyph_count <= @max_glyphs ? "✓" : "✗"
|
|
106
|
+
puts format("face %<index>d: %<glyphs>7d glyphs %<marker>s",
|
|
107
|
+
index: s.index, glyphs: s.glyph_count, marker: marker)
|
|
108
|
+
end
|
|
109
|
+
puts "all #{reader.face_count} faces within #{@max_glyphs}-glyph cap ✓"
|
|
110
|
+
|
|
111
|
+
@checks.each do |check|
|
|
112
|
+
if check.passed?
|
|
113
|
+
puts "#{check.name}: ✓"
|
|
114
|
+
else
|
|
115
|
+
puts "#{check.name}: ✗ (#{check.message})"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/fontisan/commands.rb
CHANGED
|
@@ -13,6 +13,7 @@ module Fontisan
|
|
|
13
13
|
autoload :InfoCommand, "fontisan/commands/info_command"
|
|
14
14
|
autoload :InstanceCommand, "fontisan/commands/instance_command"
|
|
15
15
|
autoload :LsCommand, "fontisan/commands/ls_command"
|
|
16
|
+
autoload :MultiFormatOutput, "fontisan/commands/multi_format_output"
|
|
16
17
|
autoload :OpticalSizeCommand, "fontisan/commands/optical_size_command"
|
|
17
18
|
autoload :PackCommand, "fontisan/commands/pack_command"
|
|
18
19
|
autoload :ScriptsCommand, "fontisan/commands/scripts_command"
|
|
@@ -20,6 +21,7 @@ module Fontisan
|
|
|
20
21
|
autoload :TablesCommand, "fontisan/commands/tables_command"
|
|
21
22
|
autoload :UnicodeCommand, "fontisan/commands/unicode_command"
|
|
22
23
|
autoload :UnpackCommand, "fontisan/commands/unpack_command"
|
|
24
|
+
autoload :ValidateCollectionCommand, "fontisan/commands/validate_collection_command"
|
|
23
25
|
autoload :ValidateCommand, "fontisan/commands/validate_command"
|
|
24
26
|
autoload :VariableCommand, "fontisan/commands/variable_command"
|
|
25
27
|
end
|
data/lib/fontisan/error.rb
CHANGED
|
@@ -206,6 +206,62 @@ module Fontisan
|
|
|
206
206
|
# CBDT/CBLC across multiple sources requires a dedicated rebuild.
|
|
207
207
|
class MultipleCbdtSourcesError < Error; end
|
|
208
208
|
|
|
209
|
+
# Stitcher produced more glyphs than the output format supports.
|
|
210
|
+
#
|
|
211
|
+
# Raised by Stitcher::GlyphLimit.check! before writing, so the user
|
|
212
|
+
# gets an actionable message instead of a silently truncated font.
|
|
213
|
+
class GlyphLimitExceededError < Error
|
|
214
|
+
attr_reader :actual, :limit, :format
|
|
215
|
+
|
|
216
|
+
def initialize(actual:, limit:, format:)
|
|
217
|
+
@actual = actual
|
|
218
|
+
@limit = limit
|
|
219
|
+
@format = format
|
|
220
|
+
super(build_message)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
def build_message
|
|
226
|
+
"Stitcher produced #{actual} unique glyphs, exceeding the " \
|
|
227
|
+
"#{format.to_s.upcase} limit of #{format_limit}. Both TTF and OTF " \
|
|
228
|
+
"(CFF1) cap at 65,535 glyphs — the maxp.num_glyphs field is uint16 " \
|
|
229
|
+
"and the CFF CharStrings INDEX count is card16. Options: " \
|
|
230
|
+
"(1) split the output into a TTC (TrueType Collection) by Unicode plane, " \
|
|
231
|
+
"(2) reduce the number of donors, " \
|
|
232
|
+
"(3) wait for CFF2 support in fontisan (card24 INDEX counts, no glyph cap)."
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def format_limit
|
|
236
|
+
limit == Float::INFINITY ? "infinity" : limit.to_s
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Partition strategy could not satisfy the requested cap.
|
|
241
|
+
#
|
|
242
|
+
# Raised by {Stitcher::PartitionStrategy} when a single Unicode block
|
|
243
|
+
# contains more codepoints than the configured cap, so the partitioner
|
|
244
|
+
# cannot sub-split it further. This is a partitioning-time detection
|
|
245
|
+
# distinct from {GlyphLimitExceededError}, which fires at compile time
|
|
246
|
+
# after the Stitcher has produced more glyphs than the output format
|
|
247
|
+
# supports. Both surface the same underlying constraint (65,535-glyph
|
|
248
|
+
# cap) but at different stages.
|
|
249
|
+
class PartitionCapExceededError < Error
|
|
250
|
+
attr_reader :block_label, :actual, :cap
|
|
251
|
+
|
|
252
|
+
# @param block_label [String] e.g. "CJK_Ext_B", or "plane_N" if the
|
|
253
|
+
# overflow is not attributable to a known block
|
|
254
|
+
# @param actual [Integer] number of codepoints in the block
|
|
255
|
+
# @param cap [Integer] the cap that was exceeded
|
|
256
|
+
def initialize(block_label:, actual:, cap:)
|
|
257
|
+
@block_label = block_label
|
|
258
|
+
@actual = actual
|
|
259
|
+
@cap = cap
|
|
260
|
+
super("single Unicode block #{block_label} (#{actual} cps) exceeds " \
|
|
261
|
+
"cap #{cap}; cannot sub-split further")
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
209
265
|
# Variation data corrupted (for use in data_extractor)
|
|
210
266
|
#
|
|
211
267
|
# Raised when extracted variation data appears corrupted.
|
data/lib/fontisan/models.rb
CHANGED
|
@@ -15,7 +15,6 @@ module Fontisan
|
|
|
15
15
|
autoload :CollectionListInfo, "fontisan/models/collection_list_info"
|
|
16
16
|
autoload :CollectionValidationReport,
|
|
17
17
|
"fontisan/models/collection_validation_report"
|
|
18
|
-
autoload :Cldr, "fontisan/models/cldr"
|
|
19
18
|
autoload :ColorGlyph, "fontisan/models/color_glyph"
|
|
20
19
|
autoload :ColorLayer, "fontisan/models/color_layer"
|
|
21
20
|
autoload :ColorPalette, "fontisan/models/color_palette"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
class Stitcher
|
|
5
|
+
# Per-subfont stats computed from the loaded, on-disk subfont (not the
|
|
6
|
+
# in-memory UFO target — the compiler may add glyphs, e.g. .notdef).
|
|
7
|
+
SubfontStats = Struct.new(:name, :glyph_count, :codepoint_count,
|
|
8
|
+
keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# Return value of {Stitcher#write_collection}. Carries the output path,
|
|
11
|
+
# total bytes, and one {SubfontStats} per declared subfont.
|
|
12
|
+
CollectionResult = Struct.new(:path, :bytes, :subfonts, keyword_init: true) do
|
|
13
|
+
def face_count
|
|
14
|
+
subfonts.size
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
class Stitcher
|
|
5
|
+
# Registry mapping glyph signatures to canonical glyph names in
|
|
6
|
+
# the target font. Enables signature-based deduplication: when
|
|
7
|
+
# two bindings produce glyphs with identical outlines, they share
|
|
8
|
+
# one gid and the duplicate's codepoint is redirected to the
|
|
9
|
+
# canonical glyph.
|
|
10
|
+
#
|
|
11
|
+
# Replaces the Stitcher's previous name-based dedup with
|
|
12
|
+
# outline-based dedup. This merges visually identical glyphs
|
|
13
|
+
# from different donors even when their names differ, reducing
|
|
14
|
+
# the glyph count below the TrueType 65,535 cap.
|
|
15
|
+
class Deduplicator
|
|
16
|
+
attr_reader :signatures
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@signatures = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Record that `glyph` maps to `canonical_name` in the target.
|
|
23
|
+
# @param glyph [Fontisan::Ufo::Glyph]
|
|
24
|
+
# @param canonical_name [String] the name under which the glyph
|
|
25
|
+
# was added to the target font
|
|
26
|
+
def register(glyph, canonical_name)
|
|
27
|
+
@signatures[GlyphSignature.for(glyph)] = canonical_name
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param glyph [Fontisan::Ufo::Glyph]
|
|
31
|
+
# @return [String, nil] the canonical name if an identical
|
|
32
|
+
# glyph was already registered, nil otherwise
|
|
33
|
+
def find(glyph)
|
|
34
|
+
@signatures[GlyphSignature.for(glyph)]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Integer] number of unique signatures registered
|
|
38
|
+
def size
|
|
39
|
+
@signatures.size
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def empty?
|
|
43
|
+
@signatures.empty?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
class Stitcher
|
|
5
|
+
# Format-specific glyph-count caps.
|
|
6
|
+
#
|
|
7
|
+
# Both TTF and OTF (CFF1) cap at 65,535 because:
|
|
8
|
+
# - TTF: maxp.num_glyphs is uint16
|
|
9
|
+
# - OTF (CFF1): maxp.num_glyphs is uint16 AND the CFF CharStrings
|
|
10
|
+
# INDEX count is card16
|
|
11
|
+
#
|
|
12
|
+
# Exceeding the cap produces a silently truncated font (the BinData
|
|
13
|
+
# uint16 writer truncates without raising). The Stitcher checks
|
|
14
|
+
# the cap BEFORE writing so the user gets a clear error.
|
|
15
|
+
#
|
|
16
|
+
# To exceed 65,535 glyphs, the font must be split into a TTC
|
|
17
|
+
# (TrueType Collection) or fontisan must implement CFF2 (card24
|
|
18
|
+
# INDEX counts). Both are future work.
|
|
19
|
+
module GlyphLimit
|
|
20
|
+
TTF_GLYPH_CAP = 65_535
|
|
21
|
+
OTF_GLYPH_CAP = 65_535
|
|
22
|
+
|
|
23
|
+
# @param format [Symbol] :ttf or :otf
|
|
24
|
+
# @return [Integer, Float::INFINITY] the max glyph count
|
|
25
|
+
def self.for_format(format)
|
|
26
|
+
case format.to_sym
|
|
27
|
+
when :ttf then TTF_GLYPH_CAP
|
|
28
|
+
when :otf then OTF_GLYPH_CAP
|
|
29
|
+
when :otf2 then OTF_GLYPH_CAP # CFF2 CharStrings count must match maxp.numGlyphs
|
|
30
|
+
else
|
|
31
|
+
raise ArgumentError, "unknown format: #{format.inspect}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Raise GlyphLimitExceededError if `glyph_count` exceeds the cap
|
|
36
|
+
# for the given format.
|
|
37
|
+
#
|
|
38
|
+
# @param glyph_count [Integer]
|
|
39
|
+
# @param format [Symbol]
|
|
40
|
+
# @raise [GlyphLimitExceededError]
|
|
41
|
+
def self.check!(glyph_count, format:)
|
|
42
|
+
limit = for_format(format)
|
|
43
|
+
return if glyph_count <= limit
|
|
44
|
+
|
|
45
|
+
raise GlyphLimitExceededError.new(
|
|
46
|
+
actual: glyph_count,
|
|
47
|
+
limit: limit,
|
|
48
|
+
format: format,
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
class Stitcher
|
|
7
|
+
# Stateless signature computation for a Ufo::Glyph's visual identity.
|
|
8
|
+
#
|
|
9
|
+
# Two glyphs with the same signature are visually interchangeable
|
|
10
|
+
# and can share a gid in the output font. The signature captures
|
|
11
|
+
# advance width, every contour's points (x, y, type), and every
|
|
12
|
+
# component reference (base glyph + transform presence).
|
|
13
|
+
#
|
|
14
|
+
# Used by Deduplicator to merge identical outlines from different
|
|
15
|
+
# donors, reducing the glyph count below the TrueType 65,535 cap.
|
|
16
|
+
module GlyphSignature
|
|
17
|
+
# @param glyph [Fontisan::Ufo::Glyph]
|
|
18
|
+
# @return [String] SHA-256 hex digest of the glyph's outline identity
|
|
19
|
+
def self.for(glyph)
|
|
20
|
+
Digest::SHA256.hexdigest(canonical_representation(glyph))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Build a deterministic string capturing the glyph's visual identity.
|
|
24
|
+
# The representation is ordered and normalized so that semantically
|
|
25
|
+
# identical glyphs produce byte-identical strings.
|
|
26
|
+
#
|
|
27
|
+
# @param glyph [Fontisan::Ufo::Glyph]
|
|
28
|
+
# @return [String]
|
|
29
|
+
def self.canonical_representation(glyph)
|
|
30
|
+
io = +""
|
|
31
|
+
io << "w:#{glyph.width.to_i};"
|
|
32
|
+
|
|
33
|
+
glyph.contours.each_with_index do |contour, ci|
|
|
34
|
+
io << "c#{ci}:"
|
|
35
|
+
contour.points.each do |pt|
|
|
36
|
+
io << "#{pt.x.to_i},#{pt.y.to_i},#{pt.type};"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
glyph.components.each_with_index do |comp, i|
|
|
41
|
+
io << "comp#{i}:#{comp.base_glyph}"
|
|
42
|
+
io << ":t" if comp.transformation
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
io
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private_class_method :canonical_representation
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
class Stitcher
|
|
5
|
+
module PartitionStrategy
|
|
6
|
+
# Abstract base. Concrete partitioners (ByPlane, ByBlock, …)
|
|
7
|
+
# implement {#call} and return a {Blueprint}.
|
|
8
|
+
class Base
|
|
9
|
+
# Default cap: 65,535 − .notdef − safety margin. Matches the
|
|
10
|
+
# Stitcher's own +GlyphLimit+ cap for TTF.
|
|
11
|
+
DEFAULT_CAP = 65_484
|
|
12
|
+
|
|
13
|
+
# @param cp_map [Hash{Integer=>Object}] codepoint → donor label
|
|
14
|
+
# @param cap [Integer] max codepoints per partition
|
|
15
|
+
# @return [Blueprint]
|
|
16
|
+
def call(cp_map, cap: DEFAULT_CAP)
|
|
17
|
+
raise NotImplementedError,
|
|
18
|
+
"#{self.class} must implement #call"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|