fontisan 0.2.0 → 0.2.2

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +119 -308
  3. data/README.adoc +1525 -1323
  4. data/Rakefile +45 -47
  5. data/benchmark/variation_quick_bench.rb +4 -4
  6. data/docs/FONT_HINTING.adoc +562 -0
  7. data/docs/VARIABLE_FONT_OPERATIONS.adoc +599 -0
  8. data/lib/fontisan/cli.rb +92 -34
  9. data/lib/fontisan/collection/builder.rb +82 -0
  10. data/lib/fontisan/collection/offset_calculator.rb +2 -0
  11. data/lib/fontisan/collection/table_deduplicator.rb +76 -0
  12. data/lib/fontisan/commands/base_command.rb +21 -2
  13. data/lib/fontisan/commands/convert_command.rb +96 -165
  14. data/lib/fontisan/commands/info_command.rb +111 -5
  15. data/lib/fontisan/commands/instance_command.rb +77 -85
  16. data/lib/fontisan/commands/validate_command.rb +28 -0
  17. data/lib/fontisan/config/validation_rules.yml +1 -1
  18. data/lib/fontisan/constants.rb +34 -24
  19. data/lib/fontisan/converters/format_converter.rb +154 -1
  20. data/lib/fontisan/converters/outline_converter.rb +101 -34
  21. data/lib/fontisan/converters/woff_writer.rb +9 -4
  22. data/lib/fontisan/font_loader.rb +14 -9
  23. data/lib/fontisan/font_writer.rb +9 -6
  24. data/lib/fontisan/formatters/text_formatter.rb +45 -1
  25. data/lib/fontisan/hints/hint_converter.rb +131 -2
  26. data/lib/fontisan/hints/hint_validator.rb +284 -0
  27. data/lib/fontisan/hints/postscript_hint_applier.rb +219 -140
  28. data/lib/fontisan/hints/postscript_hint_extractor.rb +151 -16
  29. data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
  30. data/lib/fontisan/hints/truetype_hint_extractor.rb +134 -11
  31. data/lib/fontisan/hints/truetype_instruction_analyzer.rb +261 -0
  32. data/lib/fontisan/hints/truetype_instruction_generator.rb +266 -0
  33. data/lib/fontisan/loading_modes.rb +6 -4
  34. data/lib/fontisan/models/collection_brief_info.rb +31 -0
  35. data/lib/fontisan/models/font_info.rb +3 -30
  36. data/lib/fontisan/models/hint.rb +183 -12
  37. data/lib/fontisan/models/outline.rb +4 -1
  38. data/lib/fontisan/open_type_font.rb +28 -10
  39. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  40. data/lib/fontisan/optimizers/pattern_analyzer.rb +2 -1
  41. data/lib/fontisan/optimizers/subroutine_generator.rb +1 -1
  42. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  43. data/lib/fontisan/pipeline/output_writer.rb +159 -0
  44. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  45. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  46. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  47. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  48. data/lib/fontisan/pipeline/transformation_pipeline.rb +416 -0
  49. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  50. data/lib/fontisan/subset/table_subsetter.rb +5 -5
  51. data/lib/fontisan/tables/cff/charstring.rb +58 -3
  52. data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
  53. data/lib/fontisan/tables/cff/charstring_parser.rb +249 -0
  54. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  55. data/lib/fontisan/tables/cff/dict_builder.rb +19 -1
  56. data/lib/fontisan/tables/cff/hint_operation_injector.rb +209 -0
  57. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  58. data/lib/fontisan/tables/cff/private_dict_writer.rb +131 -0
  59. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  60. data/lib/fontisan/tables/cff.rb +2 -0
  61. data/lib/fontisan/tables/cff2/charstring_parser.rb +14 -8
  62. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +247 -0
  63. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  64. data/lib/fontisan/tables/cff2/table_builder.rb +580 -0
  65. data/lib/fontisan/tables/cff2/table_reader.rb +421 -0
  66. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  67. data/lib/fontisan/tables/cff2.rb +10 -5
  68. data/lib/fontisan/tables/cvar.rb +2 -41
  69. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +2 -1
  70. data/lib/fontisan/tables/glyf/curve_converter.rb +10 -4
  71. data/lib/fontisan/tables/glyf/glyph_builder.rb +27 -10
  72. data/lib/fontisan/tables/gvar.rb +2 -41
  73. data/lib/fontisan/tables/name.rb +4 -4
  74. data/lib/fontisan/true_type_font.rb +27 -10
  75. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  76. data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
  77. data/lib/fontisan/validation/checksum_validator.rb +2 -2
  78. data/lib/fontisan/validation/table_validator.rb +1 -1
  79. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  80. data/lib/fontisan/variation/cache.rb +3 -1
  81. data/lib/fontisan/variation/converter.rb +121 -13
  82. data/lib/fontisan/variation/delta_applier.rb +2 -1
  83. data/lib/fontisan/variation/inspector.rb +2 -1
  84. data/lib/fontisan/variation/instance_generator.rb +2 -1
  85. data/lib/fontisan/variation/instance_writer.rb +341 -0
  86. data/lib/fontisan/variation/optimizer.rb +6 -3
  87. data/lib/fontisan/variation/subsetter.rb +32 -10
  88. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  89. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  90. data/lib/fontisan/variation/variation_preserver.rb +291 -0
  91. data/lib/fontisan/version.rb +1 -1
  92. data/lib/fontisan/version.rb.orig +9 -0
  93. data/lib/fontisan/woff2/glyf_transformer.rb +693 -0
  94. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  95. data/lib/fontisan/woff2_font.rb +489 -468
  96. data/lib/fontisan/woff_font.rb +16 -11
  97. data/lib/fontisan.rb +54 -2
  98. data/scripts/measure_optimization.rb +15 -7
  99. metadata +37 -2
