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,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Fontisan
6
+ module Models
7
+ # ValidationReport represents the result of font validation
8
+ #
9
+ # This model encapsulates all validation results including error, warning,
10
+ # and informational messages. It supports serialization to YAML, JSON, and
11
+ # plain text formats for different use cases.
12
+ #
13
+ # @example Creating a validation report
14
+ # report = ValidationReport.new(
15
+ # font_path: "font.ttf",
16
+ # valid: false
17
+ # )
18
+ # report.add_error("tables", "Missing required table: glyf", nil)
19
+ # report.add_warning("checksum", "Table 'name' checksum mismatch", "name table")
20
+ #
21
+ # @example Serializing to YAML
22
+ # yaml_output = report.to_yaml
23
+ #
24
+ # @example Serializing to JSON
25
+ # json_output = report.to_json
26
+ class ValidationReport < Lutaml::Model::Serializable
27
+ # Individual validation issue
28
+ class Issue < Lutaml::Model::Serializable
29
+ attribute :severity, :string
30
+ attribute :category, :string
31
+ attribute :message, :string
32
+ attribute :location, :string, default: -> {}
33
+
34
+ yaml do
35
+ map "severity", to: :severity
36
+ map "category", to: :category
37
+ map "message", to: :message
38
+ map "location", to: :location
39
+ end
40
+
41
+ json do
42
+ map "severity", to: :severity
43
+ map "category", to: :category
44
+ map "message", to: :message
45
+ map "location", to: :location
46
+ end
47
+ end
48
+
49
+ # Validation summary counts
50
+ class Summary < Lutaml::Model::Serializable
51
+ attribute :errors, :integer, default: -> { 0 }
52
+ attribute :warnings, :integer, default: -> { 0 }
53
+ attribute :info, :integer, default: -> { 0 }
54
+
55
+ yaml do
56
+ map "errors", to: :errors
57
+ map "warnings", to: :warnings
58
+ map "info", to: :info
59
+ end
60
+
61
+ json do
62
+ map "errors", to: :errors
63
+ map "warnings", to: :warnings
64
+ map "info", to: :info
65
+ end
66
+ end
67
+
68
+ attribute :font_path, :string
69
+ attribute :valid, :boolean
70
+ attribute :issues, Issue, collection: true, default: -> { [] }
71
+ attribute :summary, Summary, default: -> { Summary.new }
72
+
73
+ yaml do
74
+ map "font_path", to: :font_path
75
+ map "valid", to: :valid
76
+ map "summary", to: :summary
77
+ map "issues", to: :issues
78
+ end
79
+
80
+ json do
81
+ map "font_path", to: :font_path
82
+ map "valid", to: :valid
83
+ map "summary", to: :summary
84
+ map "issues", to: :issues
85
+ end
86
+
87
+ # Add an error to the report
88
+ #
89
+ # @param category [String] The error category (e.g., "tables", "structure")
90
+ # @param message [String] The error message
91
+ # @param location [String, nil] The specific location of the error
92
+ # @return [void]
93
+ def add_error(category, message, location = nil)
94
+ issues << Issue.new(
95
+ severity: "error",
96
+ category: category,
97
+ message: message,
98
+ location: location,
99
+ )
100
+ summary.errors += 1
101
+ self.valid = false
102
+ end
103
+
104
+ # Add a warning to the report
105
+ #
106
+ # @param category [String] The warning category
107
+ # @param message [String] The warning message
108
+ # @param location [String, nil] The specific location of the warning
109
+ # @return [void]
110
+ def add_warning(category, message, location = nil)
111
+ issues << Issue.new(
112
+ severity: "warning",
113
+ category: category,
114
+ message: message,
115
+ location: location,
116
+ )
117
+ summary.warnings += 1
118
+ end
119
+
120
+ # Add an info message to the report
121
+ #
122
+ # @param category [String] The info category
123
+ # @param message [String] The info message
124
+ # @param location [String, nil] The specific location
125
+ # @return [void]
126
+ def add_info(category, message, location = nil)
127
+ issues << Issue.new(
128
+ severity: "info",
129
+ category: category,
130
+ message: message,
131
+ location: location,
132
+ )
133
+ summary.info += 1
134
+ end
135
+
136
+ # Get all error issues
137
+ #
138
+ # @return [Array<Issue>] Array of error issues
139
+ def errors
140
+ issues.select { |issue| issue.severity == "error" }
141
+ end
142
+
143
+ # Get all warning issues
144
+ #
145
+ # @return [Array<Issue>] Array of warning issues
146
+ def warnings
147
+ issues.select { |issue| issue.severity == "warning" }
148
+ end
149
+
150
+ # Get all info issues
151
+ #
152
+ # @return [Array<Issue>] Array of info issues
153
+ def info_issues
154
+ issues.select { |issue| issue.severity == "info" }
155
+ end
156
+
157
+ # Check if report has errors
158
+ #
159
+ # @return [Boolean] true if errors exist
160
+ def has_errors?
161
+ summary.errors.positive?
162
+ end
163
+
164
+ # Check if report has warnings
165
+ #
166
+ # @return [Boolean] true if warnings exist
167
+ def has_warnings?
168
+ summary.warnings.positive?
169
+ end
170
+
171
+ # Get a text summary of the validation
172
+ #
173
+ # @return [String] Human-readable summary
174
+ def text_summary
175
+ status = valid ? "VALID" : "INVALID"
176
+ lines = []
177
+ lines << "Font: #{font_path}"
178
+ lines << "Status: #{status}"
179
+ lines << ""
180
+ lines << "Summary:"
181
+ lines << " Errors: #{summary.errors}"
182
+ lines << " Warnings: #{summary.warnings}"
183
+ lines << " Info: #{summary.info}"
184
+
185
+ if issues.any?
186
+ lines << ""
187
+ lines << "Issues:"
188
+ issues.each do |issue|
189
+ severity_marker = case issue.severity
190
+ when "error" then "[ERROR]"
191
+ when "warning" then "[WARN]"
192
+ when "info" then "[INFO]"
193
+ end
194
+ location_info = issue.location ? " (#{issue.location})" : ""
195
+ lines << " #{severity_marker} #{issue.category}: #{issue.message}#{location_info}"
196
+ end
197
+ end
198
+
199
+ lines.join("\n")
200
+ end
201
+ end
202
+ end
203
+ end
@@ -63,12 +63,13 @@ module Fontisan
63
63
  #
