fontisan 0.1.0 → 0.2.1

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 (214) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +672 -69
  3. data/Gemfile +1 -0
  4. data/LICENSE +5 -1
  5. data/README.adoc +1477 -297
  6. data/Rakefile +63 -41
  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 +364 -4
  12. data/lib/fontisan/collection/builder.rb +341 -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 +317 -0
  16. data/lib/fontisan/collection/writer.rb +306 -0
  17. data/lib/fontisan/commands/base_command.rb +24 -1
  18. data/lib/fontisan/commands/convert_command.rb +218 -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 +286 -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 +203 -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 +79 -0
  37. data/lib/fontisan/converters/conversion_strategy.rb +96 -0
  38. data/lib/fontisan/converters/format_converter.rb +408 -0
  39. data/lib/fontisan/converters/outline_converter.rb +998 -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 +122 -15
  57. data/lib/fontisan/font_writer.rb +302 -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 +310 -0
  61. data/lib/fontisan/hints/postscript_hint_applier.rb +266 -0
  62. data/lib/fontisan/hints/postscript_hint_extractor.rb +354 -0
  63. data/lib/fontisan/hints/truetype_hint_applier.rb +117 -0
  64. data/lib/fontisan/hints/truetype_hint_extractor.rb +289 -0
  65. data/lib/fontisan/loading_modes.rb +115 -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 +405 -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 +321 -19
  88. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  89. data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
  90. data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
  91. data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
  92. data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
  93. data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
  94. data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
  95. data/lib/fontisan/outline_extractor.rb +423 -0
  96. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  97. data/lib/fontisan/pipeline/output_writer.rb +154 -0
  98. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  99. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  100. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  101. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  102. data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
  103. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  104. data/lib/fontisan/subset/builder.rb +268 -0
  105. data/lib/fontisan/subset/glyph_mapping.rb +215 -0
  106. data/lib/fontisan/subset/options.rb +142 -0
  107. data/lib/fontisan/subset/profile.rb +152 -0
  108. data/lib/fontisan/subset/table_subsetter.rb +461 -0
  109. data/lib/fontisan/svg/font_face_generator.rb +278 -0
  110. data/lib/fontisan/svg/font_generator.rb +264 -0
  111. data/lib/fontisan/svg/glyph_generator.rb +168 -0
  112. data/lib/fontisan/svg/view_box_calculator.rb +137 -0
  113. data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
  114. data/lib/fontisan/tables/cff/charset.rb +282 -0
  115. data/lib/fontisan/tables/cff/charstring.rb +934 -0
  116. data/lib/fontisan/tables/cff/charstring_builder.rb +356 -0
  117. data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
  118. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  119. data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
  120. data/lib/fontisan/tables/cff/dict.rb +351 -0
  121. data/lib/fontisan/tables/cff/dict_builder.rb +257 -0
  122. data/lib/fontisan/tables/cff/encoding.rb +274 -0
  123. data/lib/fontisan/tables/cff/header.rb +102 -0
  124. data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
  125. data/lib/fontisan/tables/cff/index.rb +237 -0
  126. data/lib/fontisan/tables/cff/index_builder.rb +170 -0
  127. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  128. data/lib/fontisan/tables/cff/private_dict.rb +284 -0
  129. data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
  130. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  131. data/lib/fontisan/tables/cff/top_dict.rb +236 -0
  132. data/lib/fontisan/tables/cff.rb +489 -0
  133. data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
  134. data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
  135. data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
  136. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
  137. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  138. data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
  139. data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
  140. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  141. data/lib/fontisan/tables/cff2.rb +346 -0
  142. data/lib/fontisan/tables/cvar.rb +203 -0
  143. data/lib/fontisan/tables/fvar.rb +2 -2
  144. data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
  145. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
  146. data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
  147. data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
  148. data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
  149. data/lib/fontisan/tables/glyf.rb +235 -0
  150. data/lib/fontisan/tables/gvar.rb +231 -0
  151. data/lib/fontisan/tables/hhea.rb +124 -0
  152. data/lib/fontisan/tables/hmtx.rb +287 -0
  153. data/lib/fontisan/tables/hvar.rb +191 -0
  154. data/lib/fontisan/tables/loca.rb +322 -0
  155. data/lib/fontisan/tables/maxp.rb +192 -0
  156. data/lib/fontisan/tables/mvar.rb +185 -0
  157. data/lib/fontisan/tables/name.rb +99 -30
  158. data/lib/fontisan/tables/variation_common.rb +346 -0
  159. data/lib/fontisan/tables/vvar.rb +234 -0
  160. data/lib/fontisan/true_type_collection.rb +156 -2
  161. data/lib/fontisan/true_type_font.rb +321 -20
  162. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  163. data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
  164. data/lib/fontisan/utilities/checksum_calculator.rb +60 -0
  165. data/lib/fontisan/utils/thread_pool.rb +134 -0
  166. data/lib/fontisan/validation/checksum_validator.rb +170 -0
  167. data/lib/fontisan/validation/consistency_validator.rb +197 -0
  168. data/lib/fontisan/validation/structure_validator.rb +198 -0
  169. data/lib/fontisan/validation/table_validator.rb +158 -0
  170. data/lib/fontisan/validation/validator.rb +152 -0
  171. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  172. data/lib/fontisan/variable/axis_normalizer.rb +215 -0
  173. data/lib/fontisan/variable/delta_applicator.rb +313 -0
  174. data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
  175. data/lib/fontisan/variable/instancer.rb +344 -0
  176. data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
  177. data/lib/fontisan/variable/region_matcher.rb +208 -0
  178. data/lib/fontisan/variable/static_font_builder.rb +213 -0
  179. data/lib/fontisan/variable/table_updater.rb +219 -0
  180. data/lib/fontisan/variation/blend_applier.rb +199 -0
  181. data/lib/fontisan/variation/cache.rb +298 -0
  182. data/lib/fontisan/variation/cache_key_builder.rb +162 -0
  183. data/lib/fontisan/variation/converter.rb +375 -0
  184. data/lib/fontisan/variation/data_extractor.rb +86 -0
  185. data/lib/fontisan/variation/delta_applier.rb +266 -0
  186. data/lib/fontisan/variation/delta_parser.rb +228 -0
  187. data/lib/fontisan/variation/inspector.rb +275 -0
  188. data/lib/fontisan/variation/instance_generator.rb +273 -0
  189. data/lib/fontisan/variation/instance_writer.rb +341 -0
  190. data/lib/fontisan/variation/interpolator.rb +231 -0
  191. data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
  192. data/lib/fontisan/variation/optimizer.rb +418 -0
  193. data/lib/fontisan/variation/parallel_generator.rb +150 -0
  194. data/lib/fontisan/variation/region_matcher.rb +221 -0
  195. data/lib/fontisan/variation/subsetter.rb +463 -0
  196. data/lib/fontisan/variation/table_accessor.rb +105 -0
  197. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  198. data/lib/fontisan/variation/validator.rb +345 -0
  199. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  200. data/lib/fontisan/variation/variation_context.rb +211 -0
  201. data/lib/fontisan/variation/variation_preserver.rb +288 -0
  202. data/lib/fontisan/version.rb +1 -1
  203. data/lib/fontisan/version.rb.orig +9 -0
  204. data/lib/fontisan/woff2/directory.rb +257 -0
  205. data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
  206. data/lib/fontisan/woff2/header.rb +101 -0
  207. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  208. data/lib/fontisan/woff2/table_transformer.rb +163 -0
  209. data/lib/fontisan/woff2_font.rb +717 -0
  210. data/lib/fontisan/woff_font.rb +488 -0
  211. data/lib/fontisan.rb +132 -0
  212. data/scripts/compare_stack_aware.rb +187 -0
  213. data/scripts/measure_optimization.rb +141 -0
  214. metadata +234 -4
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "options"
4
+ require_relative "profile"
5
+ require_relative "glyph_mapping"
6
+ require_relative "table_subsetter"
7
+ require_relative "../font_writer"
8
+
9
+ module Fontisan
10
+ module Subset
11
+ # Main font subsetting engine
12
+ #
13
+ # The [`Builder`](lib/fontisan/subset/builder.rb) class orchestrates the entire
14
+ # subsetting process:
15
+ # 1. Validates input parameters
16
+ # 2. Calculates glyph closure (including composite dependencies)
17
+ # 3. Builds glyph ID mapping (old GID → new GID)
18
+ # 4. Subsets each table according to the selected profile
19
+ # 5. Assembles the final subset font binary
20
+ #
21
+ # The subsetting process ensures that .notdef (GID 0) is always included
22
+ # as the first glyph, as required by the OpenType specification.
23
+ #
24
+ # @example Basic subsetting
25
+ # font = Fontisan::TrueTypeFont.from_file('font.ttf')
26
+ # builder = Fontisan::Subset::Builder.new(
27
+ # font,
28
+ # [0, 65, 66, 67], # .notdef, A, B, C
29
+ # Options.new(profile: 'pdf')
30
+ # )
31
+ # subset_data = builder.build
32
+ #
33
+ # @example Subsetting with retain_gids
34
+ # options = Options.new(profile: 'pdf', retain_gids: true)
35
+ # builder = Fontisan::Subset::Builder.new(font, glyph_ids, options)
36
+ # subset_data = builder.build
37
+ #
38
+ # @example Web subsetting with dropped hints
39
+ # options = Options.new(profile: 'web', drop_hints: true, drop_names: true)
40
+ # builder = Fontisan::Subset::Builder.new(font, glyph_ids, options)
41
+ # subset_data = builder.build
42
+ #
43
+ # Reference: [`docs/ttfunk-feature-analysis.md:455-492`](docs/ttfunk-feature-analysis.md:455)
44
+ class Builder
45
+ # Font instance to subset
46
+ # @return [TrueTypeFont, OpenTypeFont]
47
+ attr_reader :font
48
+
49
+ # Base set of glyph IDs requested for subsetting
50
+ # @return [Array<Integer>]
51
+ attr_reader :glyph_ids
52
+
53
+ # Subsetting options
54
+ # @return [Options]
55
+ attr_reader :options
56
+
57
+ # Complete set of glyph IDs after closure calculation
58
+ # @return [Set<Integer>]
59
+ attr_reader :closure
60
+
61
+ # Glyph ID mapping (old GID → new GID)
62
+ # @return [GlyphMapping]
63
+ attr_reader :mapping
64
+
65
+ # Initialize a new subsetting builder
66
+ #
67
+ # @param font [TrueTypeFont, OpenTypeFont] Font to subset
68
+ # @param glyph_ids [Array<Integer>] Base glyph IDs to include
69
+ # @param options [Options, Hash] Subsetting options
70
+ # @raise [ArgumentError] If parameters are invalid
71
+ #
72
+ # @example
73
+ # builder = Builder.new(font, [0, 65, 66], Options.new(profile: 'pdf'))
74
+ def initialize(font, glyph_ids, options = {})
75
+ @font = font
76
+ @glyph_ids = Array(glyph_ids)
77
+ @options = options.is_a?(Options) ? options : Options.new(options)
78
+ @closure = nil
79
+ @mapping = nil
80
+ end
81
+
82
+ # Build the subset font
83
+ #
84
+ # This is the main entry point that performs the entire subsetting
85
+ # workflow:
86
+ # 1. Validates all input parameters
87
+ # 2. Calculates the glyph closure (composite dependencies)
88
+ # 3. Builds the glyph ID mapping
89
+ # 4. Subsets all required tables
90
+ # 5. Assembles the final font binary
91
+ #
92
+ # @return [String] Binary data of the subset font
93
+ # @raise [ArgumentError] If validation fails
94
+ # @raise [Fontisan::SubsettingError] If subsetting fails
95
+ #
96
+ # @example
97
+ # subset_binary = builder.build
98
+ # File.binwrite('subset.ttf', subset_binary)
99
+ def build
100
+ validate_input!
101
+ calculate_closure
102
+ build_mapping
103
+ tables = subset_tables
104
+ assemble_font(tables)
105
+ end
106
+
107
+ private
108
+
109
+ # Validate input parameters
110
+ #
111
+ # Ensures that the font, glyph IDs, and options are all valid for
112
+ # subsetting. Checks that required tables exist and that glyph IDs
113
+ # are within valid range.
114
+ #
115
+ # @raise [ArgumentError] If validation fails
116
+ def validate_input!
117
+ raise ArgumentError, "Font cannot be nil" if font.nil?
118
+
119
+ unless font.respond_to?(:table)
120
+ raise ArgumentError, "Font must respond to :table method"
121
+ end
122
+
123
+ # Validate options
124
+ options.validate!
125
+
126
+ # Ensure we have at least one glyph ID
127
+ if glyph_ids.empty?
128
+ raise ArgumentError, "At least one glyph ID must be provided"
129
+ end
130
+
131
+ # Validate that required tables exist
132
+ validate_required_tables!
133
+
134
+ # Validate glyph IDs are within range
135
+ validate_glyph_ids!
136
+ end
137
+
138
+ # Validate that required tables exist in the font
139
+ #
140
+ # @raise [Fontisan::MissingTableError] If required tables are missing
141
+ def validate_required_tables!
142
+ required = %w[head maxp]
143
+ required.each do |tag|
144
+ table = font.table(tag)
145
+ next if table
146
+
147
+ raise Fontisan::MissingTableError,
148
+ "Required table '#{tag}' not found in font"
149
+ end
150
+ end
151
+
152
+ # Validate that all glyph IDs are within valid range
153
+ #
154
+ # @raise [ArgumentError] If any glyph ID is invalid
155
+ def validate_glyph_ids!
156
+ maxp = font.table("maxp")
157
+ num_glyphs = maxp.num_glyphs
158
+
159
+ glyph_ids.each do |gid|
160
+ if gid.nil? || gid.negative?
161
+ raise ArgumentError, "Invalid glyph ID: #{gid.inspect}"
162
+ end
163
+
164
+ if gid >= num_glyphs
165
+ raise ArgumentError,
166
+ "Glyph ID #{gid} exceeds font's glyph count " \
167
+ "(#{num_glyphs})"
168
+ end
169
+ end
170
+ end
171
+
172
+ # Calculate glyph closure
173
+ #
174
+ # Uses [`GlyphAccessor`](lib/fontisan/glyph_accessor.rb) to recursively
175
+ # collect all glyphs needed, including component glyphs referenced by
176
+ # composite glyphs. Always ensures GID 0 (.notdef) is included.
177
+ #
178
+ # The closure is stored in the `@closure` instance variable as a Set.
179
+ def calculate_closure
180
+ accessor = Fontisan::GlyphAccessor.new(font)
181
+
182
+ # Ensure .notdef (GID 0) is included if specified in options
183
+ base_gids = glyph_ids.dup
184
+ base_gids.unshift(0) if options.include_notdef && !base_gids.include?(0)
185
+
186
+ # Calculate closure using GlyphAccessor
187
+ @closure = accessor.closure_for(base_gids)
188
+ end
189
+
190
+ # Build glyph mapping
191
+ #
192
+ # Creates a [`GlyphMapping`](lib/fontisan/subset/glyph_mapping.rb)
193
+ # object that maps old glyph IDs to new glyph IDs. The mapping respects
194
+ # the `retain_gids` option:
195
+ # - Compact mode (retain_gids: false): Sequential renumbering
196
+ # - Retain mode (retain_gids: true): Preserve original GIDs
197
+ #
198
+ # The mapping is stored in the `@mapping` instance variable.
199
+ def build_mapping
200
+ @mapping = GlyphMapping.new(
201
+ closure.to_a,
202
+ retain_gids: options.retain_gids,
203
+ )
204
+ end
205
+
206
+ # Subset all tables according to profile
207
+ #
208
+ # For each table specified in the subsetting profile, performs
209
+ # table-specific subsetting operations using [`TableSubsetter`](lib/fontisan/subset/table_subsetter.rb).
210
+ # Tables not in the profile are excluded from the subset font.
211
+ #
212
+ # @return [Hash<String, String>] Hash of table tag => binary data
213
+ # @raise [Fontisan::SubsettingError] If table subsetting fails
214
+ def subset_tables
215
+ profile_tables = Profile.for_name(options.profile)
216
+ subset = {}
217
+
218
+ # Create table subsetter
219
+ subsetter = TableSubsetter.new(font, mapping, options)
220
+
221
+ profile_tables.each do |tag|
222
+ table = font.table(tag)
223
+ next unless table
224
+
225
+ begin
226
+ subset[tag] = subsetter.subset_table(tag, table)
227
+ rescue StandardError => e
228
+ raise Fontisan::SubsettingError,
229
+ "Failed to subset table '#{tag}': #{e.message}"
230
+ end
231
+ end
232
+
233
+ subset
234
+ end
235
+
236
+ # Assemble final font
237
+ #
238
+ # Builds the complete font binary from subset tables, including:
239
+ # - Offset table (font directory)
240
+ # - Table directory entries
241
+ # - Table data
242
+ # - Proper padding and checksums
243
+ #
244
+ # @param tables [Hash<String, String>] Table tag => binary data
245
+ # @return [String] Complete font binary
246
+ def assemble_font(tables)
247
+ # Determine sfnt version based on font type
248
+ sfnt_version = determine_sfnt_version(tables)
249
+
250
+ # Use FontWriter to assemble the complete font
251
+ FontWriter.write_font(tables, sfnt_version: sfnt_version)
252
+ end
253
+
254
+ # Determine the sfnt version for the font
255
+ #
256
+ # @param tables [Hash<String, String>] Table tag => binary data
257
+ # @return [Integer] sfnt version number
258
+ def determine_sfnt_version(tables)
259
+ # If font has CFF or CFF2 table, use OpenType version
260
+ if tables.key?("CFF ") || tables.key?("CFF2")
261
+ 0x4F54544F # 'OTTO' for OpenType/CFF
262
+ else
263
+ 0x00010000 # 1.0 for TrueType
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
@@ -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