@@ -131,7 +131,7 @@ module Fontisan
131
131
  # @param font [TrueTypeFont] Source font
132
132
  # @param options [Hash] Conversion options (currently unused)
133
133
  # @return [Hash<String, String>] Target tables
134
- def convert_ttf_to_otf(font, options = {})
134
+ def convert_ttf_to_otf(font, _options = {})
135
135
  # Extract all glyphs from glyf table
136
136
  outlines = extract_ttf_outlines(font)
137
137
 
@@ -153,6 +153,22 @@ module Fontisan
153
153
  # Update head table for CFF
154
154
  tables["head"] = update_head_for_cff(font)
155
155
 
156
+ # Convert and apply hints if preservation is enabled
157
+ if @preserve_hints && hints_per_glyph.any?
158
+ # Extract font-level hints separately
159
+ hint_set = extract_ttf_hint_set(font)
160
+
161
+ unless hint_set.empty?
162
+ # Convert TrueType hints to PostScript format
163
+ converter = Hints::HintConverter.new
164
+ ps_hint_set = converter.convert_hint_set(hint_set, :postscript)
165
+
166
+ # Apply PostScript hints (validation mode - CFF modification pending)
167
+ applier = Hints::PostScriptHintApplier.new
168
+ tables = applier.apply(ps_hint_set, tables)
169
+ end
170
+ end
171
+
156
172
  tables
157
173
  end
158
174
 
@@ -168,7 +184,8 @@ module Fontisan
168
184
  hints_per_glyph = @preserve_hints ? extract_cff_hints(font) : {}
169
185
 
170
186
  # Build glyf and loca tables
171
- glyf_data, loca_data, loca_format = build_glyf_loca_tables(outlines, hints_per_glyph)
187
+ glyf_data, loca_data, loca_format = build_glyf_loca_tables(outlines,
188
+ hints_per_glyph)
172
189
 
173
190
  # Copy all tables except CFF
174
191
  tables = copy_tables(font, ["CFF ", "CFF2"])
@@ -183,6 +200,22 @@ module Fontisan
183
200
  # Update head table for TrueType
184
201
  tables["head"] = update_head_for_truetype(font, loca_format)
185
202
 
203
+ # Convert and apply hints if preservation is enabled
204
+ if @preserve_hints && hints_per_glyph.any?
205
+ # Extract font-level hints separately
206
+ hint_set = extract_cff_hint_set(font)
207
+
208
+ unless hint_set.empty?
209
+ # Convert PostScript hints to TrueType format
210
+ converter = Hints::HintConverter.new
211
+ tt_hint_set = converter.convert_hint_set(hint_set, :truetype)
212
+
213
+ # Apply TrueType hints (writes fpgm/prep/cvt tables)
214
+ applier = Hints::TrueTypeHintApplier.new
215
+ tables = applier.apply(tt_hint_set, tables)
216
+ end
217
+ end
218
+
186
219
  tables
