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,423 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "models/glyph_outline"
4
+
5
+ module Fontisan
6
+ # Extracts glyph outlines from font tables
7
+ #
8
+ # [`OutlineExtractor`](lib/fontisan/outline_extractor.rb) provides a unified
9
+ # interface for extracting glyph outline data from both TrueType (glyf table)
10
+ # and CFF (Compact Font Format) fonts. It uses a strategy pattern to handle
11
+ # the different outline formats transparently.
12
+ #
13
+ # The extractor:
14
+ # - Automatically detects font format (TrueType vs CFF)
15
+ # - Handles simple glyphs (direct outline data)
16
+ # - Handles compound glyphs (recursively resolves components)
17
+ # - Returns standardized [`GlyphOutline`](lib/fontisan/models/glyph_outline.rb) objects
18
+ #
19
+ # This class is responsible for extraction only, not business logic or
20
+ # presentation. It's designed to be composed with
21
+ # [`GlyphAccessor`](lib/fontisan/glyph_accessor.rb) for higher-level operations.
22
+ #
23
+ # @example Extracting a glyph outline
24
+ # extractor = Fontisan::OutlineExtractor.new(font)
25
+ # outline = extractor.extract(65) # 'A' character
26
+ #
27
+ # puts outline.contour_count
28
+ # puts outline.to_svg_path
29
+ #
30
+ # @example Using with GlyphAccessor
31
+ # accessor = Fontisan::GlyphAccessor.new(font)
32
+ # outline = accessor.outline_for_char('A')
33
+ #
34
+ # Reference: [`docs/GETTING_STARTED.md:125-172`](docs/GETTING_STARTED.md:125)
35
+ class OutlineExtractor
36
+ # @return [TrueTypeFont, OpenTypeFont] Font instance
37
+ attr_reader :font
38
+
39
+ # Initialize a new outline extractor
40
+ #
41
+ # @param font [TrueTypeFont, OpenTypeFont] Font to extract outlines from
42
+ # @raise [ArgumentError] If font is nil or doesn't have required tables
43
+ def initialize(font)
44
+ raise ArgumentError, "Font cannot be nil" if font.nil?
45
+
46
+ unless font.respond_to?(:table)
47
+ raise ArgumentError, "Font must respond to :table method"
48
+ end
49
+
50
+ @font = font
51
+ end
52
+
53
+ # Extract outline for a specific glyph
54
+ #
55
+ # This method automatically detects the font format and delegates to
56
+ # the appropriate extraction strategy. For compound glyphs (TrueType only),
57
+ # it recursively resolves component outlines and combines them with
58
+ # proper transformations.
59
+ #
60
+ # @param glyph_id [Integer] The glyph index (0-based, 0 is .notdef)
61
+ # @return [Models::GlyphOutline, nil] The outline or nil if glyph not
62
+ # found or empty
63
+ # @raise [ArgumentError] If glyph_id is invalid
64
+ # @raise [Fontisan::MissingTableError] If required tables are missing
65
+ #
66
+ # @example Extract a simple glyph
67
+ # outline = extractor.extract(65)
68
+ # puts "Glyph has #{outline.contour_count} contours"
69
+ #
70
+ # @example Handle empty glyphs (like space)
71
+ # outline = extractor.extract(space_glyph_id)
72
+ # # => nil (empty glyphs return nil)
73
+ def extract(glyph_id)
74
+ validate_glyph_id!(glyph_id)
75
+
76
+ if cff_font?
77
+ extract_cff_outline(glyph_id)
78
+ elsif truetype_font?
79
+ extract_truetype_outline(glyph_id)
80
+ else
81
+ raise Fontisan::MissingTableError,
82
+ "Font has neither glyf nor CFF table"
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ # Check if this is a CFF font
89
+ #
90
+ # @return [Boolean] True if font has CFF table
91
+ def cff_font?
92
+ font.table(Constants::CFF_TAG) != nil
93
+ end
94
+
95
+ # Check if this is a TrueType font
96
+ #
97
+ # @return [Boolean] True if font has glyf table
98
+ def truetype_font?
99
+ font.has_table?("glyf")
100
+ end
101
+
102
+ # Validate glyph ID
103
+ #
104
+ # @param glyph_id [Integer] Glyph ID to validate
105
+ # @raise [ArgumentError] If glyph ID is invalid
106
+ def validate_glyph_id!(glyph_id)
107
+ if glyph_id.nil?
108
+ raise ArgumentError, "glyph_id cannot be nil"
109
+ end
110
+
111
+ if glyph_id.negative?
112
+ raise ArgumentError, "glyph_id must be >= 0, got: #{glyph_id}"
113
+ end
114
+
115
+ maxp = font.table("maxp")
116
+ if maxp && glyph_id >= maxp.num_glyphs
117
+ raise ArgumentError,
118
+ "glyph_id #{glyph_id} exceeds number of glyphs (#{maxp.num_glyphs})"
119
+ end
120
+ end
121
+
122
+ # Extract outline from TrueType glyph
123
+ #
124
+ # Handles both simple and compound glyphs. For compound glyphs,
125
+ # recursively resolves component outlines and applies transformations.
126
+ #
127
+ # @param glyph_id [Integer] Glyph ID
128
+ # @return [Models::GlyphOutline, nil] Outline or nil if empty
129
+ # @raise [Fontisan::MissingTableError] If required tables are missing
130
+ def extract_truetype_outline(glyph_id)
131
+ glyph = get_truetype_glyph(glyph_id)
132
+ return nil unless glyph
133
+
134
+ # Handle empty glyphs (space, etc.)
135
+ return nil if glyph.respond_to?(:empty?) && glyph.empty?
136
+
137
+ if glyph.simple?
138
+ extract_simple_outline(glyph)
139
+ elsif glyph.compound?
140
+ extract_compound_outline(glyph)
141
+ else
142
+ raise Fontisan::Error, "Unknown glyph type: #{glyph.class}"
143
+ end
144
+ end
145
+
146
+ # Extract outline from CFF glyph
147
+ #
148
+ # CFF glyphs don't have compound structures, so extraction is
149
+ # straightforward from the CharString.
150
+ #
151
+ # @param glyph_id [Integer] Glyph ID
152
+ # @return [Models::GlyphOutline, nil] Outline or nil if empty
153
+ # @raise [Fontisan::MissingTableError] If CFF table is missing
154
+ def extract_cff_outline(glyph_id)
155
+ cff = font.table(Constants::CFF_TAG)
156
+ raise_missing_table!(Constants::CFF_TAG) unless cff
157
+
158
+ # Get CharString for glyph
159
+ charstring = cff.charstring_for_glyph(glyph_id)
160
+ return nil unless charstring
161
+
162
+ # CharString has path data
163
+ path = charstring.path
164
+ return nil if path.empty?
165
+
166
+ # Convert CharString path to contours
167
+ contours = convert_cff_path_to_contours(path)
168
+ return nil if contours.empty?
169
+
170
+ # Get bounding box from CharString
171
+ bbox_array = charstring.bounding_box
172
+ return nil unless bbox_array
173
+
174
+ bbox = {
175
+ x_min: bbox_array[0],
176
+ y_min: bbox_array[1],
177
+ x_max: bbox_array[2],
178
+ y_max: bbox_array[3],
179
+ }
180
+
181
+ Models::GlyphOutline.new(
182
+ glyph_id: glyph_id,
183
+ contours: contours,
184
+ bbox: bbox,
185
+ )
186
+ rescue StandardError => e
187
+ warn "Failed to extract CFF outline for glyph #{glyph_id}: #{e.message}"
188
+ nil
189
+ end
190
+
191
+ # Get TrueType glyph from glyf table
192
+ #
193
+ # @param glyph_id [Integer] Glyph ID
194
+ # @return [SimpleGlyph, CompoundGlyph, nil] Glyph object
195
+ # @raise [Fontisan::MissingTableError] If required tables are missing
196
+ def get_truetype_glyph(glyph_id)
197
+ glyf = font.table("glyf")
198
+ raise_missing_table!("glyf") unless glyf
199
+
200
+ loca = font.table("loca")
201
+ raise_missing_table!("loca") unless loca
202
+
203
+ head = font.table("head")
204
+ raise_missing_table!("head") unless head
205
+
206
+ # Ensure loca is parsed
207
+ unless loca.parsed?
208
+ maxp = font.table("maxp")
209
+ raise_missing_table!("maxp") unless maxp
210
+ loca.parse_with_context(head.index_to_loc_format, maxp.num_glyphs)
211
+ end
212
+
213
+ glyf.glyph_for(glyph_id, loca, head)
214
+ end
215
+
216
+ # Extract outline from a simple TrueType glyph
217
+ #
218
+ # @param glyph [SimpleGlyph] Simple glyph object
219
+ # @return [Models::GlyphOutline] Outline object
220
+ def extract_simple_outline(glyph)
221
+ contours = []
222
+
223
+ # Process each contour
224
+ glyph.num_contours.times do |contour_index|
225
+ points = glyph.points_for_contour(contour_index)
226
+ contours << points if points && !points.empty?
227
+ end
228
+
229
+ bbox = {
230
+ x_min: glyph.x_min,
231
+ y_min: glyph.y_min,
232
+ x_max: glyph.x_max,
233
+ y_max: glyph.y_max,
234
+ }
235
+
236
+ Models::GlyphOutline.new(
237
+ glyph_id: glyph.glyph_id,
238
+ contours: contours,
239
+ bbox: bbox,
240
+ )
241
+ end
242
+
243
+ # Extract outline from a compound TrueType glyph
244
+ #
245
+ # Recursively resolves component glyphs and applies transformations
246
+ # to combine them into a single outline.
247
+ #
248
+ # @param glyph [CompoundGlyph] Compound glyph object
249
+ # @return [Models::GlyphOutline] Combined outline object
250
+ def extract_compound_outline(glyph)
251
+ all_contours = []
252
+ combined_bbox = nil
253
+
254
+ # Process each component
255
+ glyph.components.each do |component|
256
+ component_outline = extract(component.glyph_index)
257
+ next unless component_outline
258
+
259
+ # Get transformation matrix
260
+ matrix = component.transformation_matrix
261
+
262
+ # Transform component contours
263
+ transformed_contours = transform_contours(
264
+ component_outline.contours,
265
+ matrix,
266
+ )
267
+ all_contours.concat(transformed_contours)
268
+
269
+ # Update combined bounding box
270
+ component_bbox = component_outline.bbox
271
+ combined_bbox = merge_bboxes(combined_bbox, component_bbox, matrix)
272
+ end
273
+
274
+ # Use original bbox if we couldn't compute one
275
+ combined_bbox ||= {
276
+ x_min: glyph.x_min,
277
+ y_min: glyph.y_min,
278
+ x_max: glyph.x_max,
279
+ y_max: glyph.y_max,
280
+ }
281
+
282
+ Models::GlyphOutline.new(
283
+ glyph_id: glyph.glyph_id,
284
+ contours: all_contours,
285
+ bbox: combined_bbox,
286
+ )
287
+ end
288
+
289
+ # Transform contours using an affine transformation matrix
290
+ #
291
+ # @param contours [Array<Array<Hash>>] Original contours
292
+ # @param matrix [Array<Float>] Transformation matrix [a, b, c, d, e, f]
293
+ # @return [Array<Array<Hash>>] Transformed contours
294
+ def transform_contours(contours, matrix)
295
+ a, b, c, d, e, f = matrix
296
+
297
+ contours.map do |contour|
298
+ contour.map do |point|
299
+ x = point[:x]
300
+ y = point[:y]
301
+
302
+ # Apply affine transformation: x' = a*x + c*y + e, y' = b*x + d*y + f
303
+ new_x = (a * x + c * y + e).round
304
+ new_y = (b * x + d * y + f).round
305
+
306
+ {
307
+ x: new_x,
308
+ y: new_y,
309
+ on_curve: point[:on_curve],
310
+ }
311
+ end
312
+ end
313
+ end
314
+
315
+ # Merge two bounding boxes
316
+ #
317
+ # @param bbox1 [Hash, nil] First bounding box
318
+ # @param bbox2 [Hash] Second bounding box
319
+ # @param matrix [Array<Float>] Transformation matrix for bbox2
320
+ # @return [Hash] Merged bounding box
321
+ def merge_bboxes(bbox1, bbox2, matrix)
322
+ # Transform bbox2 corners
323
+ a, b, c, d, e, f = matrix
324
+
325
+ corners = [
326
+ [bbox2[:x_min], bbox2[:y_min]],
327
+ [bbox2[:x_max], bbox2[:y_min]],
328
+ [bbox2[:x_min], bbox2[:y_max]],
329
+ [bbox2[:x_max], bbox2[:y_max]],
330
+ ]
331
+
332
+ transformed_corners = corners.map do |x, y|
333
+ [
334
+ (a * x + c * y + e).round,
335
+ (b * x + d * y + f).round,
336
+ ]
337
+ end
338
+
339
+ transformed_bbox = {
340
+ x_min: transformed_corners.map(&:first).min,
341
+ y_min: transformed_corners.map(&:last).min,
342
+ x_max: transformed_corners.map(&:first).max,
343
+ y_max: transformed_corners.map(&:last).max,
344
+ }
345
+
346
+ return transformed_bbox unless bbox1
347
+
348
+ # Merge with existing bbox
349
+ {
350
+ x_min: [bbox1[:x_min], transformed_bbox[:x_min]].min,
351
+ y_min: [bbox1[:y_min], transformed_bbox[:y_min]].min,
352
+ x_max: [bbox1[:x_max], transformed_bbox[:x_max]].max,
353
+ y_max: [bbox1[:y_max], transformed_bbox[:y_max]].max,
354
+ }
355
+ end
356
+
357
+ # Convert CFF CharString path to contours
358
+ #
359
+ # CFF paths are stored as arrays of command hashes. We need to
360
+ # convert them to the contour format used by GlyphOutline.
361
+ #
362
+ # @param path [Array<Hash>] CharString path data
363
+ # @return [Array<Array<Hash>>] Contours array
364
+ def convert_cff_path_to_contours(path)
365
+ contours = []
366
+ current_contour = []
367
+
368
+ path.each do |cmd|
369
+ case cmd[:type]
370
+ when :move_to
371
+ # Start new contour
372
+ contours << current_contour unless current_contour.empty?
373
+ current_contour = []
374
+ current_contour << {
375
+ x: cmd[:x].round,
376
+ y: cmd[:y].round,
377
+ on_curve: true,
378
+ }
379
+ when :line_to
380
+ current_contour << {
381
+ x: cmd[:x].round,
382
+ y: cmd[:y].round,
383
+ on_curve: true,
384
+ }
385
+ when :curve_to
386
+ # CFF uses cubic Bézier curves
387
+ # For now, we'll add control points and end point
388
+ # This is a simplification - proper handling would require
389
+ # converting cubic to quadratic or keeping cubic format
390
+ current_contour << {
391
+ x: cmd[:x1].round,
392
+ y: cmd[:y1].round,
393
+ on_curve: false,
394
+ }
395
+ current_contour << {
396
+ x: cmd[:x2].round,
397
+ y: cmd[:y2].round,
398
+ on_curve: false,
399
+ }
400
+ current_contour << {
401
+ x: cmd[:x].round,
402
+ y: cmd[:y].round,
403
+ on_curve: true,
404
+ }
405
+ end
406
+ end
407
+
408
+ # Add final contour
409
+ contours << current_contour unless current_contour.empty?
410
+
411
+ contours
412
+ end
413
+
414
+ # Raise MissingTableError
415
+ #
416
+ # @param table_tag [String] Table tag
417
+ # @raise [Fontisan::MissingTableError]
418
+ def raise_missing_table!(table_tag)
419
+ raise Fontisan::MissingTableError,
420
+ "Required table '#{table_tag}' not found in font"
421
+ end
422
+ end
423
+ end
@@ -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