64
64
  # @param index [Integer] Index of the font (0-based)
65
65
  # @param io [IO] Open file handle
66
+ # @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
66
67
  # @return [OpenTypeFont, nil] Font object or nil if index out of range
67
- def font(index, io)
68
+ def font(index, io, mode: LoadingModes::FULL)
68
69
  return nil if index >= num_fonts
69
70
 
70
71
  require_relative "open_type_font"
71
- OpenTypeFont.from_collection(io, font_offsets[index])
72
+ OpenTypeFont.from_collection(io, font_offsets[index], mode: mode)
72
73
  end
73
74
 
74
75
  # Get font count
@@ -93,5 +94,158 @@ module Fontisan
93
94
  def version
94
95
  (major_version << 16) | minor_version
95
96
  end
97
+
98
+ # List all fonts in the collection with basic metadata
99
+ #
100
+ # Returns a CollectionListInfo model containing summaries of all fonts.
101
+ # This is the API method used by the `ls` command for collections.
102
+ #
103
+ # @param io [IO] Open file handle to read fonts from
104
+ # @return [CollectionListInfo] List of fonts with metadata
105
+ #
106
+ # @example List fonts in collection
107
+ # File.open("fonts.otc", "rb") do |io|
108
+ # otc = OpenTypeCollection.read(io)
109
+ # list = otc.list_fonts(io)
110
+ # list.fonts.each { |f| puts "#{f.index}: #{f.family_name}" }
111
+ # end
112
+ def list_fonts(io)
113
+ require_relative "models/collection_list_info"
114
+ require_relative "models/collection_font_summary"
115
+ require_relative "open_type_font"
116
+ require_relative "tables/name"
117
+
118
+ fonts = font_offsets.map.with_index do |offset, index|
119
+ font = OpenTypeFont.from_collection(io, offset)
120
+
121
+ # Extract basic font info
122
+ name_table = font.table("name")
123
+ post_table = font.table("post")
124
+
125
+ family_name = name_table&.english_name(Tables::Name::FAMILY) || "Unknown"
126
+ subfamily_name = name_table&.english_name(Tables::Name::SUBFAMILY) || "Regular"
127
+ postscript_name = name_table&.english_name(Tables::Name::POSTSCRIPT_NAME) || "Unknown"
128
+
129
+ # Determine font format
130
+ sfnt = font.header.sfnt_version
131
+ font_format = case sfnt
132
+ when 0x00010000, 0x74727565 # 0x74727565 = 'true'
133
+ "TrueType"
134
+ when 0x4F54544F # 'OTTO'
135
+ "OpenType"
136
+ else
137
+ "Unknown"
138
+ end
139
+
140
+ num_glyphs = post_table&.glyph_names&.length || 0
141
+ num_tables = font.table_names.length
142
+
143
+ Models::CollectionFontSummary.new(
144
+ index: index,
145
+ family_name: family_name,
146
+ subfamily_name: subfamily_name,
147
+ postscript_name: postscript_name,
148
+ font_format: font_format,
149
+ num_glyphs: num_glyphs,
150
+ num_tables: num_tables,
151
+ )
152
+ end
153
+
154
+ Models::CollectionListInfo.new(
155
+ collection_path: nil, # Will be set by command
156
+ num_fonts: num_fonts,
157
+ fonts: fonts,
158
+ )
159
+ end
160
+
161
+ # Get comprehensive collection metadata
162
+ #
163
+ # Returns a CollectionInfo model with header information, offsets,
164
+ # and table sharing statistics.
165
+ # This is the API method used by the `info` command for collections.
166
+ #
167
+ # @param io [IO] Open file handle to read fonts from
168
+ # @param path [String] Collection file path (for file size)
169
+ # @return [CollectionInfo] Collection metadata
170
+ #
171
+ # @example Get collection info
172
+ # File.open("fonts.otc", "rb") do |io|
173
+ # otc = OpenTypeCollection.read(io)
174
+ # info = otc.collection_info(io, "fonts.otc")
175
+ # puts "Version: #{info.version_string}"
176
+ # end
177
+ def collection_info(io, path)
178
+ require_relative "models/collection_info"
179
+ require_relative "models/table_sharing_info"
180
+
181
+ # Calculate table sharing statistics
182
+ table_sharing = calculate_table_sharing(io)
183
+
184
+ # Get file size
185
+ file_size = path ? File.size(path) : 0
186
+
187
+ Models::CollectionInfo.new(
188
+ collection_path: path,
189
+ collection_format: "OTC",
190
+ ttc_tag: tag,
191
+ major_version: major_version,
192
+ minor_version: minor_version,
193
+ num_fonts: num_fonts,
194
+ font_offsets: font_offsets.to_a,
195
+ file_size_bytes: file_size,
196
+ table_sharing: table_sharing,
197
+ )
198
+ end
199
+
200
+ private
201
+
202
+ # Calculate table sharing statistics
203
+ #
204
+ # Analyzes which tables are shared between fonts and calculates
205
+ # space savings from deduplication.
206
+ #
207
+ # @param io [IO] Open file handle
208
+ # @return [TableSharingInfo] Sharing statistics
209
+ def calculate_table_sharing(io)
210
+ require_relative "models/table_sharing_info"
211
+ require_relative "open_type_font"
212
+
213
+ # Extract all fonts
214
+ fonts = font_offsets.map do |offset|
215
+ OpenTypeFont.from_collection(io, offset)
216
+ end
217
+
218
+ # Build table hash map (checksum -> size)
219
+ table_map = {}
220
+ total_table_size = 0
221
+
222
+ fonts.each do |font|
223
+ font.tables.each do |entry|
224
+ key = entry.checksum
225
+ size = entry.table_length
226
+ table_map[key] ||= size
227
+ total_table_size += size
228
+ end
229
+ end
230
+
231
+ # Count unique vs shared
232
+ unique_tables = table_map.size
233
+ total_tables = fonts.sum { |f| f.tables.length }
234
+ shared_tables = total_tables - unique_tables
235
+
236
+ # Calculate space saved
237
+ unique_size = table_map.values.sum
238
+ space_saved = total_table_size - unique_size
239
+
240
+ # Calculate sharing percentage
241
+ sharing_pct = total_tables.positive? ? (shared_tables.to_f / total_tables * 100).round(2) : 0.0
242
+
243
+ Models::TableSharingInfo.new(
244
+ shared_tables: shared_tables,
245
+ unique_tables: unique_tables,
246
+ sharing_percentage: sharing_pct,
247
+ space_saved_bytes: space_saved,
248
+ )
249
+ end
96
250
  end
97
251
  end