187
220
  end
188
221
 
@@ -325,7 +358,7 @@ module Fontisan
325
358
  # @param outlines [Array<Outline>] Glyph outlines
326
359
  # @param font [TrueTypeFont] Source font (for metadata)
327
360
  # @return [String] CFF table binary data
328
- def build_cff_table(outlines, font, hints_per_glyph)
361
+ def build_cff_table(outlines, font, _hints_per_glyph)
329
362
  # Build CharStrings INDEX from outlines
330
363
  begin
331
364
  charstrings = outlines.map do |outline|
@@ -446,7 +479,8 @@ module Fontisan
446
479
  top_dict_data = Tables::Cff::DictBuilder.build(top_dict_hash)
447
480
  top_dict_index_data = Tables::Cff::IndexBuilder.build([top_dict_data])
448
481
  rescue StandardError => e
449
- raise Fontisan::Error, "Failed to calculate CFF table offsets: #{e.message}"
482
+ raise Fontisan::Error,
483
+ "Failed to calculate CFF table offsets: #{e.message}"
450
484
  end
451
485
 
452
486
  # Build CFF Header
@@ -480,7 +514,7 @@ module Fontisan
480
514
  #
481
515
  # @param outlines [Array<Outline>] Glyph outlines
482
516
  # @return [Array<String, String, Integer>] [glyf_data, loca_data, loca_format]
483
- def build_glyf_loca_tables(outlines, hints_per_glyph)
517
+ def build_glyf_loca_tables(outlines, _hints_per_glyph)
484
518
  glyf_data = "".b
485
519
  offsets = []
486
520
 
@@ -493,19 +527,8 @@ module Fontisan
493
527
  next
494
528
  end
495
529
 
496
- # Convert outline to TrueType contours
497
- contours = outline.to_truetype_contours
498
-
499
- # Build glyph data
500
- builder = Tables::Glyf::GlyphBuilder.new(
501
- contours: contours,
502
- x_min: outline.bbox[:x_min],
503
- y_min: outline.bbox[:y_min],
504
- x_max: outline.bbox[:x_max],
505
- y_max: outline.bbox[:y_max],
506
- )
507
-
508
- glyph_data = builder.build
530
+ # Build glyph data using GlyphBuilder class method
531
+ glyph_data = Fontisan::Tables::GlyphBuilder.build_simple_glyph(outline)
509
532
  glyf_data << glyph_data
510
533
 
511
534
  # Add padding to 4-byte boundary
@@ -666,7 +689,7 @@ module Fontisan
666
689
  # Analyze patterns
667
690
  analyzer = Optimizers::PatternAnalyzer.new(
668
691
  min_length: 10,
669
- stack_aware: true
692
+ stack_aware: true,
670
693
  )
671
694
  patterns = analyzer.analyze(charstrings_hash)
672
695
 
@@ -674,7 +697,8 @@ module Fontisan
674
697
  return [charstrings, []] if patterns.empty?
675
698
 
676
699
  # Optimize selection
677
- optimizer = Optimizers::SubroutineOptimizer.new(patterns, max_subrs: 65_535)
700
+ optimizer = Optimizers::SubroutineOptimizer.new(patterns,
701
+ max_subrs: 65_535)
678
702
  selected_patterns = optimizer.optimize_selection
679
703
 
680
704
  # Optimize ordering
@@ -684,7 +708,8 @@ module Fontisan
684
708
  return [charstrings, []] if selected_patterns.empty?
685
709
 
686
710
  # Build subroutines
687
- builder = Optimizers::SubroutineBuilder.new(selected_patterns, type: :local)
711
+ builder = Optimizers::SubroutineBuilder.new(selected_patterns,
712
+ type: :local)
688
713
  local_subrs = builder.build
689
714
 
690
715
  # Build subroutine map
@@ -697,7 +722,9 @@ module Fontisan
697
722
  rewriter = Optimizers::CharstringRewriter.new(subroutine_map, builder)
698
723
  optimized_charstrings = charstrings.map.with_index do |charstring, glyph_id|
699
724
  # Find patterns for this glyph
700
- glyph_patterns = selected_patterns.select { |p| p.glyphs.include?(glyph_id) }
725
+ glyph_patterns = selected_patterns.select do |p|
726
+ p.glyphs.include?(glyph_id)
727
+ end
701
728
 
