fontisan 0.1.0 → 0.2.0

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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +529 -65
  3. data/Gemfile +1 -0
  4. data/LICENSE +5 -1
  5. data/README.adoc +1301 -275
  6. data/Rakefile +27 -2
  7. data/benchmark/variation_quick_bench.rb +47 -0
  8. data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
  9. data/fontisan.gemspec +4 -1
  10. data/lib/fontisan/binary/base_record.rb +22 -1
  11. data/lib/fontisan/cli.rb +309 -0
  12. data/lib/fontisan/collection/builder.rb +260 -0
  13. data/lib/fontisan/collection/offset_calculator.rb +227 -0
  14. data/lib/fontisan/collection/table_analyzer.rb +204 -0
  15. data/lib/fontisan/collection/table_deduplicator.rb +241 -0
  16. data/lib/fontisan/collection/writer.rb +306 -0
  17. data/lib/fontisan/commands/base_command.rb +8 -1
  18. data/lib/fontisan/commands/convert_command.rb +291 -0
  19. data/lib/fontisan/commands/export_command.rb +161 -0
  20. data/lib/fontisan/commands/info_command.rb +40 -6
  21. data/lib/fontisan/commands/instance_command.rb +295 -0
  22. data/lib/fontisan/commands/ls_command.rb +113 -0
  23. data/lib/fontisan/commands/pack_command.rb +241 -0
  24. data/lib/fontisan/commands/subset_command.rb +245 -0
  25. data/lib/fontisan/commands/unpack_command.rb +338 -0
  26. data/lib/fontisan/commands/validate_command.rb +178 -0
  27. data/lib/fontisan/commands/variable_command.rb +30 -1
  28. data/lib/fontisan/config/collection_settings.yml +56 -0
  29. data/lib/fontisan/config/conversion_matrix.yml +212 -0
  30. data/lib/fontisan/config/export_settings.yml +66 -0
  31. data/lib/fontisan/config/subset_profiles.yml +100 -0
  32. data/lib/fontisan/config/svg_settings.yml +60 -0
  33. data/lib/fontisan/config/validation_rules.yml +149 -0
  34. data/lib/fontisan/config/variable_settings.yml +99 -0
  35. data/lib/fontisan/config/woff2_settings.yml +77 -0
  36. data/lib/fontisan/constants.rb +69 -0
  37. data/lib/fontisan/converters/conversion_strategy.rb +96 -0
  38. data/lib/fontisan/converters/format_converter.rb +259 -0
  39. data/lib/fontisan/converters/outline_converter.rb +936 -0
  40. data/lib/fontisan/converters/svg_generator.rb +244 -0
  41. data/lib/fontisan/converters/table_copier.rb +117 -0
  42. data/lib/fontisan/converters/woff2_encoder.rb +416 -0
  43. data/lib/fontisan/converters/woff_writer.rb +391 -0
  44. data/lib/fontisan/error.rb +203 -0
  45. data/lib/fontisan/export/exporter.rb +262 -0
  46. data/lib/fontisan/export/table_serializer.rb +255 -0
  47. data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
  48. data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
  49. data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
  50. data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
  51. data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
  52. data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
  53. data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
  54. data/lib/fontisan/export/ttx_generator.rb +527 -0
  55. data/lib/fontisan/export/ttx_parser.rb +300 -0
  56. data/lib/fontisan/font_loader.rb +121 -12
  57. data/lib/fontisan/font_writer.rb +301 -0
  58. data/lib/fontisan/formatters/text_formatter.rb +102 -0
  59. data/lib/fontisan/glyph_accessor.rb +503 -0
  60. data/lib/fontisan/hints/hint_converter.rb +177 -0
  61. data/lib/fontisan/hints/postscript_hint_applier.rb +185 -0
  62. data/lib/fontisan/hints/postscript_hint_extractor.rb +254 -0
  63. data/lib/fontisan/hints/truetype_hint_applier.rb +71 -0
  64. data/lib/fontisan/hints/truetype_hint_extractor.rb +162 -0
  65. data/lib/fontisan/loading_modes.rb +113 -0
  66. data/lib/fontisan/metrics_calculator.rb +277 -0
  67. data/lib/fontisan/models/collection_font_summary.rb +52 -0
  68. data/lib/fontisan/models/collection_info.rb +76 -0
  69. data/lib/fontisan/models/collection_list_info.rb +37 -0
  70. data/lib/fontisan/models/font_export.rb +158 -0
  71. data/lib/fontisan/models/font_summary.rb +48 -0
  72. data/lib/fontisan/models/glyph_outline.rb +343 -0
  73. data/lib/fontisan/models/hint.rb +233 -0
  74. data/lib/fontisan/models/outline.rb +664 -0
  75. data/lib/fontisan/models/table_sharing_info.rb +40 -0
  76. data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
  77. data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
  78. data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
  79. data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
  80. data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
  81. data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
  82. data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
  83. data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
  84. data/lib/fontisan/models/ttx/ttfont.rb +49 -0
  85. data/lib/fontisan/models/validation_report.rb +203 -0
  86. data/lib/fontisan/open_type_collection.rb +156 -2
  87. data/lib/fontisan/open_type_font.rb +296 -10
  88. data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
  89. data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
  90. data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
  91. data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
  92. data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
  93. data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
  94. data/lib/fontisan/outline_extractor.rb +423 -0
  95. data/lib/fontisan/subset/builder.rb +268 -0
  96. data/lib/fontisan/subset/glyph_mapping.rb +215 -0
  97. data/lib/fontisan/subset/options.rb +142 -0
  98. data/lib/fontisan/subset/profile.rb +152 -0
  99. data/lib/fontisan/subset/table_subsetter.rb +461 -0
  100. data/lib/fontisan/svg/font_face_generator.rb +278 -0
  101. data/lib/fontisan/svg/font_generator.rb +264 -0
  102. data/lib/fontisan/svg/glyph_generator.rb +168 -0
  103. data/lib/fontisan/svg/view_box_calculator.rb +137 -0
  104. data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
  105. data/lib/fontisan/tables/cff/charset.rb +282 -0
  106. data/lib/fontisan/tables/cff/charstring.rb +905 -0
  107. data/lib/fontisan/tables/cff/charstring_builder.rb +322 -0
  108. data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
  109. data/lib/fontisan/tables/cff/dict.rb +351 -0
  110. data/lib/fontisan/tables/cff/dict_builder.rb +242 -0
  111. data/lib/fontisan/tables/cff/encoding.rb +274 -0
  112. data/lib/fontisan/tables/cff/header.rb +102 -0
  113. data/lib/fontisan/tables/cff/index.rb +237 -0
  114. data/lib/fontisan/tables/cff/index_builder.rb +170 -0
  115. data/lib/fontisan/tables/cff/private_dict.rb +284 -0
  116. data/lib/fontisan/tables/cff/top_dict.rb +236 -0
  117. data/lib/fontisan/tables/cff.rb +487 -0
  118. data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
  119. data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
  120. data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
  121. data/lib/fontisan/tables/cff2.rb +341 -0
  122. data/lib/fontisan/tables/cvar.rb +242 -0
  123. data/lib/fontisan/tables/fvar.rb +2 -2
  124. data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
  125. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
  126. data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
  127. data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
  128. data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
  129. data/lib/fontisan/tables/glyf.rb +235 -0
  130. data/lib/fontisan/tables/gvar.rb +270 -0
  131. data/lib/fontisan/tables/hhea.rb +124 -0
  132. data/lib/fontisan/tables/hmtx.rb +287 -0
  133. data/lib/fontisan/tables/hvar.rb +191 -0
  134. data/lib/fontisan/tables/loca.rb +322 -0
  135. data/lib/fontisan/tables/maxp.rb +192 -0
  136. data/lib/fontisan/tables/mvar.rb +185 -0
  137. data/lib/fontisan/tables/name.rb +99 -30
  138. data/lib/fontisan/tables/variation_common.rb +346 -0
  139. data/lib/fontisan/tables/vvar.rb +234 -0
  140. data/lib/fontisan/true_type_collection.rb +156 -2
  141. data/lib/fontisan/true_type_font.rb +297 -11
  142. data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
  143. data/lib/fontisan/utilities/checksum_calculator.rb +18 -0
  144. data/lib/fontisan/utils/thread_pool.rb +134 -0
  145. data/lib/fontisan/validation/checksum_validator.rb +170 -0
  146. data/lib/fontisan/validation/consistency_validator.rb +197 -0
  147. data/lib/fontisan/validation/structure_validator.rb +198 -0
  148. data/lib/fontisan/validation/table_validator.rb +158 -0
  149. data/lib/fontisan/validation/validator.rb +152 -0
  150. data/lib/fontisan/variable/axis_normalizer.rb +215 -0
  151. data/lib/fontisan/variable/delta_applicator.rb +313 -0
  152. data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
  153. data/lib/fontisan/variable/instancer.rb +344 -0
  154. data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
  155. data/lib/fontisan/variable/region_matcher.rb +208 -0
  156. data/lib/fontisan/variable/static_font_builder.rb +213 -0
  157. data/lib/fontisan/variable/table_updater.rb +219 -0
  158. data/lib/fontisan/variation/blend_applier.rb +199 -0
  159. data/lib/fontisan/variation/cache.rb +298 -0
  160. data/lib/fontisan/variation/cache_key_builder.rb +162 -0
  161. data/lib/fontisan/variation/converter.rb +268 -0
  162. data/lib/fontisan/variation/data_extractor.rb +86 -0
  163. data/lib/fontisan/variation/delta_applier.rb +266 -0
  164. data/lib/fontisan/variation/delta_parser.rb +228 -0
  165. data/lib/fontisan/variation/inspector.rb +275 -0
  166. data/lib/fontisan/variation/instance_generator.rb +273 -0
  167. data/lib/fontisan/variation/interpolator.rb +231 -0
  168. data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
  169. data/lib/fontisan/variation/optimizer.rb +418 -0
  170. data/lib/fontisan/variation/parallel_generator.rb +150 -0
  171. data/lib/fontisan/variation/region_matcher.rb +221 -0
  172. data/lib/fontisan/variation/subsetter.rb +463 -0
  173. data/lib/fontisan/variation/table_accessor.rb +105 -0
  174. data/lib/fontisan/variation/validator.rb +345 -0
  175. data/lib/fontisan/variation/variation_context.rb +211 -0
  176. data/lib/fontisan/version.rb +1 -1
  177. data/lib/fontisan/woff2/directory.rb +257 -0
  178. data/lib/fontisan/woff2/header.rb +101 -0
  179. data/lib/fontisan/woff2/table_transformer.rb +163 -0
  180. data/lib/fontisan/woff2_font.rb +712 -0
  181. data/lib/fontisan/woff_font.rb +483 -0
  182. data/lib/fontisan.rb +120 -0
  183. data/scripts/compare_stack_aware.rb +187 -0
  184. data/scripts/measure_optimization.rb +141 -0
  185. metadata +205 -4
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_command"
4
+ require_relative "../collection/builder"
5
+ require_relative "../font_loader"
6
+
7
+ module Fontisan
8
+ module Commands
9
+ # Command for packing multiple fonts into a TTC/OTC collection
10
+ #
11
+ # This command provides CLI access to font collection creation functionality.
12
+ # It loads multiple font files and combines them into a single TTC (TrueType Collection)
13
+ # or OTC (OpenType Collection) file with shared table deduplication to save space.
14
+ #
15
+ # @example Pack fonts into TTC
16
+ # command = PackCommand.new(
17
+ # ['font1.ttf', 'font2.ttf', 'font3.ttf'],
18
+ # output: 'family.ttc',
19
+ # format: :ttc,
20
+ # optimize: true
21
+ # )
22
+ # result = command.run
23
+ # puts "Saved #{result[:space_savings]} bytes through table sharing"
24
+ #
25
+ # @example Pack with analysis
26
+ # command = PackCommand.new(
27
+ # ['Regular.otf', 'Bold.otf', 'Italic.otf'],
28
+ # output: 'family.otc',
29
+ # format: :otc,
30
+ # analyze: true
31
+ # )
32
+ # result = command.run
33
+ class PackCommand
34
+ # Initialize pack command
35
+ #
36
+ # @param font_paths [Array<String>] Paths to input font files
37
+ # @param options [Hash] Command options
38
+ # @option options [String] :output Output file path (required)
39
+ # @option options [Symbol, String] :format Format type (:ttc or :otc, default: :ttc)
40
+ # @option options [Boolean] :optimize Enable table sharing optimization (default: true)
41
+ # @option options [Boolean] :analyze Show analysis report before building (default: false)
42
+ # @option options [Boolean] :verbose Enable verbose output (default: false)
43
+ # @raise [ArgumentError] if font_paths or output is invalid
44
+ def initialize(font_paths, options = {})
45
+ @font_paths = font_paths
46
+ @options = options
47
+ @output_path = options[:output]
48
+ @format = parse_format(options[:format] || :ttc)
49
+ @optimize = options.fetch(:optimize, true)
50
+ @analyze = options.fetch(:analyze, false)
51
+ @verbose = options.fetch(:verbose, false)
52
+
53
+ validate_options!
54
+ end
55
+
56
+ # Execute the pack command
57
+ #
58
+ # Loads all fonts, analyzes tables, and creates a TTC/OTC collection.
59
+ # Optionally displays analysis before building.
60
+ #
61
+ # @return [Hash] Result information with:
62
+ # - :output [String] - Output file path
63
+ # - :output_size [Integer] - Output file size in bytes
64
+ # - :num_fonts [Integer] - Number of fonts packed
65
+ # - :format [Symbol] - Collection format (:ttc or :otc)
66
+ # - :space_savings [Integer] - Bytes saved through sharing
67
+ # - :sharing_percentage [Float] - Percentage of tables shared
68
+ # - :analysis [Hash] - Analysis report (if analyze option enabled)
69
+ # @raise [ArgumentError] if options are invalid
70
+ # @raise [Fontisan::Error] if packing fails
71
+ def run
72
+ puts "Loading #{@font_paths.size} fonts..." if @verbose
73
+
74
+ # Load all fonts
75
+ fonts = load_fonts
76
+
77
+ # Create builder
78
+ builder = Collection::Builder.new(fonts, {
79
+ format: @format,
80
+ optimize: @optimize,
81
+ })
82
+
83
+ # Validate before building
84
+ builder.validate!
85
+
86
+ # Show analysis if requested
87
+ if @analyze || @verbose
88
+ show_analysis(builder)
89
+ end
90
+
91
+ # Build collection
92
+ puts "Building #{@format.upcase} collection..." if @verbose
93
+ result = builder.build_to_file(@output_path)
94
+
95
+ # Display results
96
+ if @verbose
97
+ display_results(result)
98
+ end
99
+
100
+ result
101
+ rescue Fontisan::Error => e
102
+ raise Fontisan::Error, "Collection packing failed: #{e.message}"
103
+ rescue StandardError => e
104
+ raise Fontisan::Error, "Unexpected error during packing: #{e.message}"
105
+ end
106
+
107
+ private
108
+
109
+ # Validate command options
110
+ #
111
+ # @raise [ArgumentError] if options are invalid
112
+ def validate_options!
113
+ # Must have output path
114
+ unless @output_path
115
+ raise ArgumentError, "Output path is required (--output)"
116
+ end
117
+
118
+ # Must have at least 2 fonts for collection
119
+ if @font_paths.nil? || @font_paths.empty?
120
+ raise ArgumentError, "Must specify at least 2 font files"
121
+ end
122
+
123
+ if @font_paths.size < 2
124
+ raise ArgumentError,
125
+ "Collection requires at least 2 fonts, got #{@font_paths.size}"
126
+ end
127
+
128
+ # Validate format
129
+ unless %i[ttc otc].include?(@format)
130
+ raise ArgumentError,
131
+ "Invalid format: #{@format}. Must be :ttc or :otc"
132
+ end
133
+
134
+ # Check output extension matches format
135
+ ext = File.extname(@output_path).downcase
136
+ expected_ext = @format == :ttc ? ".ttc" : ".otc"
137
+ if ext != expected_ext
138
+ warn "Warning: Output extension '#{ext}' doesn't match format '#{@format}' (expected '#{expected_ext}')"
139
+ end
140
+ end
141
+
142
+ # Load all fonts
143
+ #
144
+ # @return [Array<TrueTypeFont, OpenTypeFont>] Loaded fonts
145
+ # @raise [Fontisan::Error] if any font fails to load
146
+ def load_fonts
147
+ fonts = []
148
+
149
+ @font_paths.each_with_index do |path, index|
150
+ puts " [#{index + 1}/#{@font_paths.size}] Loading #{File.basename(path)}..." if @verbose
151
+
152
+ begin
153
+ font = FontLoader.load(path)
154
+ fonts << font
155
+ rescue Errno::ENOENT
156
+ raise Fontisan::Error, "Font file not found: #{path}"
157
+ rescue StandardError => e
158
+ raise Fontisan::Error, "Failed to load font '#{path}': #{e.message}"
159
+ end
160
+ end
161
+
162
+ fonts
163
+ end
164
+
165
+ # Parse format option
166
+ #
167
+ # @param format [Symbol, String] Format option
168
+ # @return [Symbol] Parsed format (:ttc or :otc)
169
+ # @raise [ArgumentError] if format is invalid
170
+ def parse_format(format)
171
+ return format if format.is_a?(Symbol) && %i[ttc otc].include?(format)
172
+
173
+ case format.to_s.downcase
174
+ when "ttc"
175
+ :ttc
176
+ when "otc"
177
+ :otc
178
+ else
179
+ raise ArgumentError,
180
+ "Invalid format: #{format}. Must be 'ttc' or 'otc'"
181
+ end
182
+ end
183
+
184
+ # Show analysis report
185
+ #
186
+ # @param builder [Collection::Builder] Collection builder
187
+ # @return [void]
188
+ def show_analysis(builder)
189
+ puts "\n=== Collection Analysis ==="
190
+
191
+ analysis = builder.analyze
192
+
193
+ puts "Total fonts: #{analysis[:total_fonts]}"
194
+ puts "Shared tables: #{analysis[:shared_tables].size}"
195
+ puts "Potential space savings: #{format_bytes(analysis[:space_savings])}"
196
+ puts "Table sharing: #{analysis[:sharing_percentage]}%"
197
+
198
+ if @verbose && analysis[:shared_tables].any?
199
+ puts "\nShared table details:"
200
+ analysis[:shared_tables].each do |tag, groups|
201
+ groups.each do |group|
202
+ font_indices = group[:font_indices]
203
+ puts " #{tag}: shared by fonts #{font_indices.join(', ')}"
204
+ end
205
+ end
206
+ end
207
+
208
+ puts ""
209
+ end
210
+
211
+ # Display build results
212
+ #
213
+ # @param result [Hash] Build result
214
+ # @return [void]
215
+ def display_results(result)
216
+ puts "\n=== Collection Created ==="
217
+ puts "Output: #{result[:output_path]}"
218
+ puts "Format: #{result[:format].upcase}"
219
+ puts "Fonts: #{result[:num_fonts]}"
220
+ puts "Size: #{format_bytes(result[:output_size])}"
221
+ puts "Space saved: #{format_bytes(result[:space_savings])}"
222
+ puts "Sharing: #{result[:statistics][:sharing_percentage]}%"
223
+ puts ""
224
+ end
225
+
226
+ # Format bytes for display
227
+ #
228
+ # @param bytes [Integer] Byte count
229
+ # @return [String] Formatted string
230
+ def format_bytes(bytes)
231
+ if bytes < 1024
232
+ "#{bytes} B"
233
+ elsif bytes < 1024 * 1024
234
+ "#{(bytes / 1024.0).round(2)} KB"
235
+ else
236
+ "#{(bytes / (1024.0 * 1024)).round(2)} MB"
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_command"
4
+ require_relative "../subset/builder"
5
+ require_relative "../subset/options"
6
+ require "fileutils"
7
+
8
+ module Fontisan
9
+ module Commands
10
+ # Command for subsetting fonts
11
+ #
12
+ # This command provides CLI access to font subsetting functionality.
13
+ # It supports multiple input methods for specifying glyphs:
14
+ # - Text input: Subset to characters in a text string
15
+ # - Glyph IDs: Subset to specific glyph IDs
16
+ # - Unicode codepoints: Subset to specific Unicode values
17
+ #
18
+ # The command also supports various subsetting options:
19
+ # - Profile selection (pdf, web, minimal)
20
+ # - Glyph ID retention
21
+ # - Hint dropping
22
+ # - Name dropping
23
+ #
24
+ # @example Subset to text characters
25
+ # command = SubsetCommand.new('font.ttf',
26
+ # text: 'Hello World',
27
+ # output: 'subset.ttf',
28
+ # profile: 'pdf'
29
+ # )
30
+ # command.run
31
+ #
32
+ # @example Subset to specific glyphs
33
+ # command = SubsetCommand.new('font.ttf',
34
+ # glyphs: [0, 1, 65, 66, 67],
35
+ # output: 'subset.ttf'
36
+ # )
37
+ # command.run
38
+ class SubsetCommand < BaseCommand
39
+ # Initialize subset command
40
+ #
41
+ # @param font_path [String] Path to input font file
42
+ # @param options [Hash] Command options
43
+ # @option options [String] :text Text to subset
44
+ # @option options [Array<Integer>] :glyphs Glyph IDs to subset
45
+ # @option options [Array<Integer>] :unicode Unicode codepoints to subset
46
+ # @option options [String] :output Output file path (required)
47
+ # @option options [String] :profile Subsetting profile (pdf, web, minimal)
48
+ # @option options [Boolean] :retain_gids Retain original glyph IDs
49
+ # @option options [Boolean] :drop_hints Drop hinting instructions
50
+ # @option options [Boolean] :drop_names Drop glyph names
51
+ # @option options [Boolean] :unicode_ranges Prune OS/2 Unicode ranges
52
+ def initialize(font_path, options = {})
53
+ super(font_path, options)
54
+ @output_path = options[:output]
55
+ validate_options!
56
+ end
57
+
58
+ # Execute the subset command
59
+ #
60
+ # @return [Hash] Result information with output path and glyph count
61
+ # @raise [ArgumentError] If options are invalid
62
+ # @raise [Fontisan::SubsettingError] If subsetting fails
63
+ def run
64
+ # Determine glyph IDs to subset
65
+ glyph_ids = determine_glyph_ids
66
+
67
+ # Build subsetting options
68
+ subset_options = build_subset_options
69
+
70
+ # Create builder and perform subsetting
71
+ builder = Subset::Builder.new(font, glyph_ids, subset_options)
72
+ subset_binary = builder.build
73
+
74
+ # Write output file (create parent directories if needed)
75
+ FileUtils.mkdir_p(File.dirname(@output_path))
76
+ File.binwrite(@output_path, subset_binary)
77
+
78
+ # Return result
79
+ {
80
+ input: font_path,
81
+ output: @output_path,
82
+ original_glyphs: font.table("maxp").num_glyphs,
83
+ subset_glyphs: builder.mapping.size,
84
+ profile: subset_options.profile,
85
+ size: subset_binary.bytesize,
86
+ }
87
+ rescue Fontisan::SubsettingError => e
88
+ raise Fontisan::SubsettingError,
89
+ "Subsetting failed: #{e.message}"
90
+ end
91
+
92
+ private
93
+
94
+ # Validate command options
95
+ #
96
+ # @raise [ArgumentError] If options are invalid
97
+ def validate_options!
98
+ # Must have output path
99
+ unless @output_path
100
+ raise ArgumentError, "Output path is required (--output)"
101
+ end
102
+
103
+ # Must have at least one input method
104
+ unless options[:text] || options[:glyphs] || options[:unicode]
105
+ raise ArgumentError,
106
+ "Must specify --text, --glyphs, or --unicode"
107
+ end
108
+
109
+ # Can only use one input method
110
+ input_methods = [
111
+ options[:text],
112
+ options[:glyphs],
113
+ options[:unicode],
114
+ ].compact.size
115
+
116
+ if input_methods > 1
117
+ raise ArgumentError,
118
+ "Can only specify one of --text, --glyphs, or --unicode"
119
+ end
120
+ end
121
+
122
+ # Determine glyph IDs to subset based on input options
123
+ #
124
+ # @return [Array<Integer>] Array of glyph IDs
125
+ # @raise [ArgumentError] If input is invalid
126
+ def determine_glyph_ids
127
+ if options[:text]
128
+ glyph_ids_from_text(options[:text])
129
+ elsif options[:glyphs]
130
+ parse_glyph_ids(options[:glyphs])
131
+ elsif options[:unicode]
132
+ glyph_ids_from_unicode(options[:unicode])
133
+ else
134
+ raise ArgumentError, "No input specified"
135
+ end
136
+ end
137
+
138
+ # Convert text to glyph IDs
139
+ #
140
+ # @param text [String] Input text
141
+ # @return [Array<Integer>] Array of glyph IDs
142
+ def glyph_ids_from_text(text)
143
+ cmap = font.table("cmap")
144
+ raise Fontisan::MissingTableError, "Font has no cmap table" unless cmap
145
+
146
+ mappings = cmap.unicode_mappings
147
+ glyph_ids = Set.new
148
+
149
+ text.each_char do |char|
150
+ codepoint = char.ord
151
+ glyph_id = mappings[codepoint]
152
+
153
+ if glyph_id
154
+ glyph_ids.add(glyph_id)
155
+ elsif options[:verbose]
156
+ warn "Warning: Character '#{char}' (U+#{codepoint.to_s(16).upcase}) not found in font"
157
+ end
158
+ end
159
+
160
+ if glyph_ids.empty?
161
+ raise ArgumentError, "No characters from text found in font"
162
+ end
163
+
164
+ glyph_ids.to_a.sort
165
+ end
166
+
167
+ # Convert Unicode codepoints to glyph IDs
168
+ #
169
+ # @param unicode_input [String, Array<Integer>] Unicode codepoints
170
+ # @return [Array<Integer>] Array of glyph IDs
171
+ def glyph_ids_from_unicode(unicode_input)
172
+ cmap = font.table("cmap")
173
+ raise Fontisan::MissingTableError, "Font has no cmap table" unless cmap
174
+
175
+ mappings = cmap.unicode_mappings
176
+ codepoints = parse_unicode(unicode_input)
177
+ glyph_ids = Set.new
178
+
179
+ codepoints.each do |codepoint|
180
+ glyph_id = mappings[codepoint]
181
+
182
+ if glyph_id
183
+ glyph_ids.add(glyph_id)
184
+ elsif options[:verbose]
185
+ warn "Warning: U+#{codepoint.to_s(16).upcase} not found in font"
186
+ end
187
+ end
188
+
189
+ if glyph_ids.empty?
190
+ raise ArgumentError, "No Unicode codepoints found in font"
191
+ end
192
+
193
+ glyph_ids.to_a.sort
194
+ end
195
+
196
+ # Parse glyph IDs from input
197
+ #
198
+ # @param glyph_input [String, Array<Integer>] Glyph IDs
199
+ # @return [Array<Integer>] Array of glyph IDs
200
+ def parse_glyph_ids(glyph_input)
201
+ if glyph_input.is_a?(Array)
202
+ glyph_input.map(&:to_i)
203
+ elsif glyph_input.is_a?(String)
204
+ # Parse comma-separated or space-separated list
205
+ glyph_input.split(/[,\s]+/).map(&:to_i)
206
+ else
207
+ raise ArgumentError, "Invalid glyph input: #{glyph_input.inspect}"
208
+ end
209
+ end
210
+
211
+ # Parse Unicode codepoints from input
212
+ #
213
+ # @param unicode_input [String, Array<Integer>] Unicode codepoints
214
+ # @return [Array<Integer>] Array of codepoints
215
+ def parse_unicode(unicode_input)
216
+ if unicode_input.is_a?(Array)
217
+ unicode_input.map(&:to_i)
218
+ elsif unicode_input.is_a?(String)
219
+ # Parse comma-separated list with optional U+ prefix
220
+ unicode_input.split(/[,\s]+/).map do |s|
221
+ s = s.sub(/^U\+/i, "")
222
+ s.to_i(16)
223
+ end
224
+ else
225
+ raise ArgumentError, "Invalid Unicode input: #{unicode_input.inspect}"
226
+ end
227
+ end
228
+
229
+ # Build subsetting options from command options
230
+ #
231
+ # @return [Subset::Options] Subsetting options
232
+ def build_subset_options
233
+ Subset::Options.new(
234
+ profile: options[:profile] || "pdf",
235
+ retain_gids: options[:retain_gids] || false,
236
+ drop_hints: options[:drop_hints] || false,
237
+ drop_names: options[:drop_names] || false,
238
+ unicode_ranges: options[:unicode_ranges].nil? || options[:unicode_ranges],
239
+ include_notdef: true,
240
+ include_null: false,
241
+ )
242
+ end
243
+ end
244
+ end
245
+ end