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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +119 -308
- data/README.adoc +1525 -1323
- data/Rakefile +45 -47
- data/benchmark/variation_quick_bench.rb +4 -4
- data/docs/FONT_HINTING.adoc +562 -0
- data/docs/VARIABLE_FONT_OPERATIONS.adoc +599 -0
- data/lib/fontisan/cli.rb +92 -34
- data/lib/fontisan/collection/builder.rb +82 -0
- data/lib/fontisan/collection/offset_calculator.rb +2 -0
- data/lib/fontisan/collection/table_deduplicator.rb +76 -0
- data/lib/fontisan/commands/base_command.rb +21 -2
- data/lib/fontisan/commands/convert_command.rb +96 -165
- data/lib/fontisan/commands/info_command.rb +111 -5
- data/lib/fontisan/commands/instance_command.rb +77 -85
- data/lib/fontisan/commands/validate_command.rb +28 -0
- data/lib/fontisan/config/validation_rules.yml +1 -1
- data/lib/fontisan/constants.rb +34 -24
- data/lib/fontisan/converters/format_converter.rb +154 -1
- data/lib/fontisan/converters/outline_converter.rb +101 -34
- data/lib/fontisan/converters/woff_writer.rb +9 -4
- data/lib/fontisan/font_loader.rb +14 -9
- data/lib/fontisan/font_writer.rb +9 -6
- data/lib/fontisan/formatters/text_formatter.rb +45 -1
- data/lib/fontisan/hints/hint_converter.rb +131 -2
- data/lib/fontisan/hints/hint_validator.rb +284 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +219 -140
- data/lib/fontisan/hints/postscript_hint_extractor.rb +151 -16
- data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
- data/lib/fontisan/hints/truetype_hint_extractor.rb +134 -11
- data/lib/fontisan/hints/truetype_instruction_analyzer.rb +261 -0
- data/lib/fontisan/hints/truetype_instruction_generator.rb +266 -0
- data/lib/fontisan/loading_modes.rb +6 -4
- data/lib/fontisan/models/collection_brief_info.rb +31 -0
- data/lib/fontisan/models/font_info.rb +3 -30
- data/lib/fontisan/models/hint.rb +183 -12
- data/lib/fontisan/models/outline.rb +4 -1
- data/lib/fontisan/open_type_font.rb +28 -10
- data/lib/fontisan/open_type_font_extensions.rb +54 -0
- data/lib/fontisan/optimizers/pattern_analyzer.rb +2 -1
- data/lib/fontisan/optimizers/subroutine_generator.rb +1 -1
- data/lib/fontisan/pipeline/format_detector.rb +249 -0
- data/lib/fontisan/pipeline/output_writer.rb +159 -0
- data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
- data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
- data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
- data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
- data/lib/fontisan/pipeline/transformation_pipeline.rb +416 -0
- data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
- data/lib/fontisan/subset/table_subsetter.rb +5 -5
- data/lib/fontisan/tables/cff/charstring.rb +58 -3
- data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
- data/lib/fontisan/tables/cff/charstring_parser.rb +249 -0
- data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +19 -1
- data/lib/fontisan/tables/cff/hint_operation_injector.rb +209 -0
- data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
- data/lib/fontisan/tables/cff/private_dict_writer.rb +131 -0
- data/lib/fontisan/tables/cff/table_builder.rb +221 -0
- data/lib/fontisan/tables/cff.rb +2 -0
- data/lib/fontisan/tables/cff2/charstring_parser.rb +14 -8
- data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +247 -0
- data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
- data/lib/fontisan/tables/cff2/table_builder.rb +580 -0
- data/lib/fontisan/tables/cff2/table_reader.rb +421 -0
- data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
- data/lib/fontisan/tables/cff2.rb +10 -5
- data/lib/fontisan/tables/cvar.rb +2 -41
- data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +2 -1
- data/lib/fontisan/tables/glyf/curve_converter.rb +10 -4
- data/lib/fontisan/tables/glyf/glyph_builder.rb +27 -10
- data/lib/fontisan/tables/gvar.rb +2 -41
- data/lib/fontisan/tables/name.rb +4 -4
- data/lib/fontisan/true_type_font.rb +27 -10
- data/lib/fontisan/true_type_font_extensions.rb +54 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
- data/lib/fontisan/validation/checksum_validator.rb +2 -2
- data/lib/fontisan/validation/table_validator.rb +1 -1
- data/lib/fontisan/validation/variable_font_validator.rb +218 -0
- data/lib/fontisan/variation/cache.rb +3 -1
- data/lib/fontisan/variation/converter.rb +121 -13
- data/lib/fontisan/variation/delta_applier.rb +2 -1
- data/lib/fontisan/variation/inspector.rb +2 -1
- data/lib/fontisan/variation/instance_generator.rb +2 -1
- data/lib/fontisan/variation/instance_writer.rb +341 -0
- data/lib/fontisan/variation/optimizer.rb +6 -3
- data/lib/fontisan/variation/subsetter.rb +32 -10
- data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
- data/lib/fontisan/variation/variation_preserver.rb +291 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/version.rb.orig +9 -0
- data/lib/fontisan/woff2/glyf_transformer.rb +693 -0
- data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
- data/lib/fontisan/woff2_font.rb +489 -468
- data/lib/fontisan/woff_font.rb +16 -11
- data/lib/fontisan.rb +54 -2
- data/scripts/measure_optimization.rb +15 -7
- 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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
#
|
|
497
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
752
|
+
fvar ? fvar.axes : []
|
|
726
753
|
|
|
727
|
-
generator = Variation::InstanceGenerator.new(font,
|
|
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
|
|
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
|
-
|
|
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")
|
|
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
|
|
850
|
-
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
372
|
+
sorted_tables.each do |_tag, table_info|
|
|
368
373
|
io.write(table_info[:compressed_data])
|
|
369
374
|
end
|
|
370
375
|
end
|
data/lib/fontisan/font_loader.rb
CHANGED
|
@@ -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?
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
220
|
+
private_class_method :load_from_collection, :pack_uint32, :env_mode,
|
|
221
|
+
:env_lazy
|
|
217
222
|
end
|
|
218
223
|
end
|
data/lib/fontisan/font_writer.rb
CHANGED
|
@@ -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
|
-
#
|
|
267
|
-
#
|
|
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
|
-
|
|
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
|