702
729
  if glyph_patterns.empty?
703
730
  charstring
@@ -722,13 +749,16 @@ module Fontisan
722
749
  def generate_static_instance(font, source_format, target_format)
723
750
  # Generate instance at specified coordinates
724
751
  fvar = font.table("fvar")
725
- axes = fvar ? fvar.axes : []
752
+ fvar ? fvar.axes : []
726
753
 
727
- generator = Variation::InstanceGenerator.new(font, @instance_coordinates)
754
+ generator = Variation::InstanceGenerator.new(font,
755
+ @instance_coordinates)
728
756
  instance_tables = generator.generate
729
757
 
730
758
  # If target format differs from source, convert outlines
731
- if source_format != target_format
759
+ if source_format == target_format
760
+ instance_tables
761
+ else
732
762
  # Create temporary font with instance tables
733
763
  temp_font = font.class.new
734
764
  temp_font.instance_variable_set(:@table_data, instance_tables)
@@ -742,8 +772,6 @@ module Fontisan
742
772
  else
743
773
  instance_tables
744
774
  end
745
- else
746
- instance_tables
747
775
  end
748
776
  end
749
777
 
@@ -780,8 +808,6 @@ module Fontisan
780
808
  when %i[otf ttf], %i[cff2 ttf]
781
809
  # blend → gvar
782
810
  converter.blend_to_gvar(glyph_id)
783
- else
784
- nil
785
811
  end
786
812
 
787
813
  variation_data[glyph_id] = data if data
@@ -835,19 +861,31 @@ module Fontisan
835
861
  def validate_source_tables(font, format)
836
862
  case format
837
863
  when :ttf
838
- unless font.has_table?("glyf") && font.has_table?("loca") &&
839
- font.table("glyf") && font.table("loca")
864
+ unless font.has_table?("glyf") && font.has_table?("loca")
865
+ raise Fontisan::MissingTableError,
866
+ "TrueType font missing required glyf or loca table"
867
+ end
868
+ # Also verify tables can actually be loaded
869
+ unless font.table("glyf") && font.table("loca")
840
870
  raise Fontisan::MissingTableError,
841
871
  "TrueType font missing required glyf or loca table"
842
872
  end
843
873
  when :cff2
844
- unless font.has_table?("CFF2") && font.table("CFF2")
874
+ unless font.has_table?("CFF2")
875
+ raise Fontisan::MissingTableError,
876
+ "CFF2 font missing required CFF2 table"
877
+ end
878
+ unless font.table("CFF2")
845
879
  raise Fontisan::MissingTableError,
846
880
  "CFF2 font missing required CFF2 table"
847
881
  end
848
882
  when :otf
849
- unless (font.has_table?("CFF ") && font.table("CFF ")) ||
850
- (font.has_table?("CFF2") && font.table("CFF2"))
883
+ unless font.has_table?("CFF ") || font.has_table?("CFF2")
884
+ raise Fontisan::MissingTableError,
885
+ "OpenType font missing required CFF or CFF2 table"
886
+ end
887
+ # Verify at least one can be loaded
888
+ unless font.table("CFF ") || font.table("CFF2")
851
889
  raise Fontisan::MissingTableError,
852
890
  "OpenType font missing required CFF or CFF2 table"
853
891
  end
@@ -855,6 +893,11 @@ module Fontisan
855
893
 
856
894
  # Common required tables
857
895
  %w[head hhea maxp].each do |tag|
896
+ unless font.has_table?(tag)
897
+ raise Fontisan::MissingTableError,
898
+ "Font missing required #{tag} table"
899
+ end
900
+ # Verify table can actually be loaded
858
901
  unless font.table(tag)
859
902
  raise Fontisan::MissingTableError,
860
903
  "Font missing required #{tag} table"
@@ -924,6 +967,30 @@ module Fontisan
924
967
  {}
925
968
  end
926
969
 
