fontisan 0.4.7 → 0.4.9
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/CHANGELOG.md +13 -0
- data/README.adoc +116 -2
- data/docs/.vitepress/config.ts +1 -7
- data/docs/COLLECTION_VALIDATION.adoc +61 -0
- data/docs/STITCHER_GUIDE.adoc +138 -1
- data/docs/cli/convert.md +44 -2
- data/docs/cli/index.md +21 -28
- data/docs/cli/validate-collection.md +95 -0
- data/docs/cli/validate.md +8 -0
- 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.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 +25 -0
- data/lib/fontisan/models.rb +0 -1
- data/lib/fontisan/stitcher/collection_result.rb +18 -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 +44 -10
- data/lib/fontisan/ufo/compile/name.rb +2 -2
- 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 +2 -2
- metadata +14 -18
- 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
data/docs/cli/validate.md
CHANGED
|
@@ -46,3 +46,11 @@ fontisan validate font.ttf --profile opentype --format json
|
|
|
46
46
|
## Detailed Documentation
|
|
47
47
|
|
|
48
48
|
For comprehensive documentation including profile details and validation helpers, see the [validate command guide](/guide/cli/validate).
|
|
49
|
+
|
|
50
|
+
## See also
|
|
51
|
+
|
|
52
|
+
For collection-level structural checks (face count, per-face glyph cap,
|
|
53
|
+
cmap-union size) on a TTC/OTC/dfont, see
|
|
54
|
+
[validate-collection](/cli/validate-collection). The two commands are
|
|
55
|
+
complementary: `validate` runs profile-based per-face checks;
|
|
56
|
+
`validate-collection` runs collection-level structural checks.
|
data/docs/index.md
CHANGED
|
@@ -18,8 +18,6 @@ hero:
|
|
|
18
18
|
link: /guide/migrations/fonttools
|
|
19
19
|
|
|
20
20
|
features:
|
|
21
|
-
- title: "🔍 Font Audit"
|
|
22
|
-
details: Comprehensive per-face audit (replaces otfinfo) — identity, style, metrics, coverage, licensing, hinting, color, variation, OpenType layout, UCD/CLDR aggregation, compare, and library-summary modes.
|
|
23
21
|
- title: "🔄 Font Conversion"
|
|
24
22
|
details: Convert between TTF, OTF, WOFF, WOFF2, Type 1 (PFB/PFA), and SVG formats with curve conversion and optimization.
|
|
25
23
|
- title: "✅ Font Validation"
|
data/lib/fontisan/cli.rb
CHANGED
|
@@ -25,9 +25,6 @@ module Fontisan
|
|
|
25
25
|
desc: "Suppress non-error output",
|
|
26
26
|
aliases: "-q"
|
|
27
27
|
|
|
28
|
-
desc "cldr", "Manage local CLDR cache (subcommands)", hide: true
|
|
29
|
-
subcommand "cldr", CldrCli
|
|
30
|
-
|
|
31
28
|
desc "ufo", "UFO source operations (build, convert, validate)"
|
|
32
29
|
subcommand "ufo", Fontisan::Ufo::Cli
|
|
33
30
|
|
|
@@ -211,10 +208,12 @@ module Fontisan
|
|
|
211
208
|
end
|
|
212
209
|
|
|
213
210
|
desc "convert FONT_FILE", "Convert font to different format"
|
|
214
|
-
option :to, type: :
|
|
215
|
-
desc: "Target format: ttf, otf, type1, t1, ttc, otc, dfont, svg, " \
|
|
211
|
+
option :to, type: :array, required: true,
|
|
212
|
+
desc: "Target format(s): ttf, otf, type1, t1, ttc, otc, dfont, svg, " \
|
|
216
213
|
"woff (zlib — works on all browsers incl. legacy), " \
|
|
217
|
-
"woff2 (Brotli — ~30% smaller, modern browsers only)"
|
|
214
|
+
"woff2 (Brotli — ~30% smaller, modern browsers only). " \
|
|
215
|
+
"Pass multiple times (--to woff --to woff2) or comma-separated " \
|
|
216
|
+
"(--to woff,woff2) for multi-format output.",
|
|
218
217
|
aliases: "-t"
|
|
219
218
|
option :output, type: :string,
|
|
220
219
|
desc: "Output file path (required unless --show-options)",
|
|
@@ -361,7 +360,7 @@ module Fontisan
|
|
|
361
360
|
|
|
362
361
|
# Handle --show-options
|
|
363
362
|
if options[:show_options]
|
|
364
|
-
show_recommended_options(source_format, options[:to])
|
|
363
|
+
show_recommended_options(source_format, options[:to].is_a?(Array) ? options[:to].first : options[:to])
|
|
365
364
|
return
|
|
366
365
|
end
|
|
367
366
|
|
|
@@ -371,7 +370,8 @@ module Fontisan
|
|
|
371
370
|
end
|
|
372
371
|
|
|
373
372
|
# Build ConversionOptions
|
|
374
|
-
conv_options = build_conversion_options(source_format,
|
|
373
|
+
conv_options = build_conversion_options(source_format,
|
|
374
|
+
Array(options[:to]).first,
|
|
375
375
|
options)
|
|
376
376
|
|
|
377
377
|
# Build instance coordinates from axis options
|
|
@@ -573,6 +573,27 @@ module Fontisan
|
|
|
573
573
|
exit command.run
|
|
574
574
|
end
|
|
575
575
|
|
|
576
|
+
desc "validate-collection PATH",
|
|
577
|
+
"Validate a TTC/OTC/dfont (face count, glyph cap, cmap union)"
|
|
578
|
+
option :expected_faces, type: :numeric,
|
|
579
|
+
desc: "Required face count"
|
|
580
|
+
option :max_glyphs, type: :numeric, default: Fontisan::Commands::ValidateCollectionCommand::DEFAULT_MAX_GLYPHS,
|
|
581
|
+
desc: "Per-face glyph cap"
|
|
582
|
+
option :expected_cmap_union, type: :numeric,
|
|
583
|
+
desc: "Minimum cmap union size across all faces"
|
|
584
|
+
def validate_collection(path)
|
|
585
|
+
cmd = Commands::ValidateCollectionCommand.new(
|
|
586
|
+
input: path,
|
|
587
|
+
expected_faces: options[:expected_faces],
|
|
588
|
+
max_glyphs: options[:max_glyphs],
|
|
589
|
+
expected_cmap_union: options[:expected_cmap_union],
|
|
590
|
+
)
|
|
591
|
+
exit cmd.run
|
|
592
|
+
rescue ArgumentError => e
|
|
593
|
+
warn "ERROR: #{e.message}"
|
|
594
|
+
exit 1
|
|
595
|
+
end
|
|
596
|
+
|
|
576
597
|
desc "version", "Display version information"
|
|
577
598
|
# Display the Fontisan version.
|
|
578
599
|
def version
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Collection
|
|
5
|
+
class Reader
|
|
6
|
+
# Snapshot of one face's headline metrics. Pure value object —
|
|
7
|
+
# no methods beyond accessors. Separate file from {Reader} so
|
|
8
|
+
# the Stats struct can be referenced without pulling in the
|
|
9
|
+
# full Reader (and its FontLoader dependency).
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] index
|
|
12
|
+
# @return [Integer] 0-based face index inside the collection
|
|
13
|
+
# @!attribute [r] glyph_count
|
|
14
|
+
# @return [Integer] face's maxp.numGlyphs
|
|
15
|
+
# @!attribute [r] codepoint_count
|
|
16
|
+
# @return [Integer] count of keys in face's cmap unicode_mappings
|
|
17
|
+
# @!attribute [r] sfnt_version
|
|
18
|
+
# @return [Integer] face sfnt version (0x00010000 for TTF, 0x4F54544F for OTF)
|
|
19
|
+
Stats = Struct.new(:index, :glyph_count, :codepoint_count, :sfnt_version,
|
|
20
|
+
keyword_init: true)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Collection
|
|
5
|
+
# Read-only counterpart to {Builder}. Opens an existing TTC/OTC/dfont
|
|
6
|
+
# and exposes per-face metadata (glyph count, codepoint count, sfnt
|
|
7
|
+
# version) and the cmap union across all faces.
|
|
8
|
+
#
|
|
9
|
+
# Delegates header parsing to {FontLoader} — never hand-rolls the
|
|
10
|
+
# TTC header bytes (that would duplicate BaseCollection.from_file
|
|
11
|
+
# and only handle TTC/OTC, not dfont).
|
|
12
|
+
#
|
|
13
|
+
# @example Per-face glyph counts
|
|
14
|
+
# reader = Collection::Reader.open("family.ttc")
|
|
15
|
+
# reader.stats.map { |s| [s.index, s.glyph_count] }
|
|
16
|
+
#
|
|
17
|
+
# @example Cmap union
|
|
18
|
+
# Collection::Reader.open("family.otc").cmap_union.size
|
|
19
|
+
class Reader
|
|
20
|
+
autoload :Stats, "fontisan/collection/reader/stats"
|
|
21
|
+
|
|
22
|
+
# @!attribute [r] path
|
|
23
|
+
# @return [String] the file path this reader was opened with
|
|
24
|
+
attr_reader :path
|
|
25
|
+
|
|
26
|
+
# Collection formats this reader accepts. Single source of truth —
|
|
27
|
+
# matches {FontLoader::COLLECTION_CLASSES}.
|
|
28
|
+
FORMATS = %i[ttc otc dfont].freeze
|
|
29
|
+
|
|
30
|
+
# @param path [String] path to a TTC/OTC/dfont file
|
|
31
|
+
# @raise [ArgumentError] if the file is not a recognized collection
|
|
32
|
+
def initialize(path)
|
|
33
|
+
@path = path
|
|
34
|
+
detected = FontLoader.detect_format(path)
|
|
35
|
+
unless FORMATS.include?(detected)
|
|
36
|
+
raise ArgumentError,
|
|
37
|
+
"#{path} is not a TTC/OTC/dfont (detected: #{detected.inspect})"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@collection = FontLoader.load_collection(path)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Convenience constructor.
|
|
44
|
+
# @param path [String]
|
|
45
|
+
# @return [Reader]
|
|
46
|
+
def self.open(path)
|
|
47
|
+
new(path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Integer] number of faces in the collection
|
|
51
|
+
def face_count
|
|
52
|
+
@collection.num_fonts
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Yields each face as a loaded TTF/OTF font. Without a block,
|
|
56
|
+
# returns an Enumerator (so callers can chain +each_with_index+).
|
|
57
|
+
# The Enumerator's size is set to +face_count+ so size-based
|
|
58
|
+
# assertions work without consuming it.
|
|
59
|
+
#
|
|
60
|
+
# @yieldparam face [TrueTypeFont, OpenTypeFont]
|
|
61
|
+
# @return [Enumerator, void]
|
|
62
|
+
def each_face
|
|
63
|
+
return enum_for(:each_face) { face_count } unless block_given?
|
|
64
|
+
|
|
65
|
+
face_count.times { |i| yield FontLoader.load(@path, font_index: i) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Array<Stats>] one Stats per face, in face-index order
|
|
69
|
+
def stats
|
|
70
|
+
each_face.map.with_index do |face, i|
|
|
71
|
+
Stats.new(
|
|
72
|
+
index: i,
|
|
73
|
+
glyph_count: face.table("maxp")&.num_glyphs || 0,
|
|
74
|
+
codepoint_count: (face.table("cmap")&.unicode_mappings || {}).size,
|
|
75
|
+
sfnt_version: face.header.sfnt_version,
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Union of every face's cmap keys.
|
|
81
|
+
#
|
|
82
|
+
# @return [Set<Integer>]
|
|
83
|
+
def cmap_union
|
|
84
|
+
each_face.with_object(Set.new) do |face, union|
|
|
85
|
+
union.merge((face.table("cmap")&.unicode_mappings || {}).keys)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/fontisan/collection.rb
CHANGED
|
@@ -7,6 +7,7 @@ module Fontisan
|
|
|
7
7
|
autoload :Builder, "fontisan/collection/builder"
|
|
8
8
|
autoload :DfontBuilder, "fontisan/collection/dfont_builder"
|
|
9
9
|
autoload :OffsetCalculator, "fontisan/collection/offset_calculator"
|
|
10
|
+
autoload :Reader, "fontisan/collection/reader"
|
|
10
11
|
autoload :SharedLogic, "fontisan/collection/shared_logic"
|
|
11
12
|
autoload :TableAnalyzer, "fontisan/collection/table_analyzer"
|
|
12
13
|
autoload :TableDeduplicator, "fontisan/collection/table_deduplicator"
|
|
@@ -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
|
@@ -237,6 +237,31 @@ module Fontisan
|
|
|
237
237
|
end
|
|
238
238
|
end
|
|
239
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
|
+
|
|
240
265
|
# Variation data corrupted (for use in data_extractor)
|
|
241
266
|
#
|
|
242
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"
|