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,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Subset
5
+ # Glyph ID mapping management
6
+ #
7
+ # This class manages the mapping between original glyph IDs (GIDs) in the
8
+ # source font and new GIDs in the subset font. It supports two modes:
9
+ #
10
+ # 1. Compact mode (retain_gids: false): Glyphs are renumbered sequentially,
11
+ # eliminating gaps from removed glyphs. This produces smaller fonts.
12
+ #
13
+ # 2. Retain mode (retain_gids: true): Original glyph IDs are preserved,
14
+ # with removed glyphs leaving empty slots. This maintains glyph
15
+ # references but produces larger fonts.
16
+ #
17
+ # @example Compact mode (default)
18
+ # mapping = Fontisan::Subset::GlyphMapping.new([0, 5, 10, 15])
19
+ # mapping.new_id(5) # => 1
20
+ # mapping.new_id(10) # => 2
21
+ # mapping.size # => 4
22
+ #
23
+ # @example Retain mode
24
+ # mapping = Fontisan::Subset::GlyphMapping.new([0, 5, 10, 15], retain_gids: true)
25
+ # mapping.new_id(5) # => 5
26
+ # mapping.new_id(10) # => 10
27
+ # mapping.size # => 16 (0..15)
28
+ #
29
+ # @example Reverse lookup
30
+ # mapping = Fontisan::Subset::GlyphMapping.new([0, 5, 10])
31
+ # mapping.old_id(1) # => 5
32
+ class GlyphMapping
33
+ # @return [Hash<Integer, Integer>] mapping from old GIDs to new GIDs
34
+ attr_reader :old_to_new
35
+
36
+ # @return [Hash<Integer, Integer>] mapping from new GIDs to old GIDs
37
+ attr_reader :new_to_old
38
+
39
+ # @return [Boolean] whether original GIDs are retained
40
+ attr_reader :retain_gids
41
+
42
+ # Initialize glyph mapping
43
+ #
44
+ # @param old_glyph_ids [Array<Integer>] array of glyph IDs to include
45
+ # in the subset, typically sorted
46
+ # @param retain_gids [Boolean] whether to preserve original glyph IDs
47
+ #
48
+ # @example Create compact mapping
49
+ # mapping = GlyphMapping.new([0, 3, 5, 10])
50
+ #
51
+ # @example Create mapping that retains GIDs
52
+ # mapping = GlyphMapping.new([0, 3, 5, 10], retain_gids: true)
53
+ def initialize(old_glyph_ids, retain_gids: false)
54
+ @old_to_new = {}
55
+ @new_to_old = {}
56
+ @retain_gids = retain_gids
57
+
58
+ build_mappings(old_glyph_ids)
59
+ end
60
+
61
+ # Get new glyph ID for an old glyph ID
62
+ #
63
+ # @param old_id [Integer] original glyph ID
64
+ # @return [Integer, nil] new glyph ID, or nil if not in subset
65
+ #
66
+ # @example
67
+ # mapping = GlyphMapping.new([0, 5, 10])
68
+ # mapping.new_id(5) # => 1
69
+ # mapping.new_id(99) # => nil
70
+ def new_id(old_id)
71
+ old_to_new[old_id]
72
+ end
73
+
74
+ # Get old glyph ID for a new glyph ID
75
+ #
76
+ # @param new_id [Integer] new glyph ID in subset
77
+ # @return [Integer, nil] original glyph ID, or nil if invalid
78
+ #
79
+ # @example
80
+ # mapping = GlyphMapping.new([0, 5, 10])
81
+ # mapping.old_id(1) # => 5
82
+ # mapping.old_id(99) # => nil
83
+ def old_id(new_id)
84
+ new_to_old[new_id]
85
+ end
86
+
87
+ # Get number of glyphs in the subset
88
+ #
89
+ # In compact mode, this is the number of included glyphs.
90
+ # In retain mode, this is the highest old GID + 1.
91
+ #
92
+ # @return [Integer] number of glyphs
93
+ #
94
+ # @example Compact mode
95
+ # mapping = GlyphMapping.new([0, 5, 10])
96
+ # mapping.size # => 3
97
+ #
98
+ # @example Retain mode
99
+ # mapping = GlyphMapping.new([0, 5, 10], retain_gids: true)
100
+ # mapping.size # => 11 (0..10)
101
+ def size
102
+ new_to_old.size
103
+ end
104
+
105
+ # Check if a glyph is included in the subset
106
+ #
107
+ # @param old_id [Integer] original glyph ID to check
108
+ # @return [Boolean] true if glyph is in subset
109
+ #
110
+ # @example
111
+ # mapping = GlyphMapping.new([0, 5, 10])
112
+ # mapping.include?(5) # => true
113
+ # mapping.include?(99) # => false
114
+ def include?(old_id)
115
+ old_to_new.key?(old_id)
116
+ end
117
+
118
+ # Get array of all old glyph IDs in subset
119
+ #
120
+ # @return [Array<Integer>] sorted array of old glyph IDs
121
+ #
122
+ # @example
123
+ # mapping = GlyphMapping.new([10, 0, 5])
124
+ # mapping.old_ids # => [0, 5, 10]
125
+ def old_ids
126
+ old_to_new.keys.sort
127
+ end
128
+
129
+ # Get array of all new glyph IDs in subset
130
+ #
131
+ # @return [Array<Integer>] sorted array of new glyph IDs
132
+ #
133
+ # @example
134
+ # mapping = GlyphMapping.new([0, 5, 10])
135
+ # mapping.new_ids # => [0, 1, 2]
136
+ def new_ids
137
+ new_to_old.keys.sort
138
+ end
139
+
140
+ # Iterate over all glyph mappings
141
+ #
142
+ # Yields old_id and new_id pairs in order of old glyph IDs.
143
+ #
144
+ # @yield [old_id, new_id] each glyph mapping
145
+ # @yieldparam old_id [Integer] original glyph ID
146
+ # @yieldparam new_id [Integer] new glyph ID
147
+ #
148
+ # @example
149
+ # mapping = GlyphMapping.new([0, 5, 10])
150
+ # mapping.each do |old_id, new_id|
151
+ # puts "#{old_id} => #{new_id}"
152
+ # end
153
+ # # Output:
154
+ # # 0 => 0
155
+ # # 5 => 1
156
+ # # 10 => 2
157
+ def each
158
+ return enum_for(:each) unless block_given?
159
+
160
+ old_ids.each do |old_id|
161
+ yield old_id, old_to_new[old_id]
162
+ end
163
+ end
164
+
165
+ private
166
+
167
+ # Build the bidirectional mapping tables
168
+ #
169
+ # @param old_glyph_ids [Array<Integer>] glyph IDs to map
170
+ def build_mappings(old_glyph_ids)
171
+ if retain_gids
172
+ build_retained_mappings(old_glyph_ids)
173
+ else
174
+ build_compact_mappings(old_glyph_ids)
175
+ end
176
+ end
177
+
178
+ # Build mappings in compact mode
179
+ #
180
+ # Assigns sequential new GIDs starting from 0, preserving the order
181
+ # of old GIDs.
182
+ #
183
+ # @param old_glyph_ids [Array<Integer>] glyph IDs to map
184
+ def build_compact_mappings(old_glyph_ids)
185
+ sorted_ids = old_glyph_ids.sort.uniq
186
+ sorted_ids.each_with_index do |old_id, new_id|
187
+ old_to_new[old_id] = new_id
188
+ new_to_old[new_id] = old_id
189
+ end
190
+ end
191
+
192
+ # Build mappings in retain GID mode
193
+ #
194
+ # Preserves original GIDs, creating empty slots for removed glyphs.
195
+ #
196
+ # @param old_glyph_ids [Array<Integer>] glyph IDs to map
197
+ def build_retained_mappings(old_glyph_ids)
198
+ sorted_ids = old_glyph_ids.sort.uniq
199
+ max_id = sorted_ids.max || 0
200
+
201
+ # Map each glyph to itself
202
+ sorted_ids.each do |old_id|
203
+ old_to_new[old_id] = old_id
204
+ new_to_old[old_id] = old_id
205
+ end
206
+
207
+ # Fill in empty slots for removed glyphs with nil mappings
208
+ # This ensures size calculation includes the empty slots
209
+ (0..max_id).each do |gid|
210
+ new_to_old[gid] ||= nil
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Fontisan
6
+ module Subset
7
+ # Subsetting configuration class
8
+ #
9
+ # This class defines all available options for font subsetting operations.
10
+ # It provides sensible defaults for various subsetting scenarios and uses
11
+ # Lutaml::Model for serialization support.
12
+ #
13
+ # @example Create default PDF subsetting options
14
+ # options = Fontisan::Subset::Options.new
15
+ # options.profile # => "pdf"
16
+ # options.drop_hints # => false
17
+ #
18
+ # @example Create custom web subsetting options
19
+ # options = Fontisan::Subset::Options.new(
20
+ # profile: "web",
21
+ # drop_hints: true,
22
+ # unicode_ranges: false
23
+ # )
24
+ #
25
+ # @example Retain original glyph IDs
26
+ # options = Fontisan::Subset::Options.new(retain_gids: true)
27
+ class Options < Lutaml::Model::Serializable
28
+ # Subsetting profile name (pdf, web, minimal, or custom)
29
+ #
30
+ # @return [String] the profile name
31
+ attribute :profile, :string, default: -> { "pdf" }
32
+
33
+ # Whether to drop hinting instructions
34
+ #
35
+ # Hinting improves text rendering at small sizes but increases file size.
36
+ # Web fonts typically don't need hints due to modern rendering engines.
37
+ #
38
+ # @return [Boolean] true to drop hints, false to retain them
39
+ attribute :drop_hints, :boolean, default: -> { false }
40
+
41
+ # Whether to drop glyph names from the post table
42
+ #
43
+ # Glyph names are useful for debugging but not required for rendering.
44
+ # Dropping them reduces file size.
45
+ #
46
+ # @return [Boolean] true to drop names, false to retain them
47
+ attribute :drop_names, :boolean, default: -> { false }
48
+
49
+ # Whether to prune OS/2 Unicode ranges
50
+ #
51
+ # Updates the OS/2 table's Unicode range bits to reflect only the
52
+ # glyphs present in the subset.
53
+ #
54
+ # @return [Boolean] true to prune ranges, false to keep original
55
+ attribute :unicode_ranges, :boolean, default: -> { true }
56
+
57
+ # Whether to retain original glyph IDs
58
+ #
59
+ # When true, removed glyphs leave empty slots in the glyf table,
60
+ # preserving original GID assignments. When false, glyphs are
61
+ # compacted to eliminate gaps.
62
+ #
63
+ # @return [Boolean] true to retain GIDs, false to compact
64
+ attribute :retain_gids, :boolean, default: -> { false }
65
+
66
+ # Whether to include the .notdef glyph
67
+ #
68
+ # The .notdef glyph is displayed for missing characters. It is
69
+ # typically required by font specifications.
70
+ #
71
+ # @return [Boolean] true to include .notdef, false to exclude
72
+ attribute :include_notdef, :boolean, default: -> { true }
73
+
74
+ # Whether to include the .null glyph
75
+ #
76
+ # The .null glyph (U+0000) is sometimes used for control purposes.
77
+ #
78
+ # @return [Boolean] true to include .null, false to exclude
79
+ attribute :include_null, :boolean, default: -> { false }
80
+
81
+ # OpenType features to retain in the subset
82
+ #
83
+ # An empty array means all features are retained. Specify feature
84
+ # tags (e.g., ['liga', 'kern']) to keep only those features.
85
+ #
86
+ # @return [Array<String>] array of feature tags to retain
87
+ attribute :features, :string, collection: true, default: -> { [] }
88
+
89
+ # Script tags to retain in the subset
90
+ #
91
+ # An array containing "*" means all scripts are retained. Specify
92
+ # script tags (e.g., ['latn', 'arab']) to keep only those scripts.
93
+ #
94
+ # @return [Array<String>] array of script tags to retain
95
+ attribute :scripts, :string, collection: true, default: -> { ["*"] }
96
+
97
+ # Initialize options with custom values
98
+ #
99
+ # @param attributes [Hash] hash of attribute values
100
+ # @option attributes [String] :profile ("pdf") subsetting profile
101
+ # @option attributes [Boolean] :drop_hints (false) drop hinting
102
+ # @option attributes [Boolean] :drop_names (false) drop glyph names
103
+ # @option attributes [Boolean] :unicode_ranges (true) prune OS/2 ranges
104
+ # @option attributes [Boolean] :retain_gids (false) retain glyph IDs
105
+ # @option attributes [Boolean] :include_notdef (true) include .notdef
106
+ # @option attributes [Boolean] :include_null (false) include .null
107
+ # @option attributes [Array<String>] :features ([]) features to keep
108
+ # @option attributes [Array<String>] :scripts (["*"]) scripts to keep
109
+ def initialize(attributes = {})
110
+ super
111
+ end
112
+
113
+ # Check if all features should be retained
114
+ #
115
+ # @return [Boolean] true if features array is empty
116
+ def all_features?
117
+ features.empty?
118
+ end
119
+
120
+ # Check if all scripts should be retained
121
+ #
122
+ # @return [Boolean] true if scripts contains "*"
123
+ def all_scripts?
124
+ scripts.include?("*")
125
+ end
126
+
127
+ # Validate the options configuration
128
+ #
129
+ # @raise [ArgumentError] if profile is invalid
130
+ # @return [Boolean] true if valid
131
+ def validate!
132
+ valid_profiles = %w[pdf web minimal custom]
133
+ unless valid_profiles.include?(profile)
134
+ raise ArgumentError,
135
+ "Invalid profile '#{profile}'. Must be one of: #{valid_profiles.join(', ')}"
136
+ end
137
+
138
+ true
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Fontisan
6
+ module Subset
7
+ # Subsetting profiles
8
+ #
9
+ # This class manages font subsetting profiles that specify which
10
+ # font tables should be included in the subset. Profiles are loaded from
11
+ # an external YAML configuration file for flexibility and maintainability.
12
+ #
13
+ # @example Get tables for PDF profile
14
+ # tables = Fontisan::Subset::Profile.for_name("pdf")
15
+ # # => ["cmap", "head", "hhea", "hmtx", "maxp", "name", "post", "loca", "glyf"]
16
+ #
17
+ # @example Get tables for web profile
18
+ # tables = Fontisan::Subset::Profile.for_name("web")
19
+ # # => ["cmap", "head", "hhea", "hmtx", "maxp", "name", "OS/2", "post", "loca", "glyf"]
20
+ #
21
+ # @example Create custom profile
22
+ # tables = Fontisan::Subset::Profile.custom(["cmap", "head", "hhea"])
23
+ # # => ["cmap", "head", "hhea"]
24
+ class Profile
25
+ # All known font table tags
26
+ #
27
+ # Comprehensive list of all standard TrueType/OpenType tables
28
+ KNOWN_TABLES = %w[
29
+ cmap head hhea hmtx maxp name OS/2 post
30
+ loca glyf cvt fpgm prep gasp
31
+ GSUB GPOS GDEF BASE JSTF
32
+ CFF CFF2 VORG
33
+ EBDT EBLC EBSC
34
+ CBDT CBLC sbix
35
+ kern vhea vmtx
36
+ LTSH PCLT VDMX hdmx
37
+ fvar gvar avar cvar HVAR VVAR MVAR STAT
38
+ DSIG
39
+ ].freeze
40
+
41
+ class << self
42
+ # Get table list for a named profile
43
+ #
44
+ # @param name [String] profile name (pdf, web, minimal, full)
45
+ # @raise [ArgumentError] if profile name is unknown
46
+ # @return [Array<String>] array of table tags
47
+ #
48
+ # @example
49
+ # Profile.for_name("pdf")
50
+ # # => ["cmap", "head", "hhea", "hmtx", "maxp", "name", "post", "loca", "glyf"]
51
+ def for_name(name)
52
+ profiles = load_profiles
53
+ profile_config = profiles[name.to_s.downcase]
54
+
55
+ unless profile_config
56
+ raise ArgumentError,
57
+ "Unknown profile '#{name}'. Valid profiles: #{valid_names.join(', ')}"
58
+ end
59
+
60
+ profile_config["tables"].dup
61
+ end
62
+
63
+ # Create a custom profile with specified tables
64
+ #
65
+ # Validates that all provided table tags are recognized and returns
66
+ # the list of tables in a consistent format.
67
+ #
68
+ # @param tables [Array<String>] array of table tags
69
+ # @raise [ArgumentError] if any table tag is unknown
70
+ # @return [Array<String>] validated array of table tags
71
+ #
72
+ # @example Create custom profile
73
+ # Profile.custom(["cmap", "head", "glyf"])
74
+ # # => ["cmap", "head", "glyf"]
75
+ #
76
+ # @example Invalid table raises error
77
+ # Profile.custom(["cmap", "invalid"])
78
+ # # => ArgumentError: Unknown table tags: invalid
79
+ def custom(tables)
80
+ tables = Array(tables)
81
+ unknown = tables - KNOWN_TABLES
82
+
83
+ unless unknown.empty?
84
+ raise ArgumentError,
85
+ "Unknown table tags: #{unknown.join(', ')}"
86
+ end
87
+
88
+ tables.dup
89
+ end
90
+
91
+ # Check if a table tag is recognized
92
+ #
93
+ # @param table [String] table tag to check
94
+ # @return [Boolean] true if table is known
95
+ #
96
+ # @example
97
+ # Profile.known_table?("cmap") # => true
98
+ # Profile.known_table?("invalid") # => false
99
+ def known_table?(table)
100
+ KNOWN_TABLES.include?(table.to_s)
101
+ end
102
+
103
+ # Get list of all valid profile names
104
+ #
105
+ # @return [Array<String>] array of profile names
106
+ #
107
+ # @example
108
+ # Profile.valid_names # => ["pdf", "web", "minimal", "full"]
109
+ def valid_names
110
+ load_profiles.keys.sort
111
+ end
112
+
113
+ # Get profile description
114
+ #
115
+ # @param name [String] profile name
116
+ # @return [String, nil] profile description or nil if not found
117
+ #
118
+ # @example
119
+ # Profile.description("pdf")
120
+ # # => "Minimal tables required for PDF font embedding"
121
+ def description(name)
122
+ profiles = load_profiles
123
+ profile_config = profiles[name.to_s.downcase]
124
+ profile_config&.dig("description")
125
+ end
126
+
127
+ private
128
+
129
+ # Load profiles from YAML configuration file
130
+ #
131
+ # @return [Hash] Hash of profile configurations
132
+ def load_profiles
133
+ @load_profiles ||= begin
134
+ config_path = File.join(__dir__, "../config/subset_profiles.yml")
135
+ YAML.load_file(config_path)
136
+ rescue Errno::ENOENT
137
+ raise Fontisan::Error,
138
+ "Profile configuration file not found: #{config_path}"
139
+ rescue Psych::SyntaxError => e
140
+ raise Fontisan::Error,
141
+ "Invalid YAML in profile configuration: #{e.message}"
142
+ end
143
+ end
144
+
145
+ # Clear cached profiles (useful for testing)
146
+ def clear_cache!
147
+ @load_profiles = nil
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end