970
+ # Extract complete TrueType hint set from font
971
+ #
972
+ # @param font [TrueTypeFont] Source font
973
+ # @return [HintSet] Complete hint set
974
+ def extract_ttf_hint_set(font)
975
+ extractor = Hints::TrueTypeHintExtractor.new
976
+ extractor.extract_from_font(font)
977
+ rescue StandardError => e
978
+ warn "Failed to extract TrueType hint set: #{e.message}"
979
+ Models::HintSet.new(format: :truetype)
980
+ end
981
+
982
+ # Extract complete PostScript hint set from font
983
+ #
984
+ # @param font [OpenTypeFont] Source font
985
+ # @return [HintSet] Complete hint set
986
+ def extract_cff_hint_set(font)
987
+ extractor = Hints::PostScriptHintExtractor.new
988
+ extractor.extract_from_font(font)
989
+ rescue StandardError => e
990
+ warn "Failed to extract PostScript hint set: #{e.message}"
991
+ Models::HintSet.new(format: :postscript)
992
+ end
993
+
927
994
  # Check if font is a variable font
928
995
  #
929
996
  # @param font [TrueTypeFont, OpenTypeFont] Font to check
@@ -128,7 +128,8 @@ module Fontisan
128
128
  # @raise [ArgumentError] if compression level is invalid
129
129
  def validate_compression_level!
130
130
  unless @compression_level.between?(0, 9)
131
- raise ArgumentError, "Compression level must be between 0 and 9, got #{@compression_level}"
131
+ raise ArgumentError,
132
+ "Compression level must be between 0 and 9, got #{@compression_level}"
132
133
  end
133
134
  end
134
135
 
@@ -235,7 +236,9 @@ module Fontisan
235
236
  metadata_size = compressed_metadata ? compressed_metadata[:compressed_length] : 0
236
237
 
237
238
  # Calculate total compressed data size
238
- total_compressed_size = compressed_tables.values.sum { |table| table[:compressed_length] }
239
+ total_compressed_size = compressed_tables.values.sum do |table|
240
+ table[:compressed_length]
241
+ end
239
242
 
240
243
  # Calculate private data offset (after table data + metadata)
241
244
  private_offset = data_offset + total_compressed_size + metadata_size
@@ -245,7 +248,9 @@ module Fontisan
245
248
  total_size = private_offset + private_size
246
249
 
247
250
  # Calculate total SFNT size (uncompressed)
248
- total_sfnt_size = compressed_tables.values.sum { |table| table[:original_length] } +
251
+ total_sfnt_size = compressed_tables.values.sum do |table|
252
+ table[:original_length]
253
+ end +
249
254
  header_size + table_dir_size
250
255
 
251
256
  # Write WOFF header
@@ -364,7 +369,7 @@ module Fontisan
364
369
  # Sort tables by tag for consistent output (same order as directory)
365
370
  sorted_tables = compressed_tables.sort_by { |tag, _| tag }
366
371
 
367
- sorted_tables.each_value do |table_info|
372
+ sorted_tables.each do |_tag, table_info|
368
373
  io.write(table_info[:compressed_data])
369
374
  end
370
375
  end
@@ -46,7 +46,11 @@ module Fontisan
46
46
 
47
47
  # Resolve mode and lazy parameters with environment variables
48
48
  resolved_mode = mode || env_mode || LoadingModes::FULL
49
- resolved_lazy = lazy.nil? ? (env_lazy.nil? ? false : env_lazy) : lazy
49
+ resolved_lazy = if lazy.nil?
50
+ env_lazy.nil? ? false : env_lazy
51
+ else
52
+ lazy
53
+ end
50
54
 
51
55
  # Validate mode
52
56
  LoadingModes.validate_mode!(resolved_mode)
@@ -57,20 +61,19 @@ module Fontisan
57
61
 
58
62
  case signature
59
63
  when Constants::TTC_TAG
60
- load_from_collection(io, path, font_index, mode: resolved_mode, lazy: resolved_lazy)
64
+ load_from_collection(io, path, font_index, mode: resolved_mode,
65
+ lazy: resolved_lazy)
61
66
  when pack_uint32(Constants::SFNT_VERSION_TRUETYPE)
62
67
  TrueTypeFont.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
63
68
  when "OTTO"
64
69
  OpenTypeFont.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
65
70
  when "wOFF"
66
- raise UnsupportedFormatError,
67
- "Unsupported font format: WOFF. Please convert to TTF/OTF first."
71
+ WoffFont.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
68
72
  when "wOF2"
69
- raise UnsupportedFormatError,
70
- "Unsupported font format: WOFF2. Please convert to TTF/OTF first."
73
+ Woff2Font.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
71
74
  else
72
75
  raise InvalidFontError,
