fontisan 0.4.7 → 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/CHANGELOG.md +13 -0
- data/docs/.vitepress/config.ts +0 -7
- 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.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 +13 -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
|
@@ -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"
|
|
@@ -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,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
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
class Stitcher
|
|
5
|
+
module PartitionStrategy
|
|
6
|
+
# The result of partitioning a codepoint set: an ordered list of
|
|
7
|
+
# {Partition}s. Applying a blueprint to a Stitcher declares one
|
|
8
|
+
# subfont per partition.
|
|
9
|
+
Blueprint = Struct.new(:partitions, keyword_init: true) do
|
|
10
|
+
# @param stitcher [Fontisan::Stitcher]
|
|
11
|
+
# @return [Array<Symbol>] names of subfonts declared
|
|
12
|
+
def apply_to(stitcher)
|
|
13
|
+
partitions.each { |p| p.apply_to(stitcher) }
|
|
14
|
+
partitions.map(&:name)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [Array<Symbol>]
|
|
18
|
+
def names
|
|
19
|
+
partitions.map(&:name)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
class Stitcher
|
|
5
|
+
module PartitionStrategy
|
|
6
|
+
# Partition codepoints by Unicode plane (BMP, SMP, SIP, …).
|
|
7
|
+
#
|
|
8
|
+
# For each plane with codepoints:
|
|
9
|
+
# - if the count fits under +cap+, emit one partition named
|
|
10
|
+
# +:plane_<n>+ (e.g. +:plane_0+, +:plane_2+);
|
|
11
|
+
# - otherwise sub-split using the large CJK extension block
|
|
12
|
+
# boundaries, naming partitions +:plane_<n>_a+, +:plane_<n>_b+,
|
|
13
|
+
# and so on.
|
|
14
|
+
#
|
|
15
|
+
# If a single CJK extension block alone exceeds +cap+, raises
|
|
16
|
+
# {Fontisan::GlyphLimitExceededError} — the partitioner cannot
|
|
17
|
+
# satisfy the cap and the caller must use a smaller cap, split
|
|
18
|
+
# manually, or switch to a format with a higher glyph limit.
|
|
19
|
+
class ByPlane < Base
|
|
20
|
+
# @param cp_map [Hash{Integer=>Object}] codepoint → donor label
|
|
21
|
+
# @param cap [Integer] max codepoints per partition
|
|
22
|
+
# @return [Blueprint]
|
|
23
|
+
def call(cp_map, cap: DEFAULT_CAP)
|
|
24
|
+
grouped = group_by_plane(cp_map)
|
|
25
|
+
partitions = grouped.flat_map do |plane_num, entries|
|
|
26
|
+
build_partitions_for_plane(plane_num, entries, cap)
|
|
27
|
+
end
|
|
28
|
+
Blueprint.new(partitions: partitions)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def group_by_plane(cp_map)
|
|
34
|
+
cp_map.group_by { |cp, _label| Fontisan::Unicode::Plane.of(cp) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_partitions_for_plane(plane_num, entries, cap)
|
|
38
|
+
if entries.size <= cap
|
|
39
|
+
return [single_partition(plane_num, entries)]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sub_split_by_block(plane_num, entries, cap)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def single_partition(plane_num, entries)
|
|
46
|
+
Partition.new(
|
|
47
|
+
name: :"plane_#{plane_num}",
|
|
48
|
+
cps: entries.map(&:first),
|
|
49
|
+
donor_map: entries.to_h,
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# When a plane overflows +cap+, carve it along the large CJK
|
|
54
|
+
# extension block boundaries. The +:other+ bucket (everything
|
|
55
|
+
# outside the known mega-blocks) is split into chunks of +cap+,
|
|
56
|
+
# while each known large block becomes one partition atomically —
|
|
57
|
+
# if a single block alone exceeds +cap+, we cannot sub-split it
|
|
58
|
+
# (its codepoints are contiguous and we don't have finer-grained
|
|
59
|
+
# boundaries to use), so raise.
|
|
60
|
+
def sub_split_by_block(plane_num, entries, cap)
|
|
61
|
+
buckets = bucket_by_large_block(entries)
|
|
62
|
+
partitions = []
|
|
63
|
+
suffix = "a"
|
|
64
|
+
|
|
65
|
+
buckets.each do |label, bucket_entries|
|
|
66
|
+
if large_block?(label) && bucket_entries.size > cap
|
|
67
|
+
raise PartitionCapExceededError.new(
|
|
68
|
+
block_label: label,
|
|
69
|
+
actual: bucket_entries.size,
|
|
70
|
+
cap: cap,
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
chunks(bucket_entries, cap).each do |chunk|
|
|
75
|
+
partitions << Partition.new(
|
|
76
|
+
name: :"plane_#{plane_num}_#{suffix}",
|
|
77
|
+
cps: chunk.map(&:first),
|
|
78
|
+
donor_map: chunk.to_h,
|
|
79
|
+
)
|
|
80
|
+
suffix = suffix.succ
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
partitions
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def large_block?(label)
|
|
87
|
+
Fontisan::Unicode::Plane::LARGE_CJK_BLOCKS.key?(label)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Split +entries+ into sub-arrays of at most +cap+ each.
|
|
91
|
+
def chunks(entries, cap)
|
|
92
|
+
entries.each_slice(cap).to_a
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Bucket entries by which large CJK block (or :other) they fall
|
|
96
|
+
# into. Entries outside any known large block go into :other,
|
|
97
|
+
# which is then packed into its own partition(s).
|
|
98
|
+
def bucket_by_large_block(entries)
|
|
99
|
+
buckets = { other: [] }
|
|
100
|
+
Fontisan::Unicode::Plane::LARGE_CJK_BLOCKS.each_key do |label|
|
|
101
|
+
buckets[label] = []
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
entries.each do |cp, label|
|
|
105
|
+
block = find_large_block(cp)
|
|
106
|
+
if block
|
|
107
|
+
buckets[block] << [cp, label]
|
|
108
|
+
else
|
|
109
|
+
buckets[:other] << [cp, label]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Drop empty buckets; preserve order (other first if non-empty,
|
|
114
|
+
# then CJK blocks in declaration order).
|
|
115
|
+
ordered = []
|
|
116
|
+
ordered << [:other, buckets[:other]] unless buckets[:other].empty?
|
|
117
|
+
Fontisan::Unicode::Plane::LARGE_CJK_BLOCKS.each_key do |label|
|
|
118
|
+
ordered << [label, buckets[label]] unless buckets[label].empty?
|
|
119
|
+
end
|
|
120
|
+
ordered
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def find_large_block(codepoint)
|
|
124
|
+
Fontisan::Unicode::Plane::LARGE_CJK_BLOCKS.find do |_label, range|
|
|
125
|
+
range.include?(codepoint)
|
|
126
|
+
end&.first
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
class Stitcher
|
|
5
|
+
module PartitionStrategy
|
|
6
|
+
# One slice of a partitioned codepoint set. Each partition names
|
|
7
|
+
# a target subfont and the codepoints + donor mapping that should
|
|
8
|
+
# land in it.
|
|
9
|
+
#
|
|
10
|
+
# {#apply_to} pushes the partition's bindings into a Stitcher via
|
|
11
|
+
# +include_codepoints_map+ (TODO 68). If that API is unavailable
|
|
12
|
+
# (older Stitcher), it falls back to one +include_codepoints+
|
|
13
|
+
# call per donor — but the preferred path is the map API.
|
|
14
|
+
Partition = Struct.new(:name, :cps, :donor_map, keyword_init: true) do
|
|
15
|
+
# @param stitcher [Fontisan::Stitcher]
|
|
16
|
+
# @return [void]
|
|
17
|
+
def apply_to(stitcher)
|
|
18
|
+
slice = cps.to_h { |cp| [cp, donor_map[cp]] }
|
|
19
|
+
stitcher.include_codepoints_map(slice, into: name)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
class Stitcher
|
|
5
|
+
# Namespace for codepoint partitioners that split a codepoint set
|
|
6
|
+
# across named subfonts while respecting the format's glyph cap.
|
|
7
|
+
#
|
|
8
|
+
# A partitioner is a strategy object with a single entry point
|
|
9
|
+
# (`.partition`) that takes a +{codepoint => donor}+ map and returns
|
|
10
|
+
# a {Blueprint}. The blueprint is then applied to a Stitcher via
|
|
11
|
+
# {Blueprint#apply_to}.
|
|
12
|
+
#
|
|
13
|
+
# Adding a new partitioner = adding a new file + a new entry here.
|
|
14
|
+
# No edits to existing partitioners required (open/closed).
|
|
15
|
+
module PartitionStrategy
|
|
16
|
+
autoload :Base, "fontisan/stitcher/partition_strategy/base"
|
|
17
|
+
autoload :Blueprint, "fontisan/stitcher/partition_strategy/blueprint"
|
|
18
|
+
autoload :Partition, "fontisan/stitcher/partition_strategy/partition"
|
|
19
|
+
autoload :ByPlane, "fontisan/stitcher/partition_strategy/by_plane"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/fontisan/stitcher.rb
CHANGED
|
@@ -25,11 +25,20 @@ module Fontisan
|
|
|
25
25
|
# stitcher.include_range(0x4E00..0x9FFF, from: :noto_cjk, into: :cjk)
|
|
26
26
|
# stitcher.write_collection("out.otc", format: :otf2)
|
|
27
27
|
class Stitcher
|
|
28
|
-
autoload :Source,
|
|
29
|
-
autoload :Selector,
|
|
30
|
-
autoload :GlyphSignature,
|
|
31
|
-
autoload :Deduplicator,
|
|
32
|
-
autoload :GlyphLimit,
|
|
28
|
+
autoload :Source, "fontisan/stitcher/source"
|
|
29
|
+
autoload :Selector, "fontisan/stitcher/selector"
|
|
30
|
+
autoload :GlyphSignature, "fontisan/stitcher/glyph_signature"
|
|
31
|
+
autoload :Deduplicator, "fontisan/stitcher/deduplicator"
|
|
32
|
+
autoload :GlyphLimit, "fontisan/stitcher/glyph_limit"
|
|
33
|
+
autoload :CollectionResult, "fontisan/stitcher/collection_result"
|
|
34
|
+
autoload :SubfontStats, "fontisan/stitcher/collection_result"
|
|
35
|
+
autoload :PartitionStrategy, "fontisan/stitcher/partition_strategy"
|
|
36
|
+
|
|
37
|
+
# Internal: pairs a compiled loaded font with its stats so
|
|
38
|
+
# +write_collection+ can build the collection and the result from a
|
|
39
|
+
# single compilation pass.
|
|
40
|
+
CompiledSubfont = Struct.new(:name, :font, :stats, keyword_init: true)
|
|
41
|
+
private_constant :CompiledSubfont
|
|
33
42
|
|
|
34
43
|
DEFAULT_DEDUPLICATE = true
|
|
35
44
|
|
|
@@ -54,6 +63,13 @@ module Fontisan
|
|
|
54
63
|
Selector::Codepoints.new(codepoints).apply(source(from), @subfonts[into])
|
|
55
64
|
end
|
|
56
65
|
|
|
66
|
+
def include_codepoints_map(cp_map, into:)
|
|
67
|
+
cp_map.to_h
|
|
68
|
+
.group_by { |_cp, label| label }
|
|
69
|
+
.transform_values { |pairs| pairs.map(&:first).sort }
|
|
70
|
+
.each { |label, cps| include_codepoints(cps, from: label, into: into) }
|
|
71
|
+
end
|
|
72
|
+
|
|
57
73
|
def include_gid(donor_gid, from:, into:)
|
|
58
74
|
Selector::Gid.new(donor_gid).apply(source(from), @subfonts[into])
|
|
59
75
|
end
|
|
@@ -89,13 +105,19 @@ module Fontisan
|
|
|
89
105
|
raise ArgumentError, "no subfonts declared" if @subfonts.empty?
|
|
90
106
|
|
|
91
107
|
compiled = @subfonts.keys.map do |name|
|
|
92
|
-
|
|
108
|
+
compile_subfont_with_stats(name, format: format)
|
|
93
109
|
end
|
|
110
|
+
fonts = compiled.map(&:font)
|
|
94
111
|
|
|
95
112
|
collection_format = collection_format_for(format)
|
|
96
|
-
Collection::Builder.new(
|
|
97
|
-
|
|
98
|
-
|
|
113
|
+
Collection::Builder.new(fonts, format: collection_format,
|
|
114
|
+
optimize: true).build_to_file(path)
|
|
115
|
+
|
|
116
|
+
CollectionResult.new(
|
|
117
|
+
path: path,
|
|
118
|
+
bytes: File.size(path),
|
|
119
|
+
subfonts: compiled.map(&:stats),
|
|
120
|
+
)
|
|
99
121
|
end
|
|
100
122
|
|
|
101
123
|
private
|
|
@@ -140,6 +162,10 @@ module Fontisan
|
|
|
140
162
|
end
|
|
141
163
|
|
|
142
164
|
def compile_subfont_to_loaded_font(subfont_name, format:)
|
|
165
|
+
compile_subfont_with_stats(subfont_name, format: format).font
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def compile_subfont_with_stats(subfont_name, format:)
|
|
143
169
|
target = build_target_for(subfont_name)
|
|
144
170
|
GlyphLimit.check!(target.glyphs.size, format: format)
|
|
145
171
|
|
|
@@ -148,7 +174,15 @@ module Fontisan
|
|
|
148
174
|
sub_path = File.join(dir, "sub#{subfont_name}#{ext}")
|
|
149
175
|
compiler = compiler_for(format)
|
|
150
176
|
compiler.new(target).compile(output_path: sub_path)
|
|
151
|
-
|
|
177
|
+
propagate_cbdt_tables(sub_path) if cbdt_source
|
|
178
|
+
|
|
179
|
+
loaded = Fontisan::FontLoader.load(sub_path)
|
|
180
|
+
stats = SubfontStats.new(
|
|
181
|
+
name: subfont_name,
|
|
182
|
+
glyph_count: loaded.table("maxp")&.num_glyphs || 0,
|
|
183
|
+
codepoint_count: (loaded.table("cmap")&.unicode_mappings || {}).size,
|
|
184
|
+
)
|
|
185
|
+
CompiledSubfont.new(name: subfont_name, font: loaded, stats: stats)
|
|
152
186
|
end
|
|
153
187
|
end
|
|
154
188
|
|
|
@@ -31,7 +31,7 @@ module Fontisan
|
|
|
31
31
|
family = font.info.family_name || "Untitled"
|
|
32
32
|
subfamily = font.info.style_name || "Regular"
|
|
33
33
|
ps_name = font.info.postscript_font_name || "#{family}-#{subfamily}"
|
|
34
|
-
full_name = "#{family} #{subfamily}".strip
|
|
34
|
+
full_name = font.info.postscript_full_name || "#{family} #{subfamily}".strip
|
|
35
35
|
major = font.info.version_major || 0
|
|
36
36
|
minor = font.info.version_minor || 0
|
|
37
37
|
version_str = "Version #{major}.#{minor}"
|
|
@@ -72,7 +72,7 @@ module Fontisan
|
|
|
72
72
|
|
|
73
73
|
header + body + storage
|
|
74
74
|
end
|
|
75
|
-
private_class_method :format0_bytes
|
|
75
|
+
private_class_method :format0_bytes
|
|
76
76
|
end
|
|
77
77
|
end
|
|
78
78
|
end
|
data/lib/fontisan/ufo/info.rb
CHANGED
|
@@ -41,6 +41,54 @@ module Fontisan
|
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# Build a +Ufo::Info+ for one subfont of a collection. The family
|
|
45
|
+
# name embeds the subfont name (e.g. +"MyFont CJK"+), and the
|
|
46
|
+
# PostScript name uses the hyphenated form (e.g. +"MyFont-CJK"+).
|
|
47
|
+
#
|
|
48
|
+
# +version+ is parsed into +version_major+ / +version_minor+ per
|
|
49
|
+
# the UFO major.minor shape (patch is dropped).
|
|
50
|
+
#
|
|
51
|
+
# +trademark+ is stored under +extras["openTypeNameTrademark"]+
|
|
52
|
+
# because it is not in +STANDARD_FIELDS+ yet — see TODO 71
|
|
53
|
+
# out-of-scope follow-up.
|
|
54
|
+
#
|
|
55
|
+
# @param family [String] collection-wide family name
|
|
56
|
+
# @param subfont [String, Symbol] subfont identifier (appended to family)
|
|
57
|
+
# @param version [String, Integer] e.g. "0.1", "1", "1.2.3"
|
|
58
|
+
# @param subfamily [String] OpenType subfamily (default "Regular")
|
|
59
|
+
# @param copyright [String, nil]
|
|
60
|
+
# @param trademark [String, nil]
|
|
61
|
+
# @return [Info]
|
|
62
|
+
def self.for_subfont(family:, subfont:, version:,
|
|
63
|
+
subfamily: "Regular",
|
|
64
|
+
copyright: nil, trademark: nil)
|
|
65
|
+
major, minor = parse_version(version)
|
|
66
|
+
subfont_str = subfont.to_s
|
|
67
|
+
values = {
|
|
68
|
+
family_name: "#{family} #{subfont_str}",
|
|
69
|
+
style_name: subfamily,
|
|
70
|
+
version_major: major,
|
|
71
|
+
version_minor: minor,
|
|
72
|
+
postscript_font_name: "#{family}-#{subfont_str}",
|
|
73
|
+
postscript_full_name: "#{family} #{subfont_str}",
|
|
74
|
+
}
|
|
75
|
+
values[:copyright] = copyright if copyright
|
|
76
|
+
values["openTypeNameTrademark"] = trademark if trademark
|
|
77
|
+
new(values)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @param version [String, Integer]
|
|
81
|
+
# @return [Array(Integer, Integer)] [major, minor]
|
|
82
|
+
def self.parse_version(version)
|
|
83
|
+
case version.to_s
|
|
84
|
+
when /\A(\d+)\.(\d+)\.\d+\z/ then [Regexp.last_match(1).to_i, Regexp.last_match(2).to_i]
|
|
85
|
+
when /\A(\d+)\.(\d+)\z/ then [Regexp.last_match(1).to_i, Regexp.last_match(2).to_i]
|
|
86
|
+
when /\A(\d+)\z/ then [Regexp.last_match(1).to_i, 0]
|
|
87
|
+
else [0, 0]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
private_class_method :parse_version
|
|
91
|
+
|
|
44
92
|
# @return [Hash] a Hash<String, Object> suitable for emit() to
|
|
45
93
|
# serialize back to plist. Keys are in camelCase per UFO 3.
|
|
46
94
|
def to_plist
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Unicode
|
|
5
|
+
# Unicode plane metadata. A plane is a contiguous range of 65,536
|
|
6
|
+
# codepoints; +cp >> 16+ is the plane number.
|
|
7
|
+
#
|
|
8
|
+
# Plane labels follow the Unicode standard names. Unassigned planes
|
|
9
|
+
# fall back to +"Plane_N"+. This is a pure-data module — no I/O, no
|
|
10
|
+
# state, no dependency on tables or stitcher.
|
|
11
|
+
module Plane
|
|
12
|
+
BMP = 0
|
|
13
|
+
SMP = 1
|
|
14
|
+
SIP = 2
|
|
15
|
+
TIP = 3
|
|
16
|
+
SSP = 14
|
|
17
|
+
|
|
18
|
+
# The handful of mega-blocks large enough to overflow a single
|
|
19
|
+
# plane's 65,535-glyph cap when partitioning by plane. Range +
|
|
20
|
+
# label only — full Unicode Blocks.txt data lives in +Block+
|
|
21
|
+
# (follow-up).
|
|
22
|
+
LARGE_CJK_BLOCKS = {
|
|
23
|
+
"CJK_Ext_B" => 0x2A700..0x2B73F,
|
|
24
|
+
"CJK_Ext_C" => 0x2B740..0x2B81F,
|
|
25
|
+
"CJK_Ext_D" => 0x2B820..0x2CEAF,
|
|
26
|
+
"CJK_Ext_E" => 0x2CEB0..0x2EBEF,
|
|
27
|
+
"CJK_Ext_F" => 0x2EBF0..0x2EE5F,
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# @param codepoint [Integer]
|
|
31
|
+
# @return [Integer] plane number (0..16)
|
|
32
|
+
def self.of(codepoint)
|
|
33
|
+
codepoint >> 16
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param plane [Integer]
|
|
37
|
+
# @return [String] human-readable plane label
|
|
38
|
+
def self.label(plane)
|
|
39
|
+
case plane
|
|
40
|
+
when BMP then "BMP"
|
|
41
|
+
when SMP then "SMP"
|
|
42
|
+
when SIP then "SIP"
|
|
43
|
+
when TIP then "TIP"
|
|
44
|
+
when SSP then "SSP"
|
|
45
|
+
else "Plane_#{plane}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param codepoint [Integer]
|
|
50
|
+
# @return [String] label of the plane containing +codepoint+
|
|
51
|
+
def self.label_of(codepoint)
|
|
52
|
+
label(of(codepoint))
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Namespace hub for Unicode metadata used by font tooling.
|
|
4
|
+
#
|
|
5
|
+
# Plane/Block/Script metadata is a separate concern from font format
|
|
6
|
+
# parsing — it is pure Unicode knowledge. Keeping it under its own
|
|
7
|
+
# namespace (rather than scattering it across +Tables+ or +Stitcher+)
|
|
8
|
+
# keeps the data MECE: one concept, one home.
|
|
9
|
+
#
|
|
10
|
+
# Each metadata concept (Plane, Block, Script) lives in its own file so
|
|
11
|
+
# that adding a new concept is additive (open/closed).
|
|
12
|
+
|
|
13
|
+
module Fontisan
|
|
14
|
+
module Unicode
|
|
15
|
+
autoload :Plane, "fontisan/unicode/plane"
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/fontisan/version.rb
CHANGED