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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/docs/.vitepress/config.ts +0 -7
  4. data/docs/cli/index.md +5 -28
  5. data/docs/index.md +0 -2
  6. data/lib/fontisan/cli.rb +29 -8
  7. data/lib/fontisan/collection/reader/stats.rb +23 -0
  8. data/lib/fontisan/collection/reader.rb +90 -0
  9. data/lib/fontisan/collection.rb +1 -0
  10. data/lib/fontisan/commands/convert_command.rb +96 -18
  11. data/lib/fontisan/commands/multi_format_output.rb +59 -0
  12. data/lib/fontisan/commands/validate_collection_command.rb +121 -0
  13. data/lib/fontisan/commands.rb +2 -0
  14. data/lib/fontisan/error.rb +25 -0
  15. data/lib/fontisan/models.rb +0 -1
  16. data/lib/fontisan/stitcher/collection_result.rb +18 -0
  17. data/lib/fontisan/stitcher/partition_strategy/base.rb +23 -0
  18. data/lib/fontisan/stitcher/partition_strategy/blueprint.rb +24 -0
  19. data/lib/fontisan/stitcher/partition_strategy/by_plane.rb +131 -0
  20. data/lib/fontisan/stitcher/partition_strategy/partition.rb +24 -0
  21. data/lib/fontisan/stitcher/partition_strategy.rb +22 -0
  22. data/lib/fontisan/stitcher.rb +44 -10
  23. data/lib/fontisan/ufo/compile/name.rb +2 -2
  24. data/lib/fontisan/ufo/info.rb +48 -0
  25. data/lib/fontisan/unicode/plane.rb +56 -0
  26. data/lib/fontisan/unicode.rb +17 -0
  27. data/lib/fontisan/version.rb +1 -1
  28. data/lib/fontisan.rb +2 -2
  29. metadata +13 -18
  30. data/docs/cli/audit.md +0 -337
  31. data/lib/fontisan/cldr/aggregator.rb +0 -33
  32. data/lib/fontisan/cldr/cache_manager.rb +0 -110
  33. data/lib/fontisan/cldr/config.rb +0 -59
  34. data/lib/fontisan/cldr/download_error.rb +0 -9
  35. data/lib/fontisan/cldr/downloader.rb +0 -79
  36. data/lib/fontisan/cldr/error.rb +0 -8
  37. data/lib/fontisan/cldr/index.rb +0 -64
  38. data/lib/fontisan/cldr/index_builder.rb +0 -72
  39. data/lib/fontisan/cldr/unicode_set_parser.rb +0 -189
  40. data/lib/fontisan/cldr/unknown_version_error.rb +0 -9
  41. data/lib/fontisan/cldr/version_resolver.rb +0 -91
  42. data/lib/fontisan/cldr.rb +0 -23
  43. data/lib/fontisan/cli/cldr_cli.rb +0 -85
  44. data/lib/fontisan/config/cldr.yml +0 -22
  45. data/lib/fontisan/models/cldr/language_coverage.rb +0 -31
  46. 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
@@ -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
@@ -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.
@@ -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
@@ -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, "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"
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
- compile_subfont_to_loaded_font(name, format: format)
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(compiled, format: collection_format,
97
- optimize: true).build_to_file(path)
98
- path
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
- return Fontisan::FontLoader.load(sub_path)
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, :default_records
75
+ private_class_method :format0_bytes
76
76
  end
77
77
  end
78
78
  end
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.7"
4
+ VERSION = "0.4.8"
5
5
  end