73
- "Unknown font format. Expected TTF, OTF, TTC, or OTC file."
76
+ "Unknown font format. Expected TTF, OTF, TTC, OTC, WOFF, or WOFF2 file."
74
77
  end
75
78
  end
76
79
  end
@@ -171,7 +174,8 @@ module Fontisan
171
174
  # @param lazy [Boolean] If true, load tables on demand
172
175
  # @return [TrueTypeFont, OpenTypeFont] The loaded font object
173
176
  # @raise [InvalidFontError] if collection type cannot be determined
174
- def self.load_from_collection(io, path, font_index, mode: LoadingModes::FULL, lazy: true)
177
+ def self.load_from_collection(io, path, font_index,
178
+ mode: LoadingModes::FULL, lazy: true)
175
179
  # Read collection header to get font offsets
176
180
  io.seek(12) # Skip tag (4) + major_version (2) + minor_version (2) + num_fonts marker (4)
177
181
  num_fonts = io.read(4).unpack1("N")
@@ -213,6 +217,7 @@ module Fontisan
213
217
  [value].pack("N")
214
218
  end
215
219
 
216
- private_class_method :load_from_collection, :pack_uint32, :env_mode, :env_lazy
220
+ private_class_method :load_from_collection, :pack_uint32, :env_mode,
221
+ :env_lazy
217
222
  end
218
223
  end
@@ -113,6 +113,7 @@ module Fontisan
113
113
  # Write offset table (sfnt header)
114
114
  font_data << write_offset_table(table_entries.size)
115
115
 
116
+ # rubocop:disable Style/CombinableLoops
116
117
  # Write table directory (ALL entries first)
117
118
  table_entries.each do |entry|
118
119
  font_data << write_table_entry(entry)
@@ -123,6 +124,7 @@ module Fontisan
123
124
  font_data << entry[:data]
124
125
  font_data << entry[:padding]
125
126
  end
127
+ # rubocop:enable Style/CombinableLoops
126
128
 
127
129
  # Calculate and update head table checksum adjustment
128
130
  update_checksum_adjustment!(font_data, table_entries)
@@ -263,17 +265,18 @@ module Fontisan
263
265
  head_entry = table_entries.find { |e| e[:tag] == "head" }
264
266
  return unless head_entry
265
267
 
266
- # Calculate font checksum (with head checksumAdjustment set to 0)
267
- # The head table at offset 8 should already be 0 from original table
268
+ # Zero out checksumAdjustment field (offset 8 in head table) before calculating
269
+ # This ensures we calculate the correct checksum regardless of source font's value
270
+ head_offset = head_entry[:offset]
271
+ checksum_offset = head_offset + 8
272
+ font_data[checksum_offset, 4] = "\x00\x00\x00\x00"
273
+
274
+ # Calculate font checksum (with head checksumAdjustment zeroed)
268
275
  font_checksum = calculate_font_checksum(font_data)
269
276
 
270
277
  # Calculate adjustment
271
278
  adjustment = (Constants::CHECKSUM_ADJUSTMENT_MAGIC - font_checksum) & 0xFFFFFFFF
272
279
 
273
- # Update head table checksumAdjustment field (offset 8 in head table)
274
- head_offset = head_entry[:offset]
275
- checksum_offset = head_offset + 8
276
-
277
280
  # Write adjustment as uint32 big-endian
278
281
  font_data[checksum_offset, 4] = [adjustment].pack("N")
279
282
  end
@@ -42,6 +42,8 @@ module Fontisan
42
42
  format_font_summary(model)
43
43
  when Models::CollectionInfo
44
44
  format_collection_info(model)
45
+ when Models::CollectionBriefInfo
46
+ format_collection_brief_info(model)
45
47
  else
46
48
  model.to_s
47
49
  end
@@ -312,7 +314,8 @@ module Fontisan
312
314
  font_format
313
315
  end
314
316
 
315
- type += " (Variable)" if is_variable
317
+ # Always show variable status explicitly
318
+ type += is_variable ? " (Variable)" : " (Not Variable)"
316
319
  type
317
320
  end
318
321
 
@@ -396,6 +399,47 @@ module Fontisan
396
399
  lines.join("\n")
397
400
  end
398
401
 
402
+ # Format CollectionBriefInfo as human-readable text.
403
+ #
404
+ # @param info [Models::CollectionBriefInfo] Collection brief information to format
405
+ # @return [String] Formatted text with collection header and each font's brief info
406
+ def format_collection_brief_info(info)
407
+ lines = []
408
+
409
+ # Collection header
410
+ lines << "Collection: #{info.collection_path}"
411
+ lines << "Fonts: #{info.num_fonts}"
412
+ lines << ""
413
+
414
+ # Each font's brief info
415
+ info.fonts.each_with_index do |font_info, index|
416
+ # Show font index with offset
417
+ if font_info.collection_offset
418
+ lines << "Font #{index} (offset: #{font_info.collection_offset}):"
419
+ else
420
+ lines << "Font #{index}:"
421
+ end
422
+ lines << ""
423
+
424
+ # Format each font using same structure as individual fonts
425
+ font_type_display = format_font_type_display(font_info.font_format, font_info.is_variable)
426
+ add_line(lines, "Font type", font_type_display)
427
+ add_line(lines, "Family", font_info.family_name)
428
+ add_line(lines, "Subfamily", font_info.subfamily_name)
429
+ add_line(lines, "Full name", font_info.full_name)
430
+ add_line(lines, "PostScript name", font_info.postscript_name)
431
+ add_line(lines, "Version", font_info.version)
432
+ add_line(lines, "Vendor ID", font_info.vendor_id)
433
+ add_line(lines, "Font revision", format_float(font_info.font_revision))
434
+ add_line(lines, "Units per em", font_info.units_per_em)
435
+
436
+ # Blank line between fonts (except after last)
437
+ lines << "" unless index == info.num_fonts - 1
438
+ end
439
+
440
+ lines.join("\n")
441
+ end
442
+
399
443
  # Format bytes for human-readable display.
400
444
  #
401
445
  # @param bytes [Integer] Number of bytes
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "../models/hint"
4
5
 
5
6
  module Fontisan
@@ -67,6 +68,59 @@ module Fontisan
67
68
  remove_conflicts(unique_hints)
68
69
  end
69
70
 
71
+ # Convert entire HintSet between formats
72
+ #
73
+ # @param hint_set [Models::HintSet] Source hint set
74
+ # @param target_format [Symbol] Target format (:truetype or :postscript)
75
+ # @return [Models::HintSet] Converted hint set
76
+ def convert_hint_set(hint_set, target_format)
77
+ return hint_set if hint_set.format == target_format.to_s
78
+
79
+ result = Models::HintSet.new(format: target_format.to_s)
80
+
81
+ case target_format
82
+ when :postscript
83
+ # Convert font-level TT → PS
84
+ if hint_set.font_program || hint_set.control_value_program ||
85
+ hint_set.control_values&.any?
86
+ ps_dict = convert_tt_programs_to_ps_dict(
87
+ hint_set.font_program,
88
+ hint_set.control_value_program,
89
+ hint_set.control_values,
90
+ )
91
+ result.private_dict_hints = ps_dict.to_json
92
+ end
93
+
94
+ # Convert per-glyph hints
95
+ hint_set.hinted_glyph_ids.each do |glyph_id|
96
+ glyph_hints = hint_set.get_glyph_hints(glyph_id)
97
+ ps_hints = to_postscript(glyph_hints)
98
+ result.add_glyph_hints(glyph_id, ps_hints) unless ps_hints.empty?
99
+ end
100
+
101
+ when :truetype
102
+ # Convert font-level PS → TT
103
+ if hint_set.private_dict_hints && hint_set.private_dict_hints != "{}"
104
+ tt_programs = convert_ps_dict_to_tt_programs(
105
+ JSON.parse(hint_set.private_dict_hints),
106
+ )
107
+ result.font_program = tt_programs[:fpgm]
108
+ result.control_value_program = tt_programs[:prep]
109
+ result.control_values = tt_programs[:cvt]
110
+ end
111
+
112
+ # Convert per-glyph hints
113
+ hint_set.hinted_glyph_ids.each do |glyph_id|
114
+ glyph_hints = hint_set.get_glyph_hints(glyph_id)
115
+ tt_hints = to_truetype(glyph_hints)
116
+ result.add_glyph_hints(glyph_id, tt_hints) unless tt_hints.empty?
117
+ end
118
+ end
119
+
120
+ result.has_hints = !result.empty?
121
+ result
122
+ end
123
+
70
124
  private
71
125
 
72
126
  # Convert a single hint to PostScript format
@@ -83,7 +137,7 @@ module Fontisan
83
137
  Models::Hint.new(
84
138
  type: hint.type,
85
139
  data: ps_data,
86
- source_format: :postscript
140
+ source_format: :postscript,
87
141
  )
88
142
  rescue StandardError => e
89
143
  warn "Failed to convert hint to PostScript: #{e.message}"
@@ -104,7 +158,7 @@ module Fontisan
104
158
  Models::Hint.new(
105
159
  type: hint.type,
106
160
  data: { instructions: tt_instructions },
107
- source_format: :truetype
161
+ source_format: :truetype,
108
162
  )
109
163
  rescue StandardError => e
110
164
  warn "Failed to convert hint to TrueType: #{e.message}"
@@ -172,6 +226,81 @@ module Fontisan
172
226
 
173
227
  pos1 < end2 && pos2 < end1
174
228
  end
229
+
230
+ # Convert TrueType font programs to PostScript Private dict
231
+ #
232
+ # Analyzes TrueType fpgm, prep, and cvt to extract semantic intent
233
+ # and generate corresponding PostScript hint parameters using the
234
+ # TrueTypeInstructionAnalyzer.
235
+ #
236
+ # @param fpgm [String] Font program bytecode
237
+ # @param prep [String] Control value program bytecode
238
+ # @param cvt [Array<Integer>] Control values
239
+ # @return [Hash] PostScript Private dict hint parameters
240
+ def convert_tt_programs_to_ps_dict(fpgm, prep, cvt)
241
+ hints = {}
242
+
243
+ # Extract stem widths from CVT if present
244
+ # CVT values typically contain standard widths at the beginning
245
+ if cvt && !cvt.empty?
246
+ # First CVT value often represents standard horizontal stem
247
+ hints[:std_hw] = cvt[0].abs if cvt.length > 0
248
+ # Second CVT value often represents standard vertical stem
249
+ hints[:std_vw] = cvt[1].abs if cvt.length > 1
250
+ end
251
+
252
+ # Use the instruction analyzer to extract additional hint parameters
253
+ analyzer = TrueTypeInstructionAnalyzer.new
254
+
255
+ # Analyze prep program if present
256
+ prep_hints = if prep && !prep.empty?
257
+ analyzer.analyze_prep(prep, cvt)
258
+ else
259
+ {}
260
+ end
261
+
262
+ # Analyze fpgm program complexity
263
+ fpgm_hints = if fpgm && !fpgm.empty?
264
+ analyzer.analyze_fpgm(fpgm)
265
+ else
266
+ {}
267
+ end
268
+
269
+ # Extract blue zones from CVT if present
270
+ blue_zones = if cvt && !cvt.empty?
271
+ analyzer.extract_blue_zones_from_cvt(cvt)
272
+ else
273
+ {}
274
+ end
275
+
276
+ # Merge all extracted hints (prep_hints and fpgm_hints override stem widths if present)
277
+ hints.merge!(prep_hints).merge!(fpgm_hints).merge!(blue_zones)
278
+
279
+ # Provide default blue_values if none were detected
280
+ # These are standard values that work for most Latin fonts
281
+ hints[:blue_values] ||= [-20, 0, 706, 726]
282
+
283
+ hints
284
+ rescue StandardError => e
285
+ warn "Error converting TT programs to PS dict: #{e.message}"
286
+ {}
287
+ end
288
+
289
+ # Convert PostScript Private dict to TrueType font programs
290
+ #
291
+ # Generates TrueType control values and programs from PostScript
292
+ # hint parameters using the TrueTypeInstructionGenerator.
293
+ #
294
+ # @param ps_dict [Hash] PostScript Private dict parameters
295
+ # @return [Hash] TrueType programs ({ fpgm:, prep:, cvt: })
296
+ def convert_ps_dict_to_tt_programs(ps_dict)
297
+ # Use the instruction generator to create real TrueType programs
298
+ generator = TrueTypeInstructionGenerator.new
299
+ generator.generate(ps_dict)
300
+ rescue StandardError => e
301
+ warn "Error converting PS dict to TT programs: #{e.message}"
302
+ { fpgm: "".b, prep: "".b, cvt: [] }
303
+ end
175
304
  end
176
305
  end
177
306
  end