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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +529 -65
- data/Gemfile +1 -0
- data/LICENSE +5 -1
- data/README.adoc +1301 -275
- data/Rakefile +27 -2
- data/benchmark/variation_quick_bench.rb +47 -0
- data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
- data/fontisan.gemspec +4 -1
- data/lib/fontisan/binary/base_record.rb +22 -1
- data/lib/fontisan/cli.rb +309 -0
- data/lib/fontisan/collection/builder.rb +260 -0
- data/lib/fontisan/collection/offset_calculator.rb +227 -0
- data/lib/fontisan/collection/table_analyzer.rb +204 -0
- data/lib/fontisan/collection/table_deduplicator.rb +241 -0
- data/lib/fontisan/collection/writer.rb +306 -0
- data/lib/fontisan/commands/base_command.rb +8 -1
- data/lib/fontisan/commands/convert_command.rb +291 -0
- data/lib/fontisan/commands/export_command.rb +161 -0
- data/lib/fontisan/commands/info_command.rb +40 -6
- data/lib/fontisan/commands/instance_command.rb +295 -0
- data/lib/fontisan/commands/ls_command.rb +113 -0
- data/lib/fontisan/commands/pack_command.rb +241 -0
- data/lib/fontisan/commands/subset_command.rb +245 -0
- data/lib/fontisan/commands/unpack_command.rb +338 -0
- data/lib/fontisan/commands/validate_command.rb +178 -0
- data/lib/fontisan/commands/variable_command.rb +30 -1
- data/lib/fontisan/config/collection_settings.yml +56 -0
- data/lib/fontisan/config/conversion_matrix.yml +212 -0
- data/lib/fontisan/config/export_settings.yml +66 -0
- data/lib/fontisan/config/subset_profiles.yml +100 -0
- data/lib/fontisan/config/svg_settings.yml +60 -0
- data/lib/fontisan/config/validation_rules.yml +149 -0
- data/lib/fontisan/config/variable_settings.yml +99 -0
- data/lib/fontisan/config/woff2_settings.yml +77 -0
- data/lib/fontisan/constants.rb +69 -0
- data/lib/fontisan/converters/conversion_strategy.rb +96 -0
- data/lib/fontisan/converters/format_converter.rb +259 -0
- data/lib/fontisan/converters/outline_converter.rb +936 -0
- data/lib/fontisan/converters/svg_generator.rb +244 -0
- data/lib/fontisan/converters/table_copier.rb +117 -0
- data/lib/fontisan/converters/woff2_encoder.rb +416 -0
- data/lib/fontisan/converters/woff_writer.rb +391 -0
- data/lib/fontisan/error.rb +203 -0
- data/lib/fontisan/export/exporter.rb +262 -0
- data/lib/fontisan/export/table_serializer.rb +255 -0
- data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
- data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
- data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
- data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
- data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
- data/lib/fontisan/export/ttx_generator.rb +527 -0
- data/lib/fontisan/export/ttx_parser.rb +300 -0
- data/lib/fontisan/font_loader.rb +121 -12
- data/lib/fontisan/font_writer.rb +301 -0
- data/lib/fontisan/formatters/text_formatter.rb +102 -0
- data/lib/fontisan/glyph_accessor.rb +503 -0
- data/lib/fontisan/hints/hint_converter.rb +177 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +185 -0
- data/lib/fontisan/hints/postscript_hint_extractor.rb +254 -0
- data/lib/fontisan/hints/truetype_hint_applier.rb +71 -0
- data/lib/fontisan/hints/truetype_hint_extractor.rb +162 -0
- data/lib/fontisan/loading_modes.rb +113 -0
- data/lib/fontisan/metrics_calculator.rb +277 -0
- data/lib/fontisan/models/collection_font_summary.rb +52 -0
- data/lib/fontisan/models/collection_info.rb +76 -0
- data/lib/fontisan/models/collection_list_info.rb +37 -0
- data/lib/fontisan/models/font_export.rb +158 -0
- data/lib/fontisan/models/font_summary.rb +48 -0
- data/lib/fontisan/models/glyph_outline.rb +343 -0
- data/lib/fontisan/models/hint.rb +233 -0
- data/lib/fontisan/models/outline.rb +664 -0
- data/lib/fontisan/models/table_sharing_info.rb +40 -0
- data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
- data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
- data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
- data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
- data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
- data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
- data/lib/fontisan/models/ttx/ttfont.rb +49 -0
- data/lib/fontisan/models/validation_report.rb +203 -0
- data/lib/fontisan/open_type_collection.rb +156 -2
- data/lib/fontisan/open_type_font.rb +296 -10
- data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
- data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
- data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
- data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
- data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
- data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
- data/lib/fontisan/outline_extractor.rb +423 -0
- data/lib/fontisan/subset/builder.rb +268 -0
- data/lib/fontisan/subset/glyph_mapping.rb +215 -0
- data/lib/fontisan/subset/options.rb +142 -0
- data/lib/fontisan/subset/profile.rb +152 -0
- data/lib/fontisan/subset/table_subsetter.rb +461 -0
- data/lib/fontisan/svg/font_face_generator.rb +278 -0
- data/lib/fontisan/svg/font_generator.rb +264 -0
- data/lib/fontisan/svg/glyph_generator.rb +168 -0
- data/lib/fontisan/svg/view_box_calculator.rb +137 -0
- data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
- data/lib/fontisan/tables/cff/charset.rb +282 -0
- data/lib/fontisan/tables/cff/charstring.rb +905 -0
- data/lib/fontisan/tables/cff/charstring_builder.rb +322 -0
- data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
- data/lib/fontisan/tables/cff/dict.rb +351 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +242 -0
- data/lib/fontisan/tables/cff/encoding.rb +274 -0
- data/lib/fontisan/tables/cff/header.rb +102 -0
- data/lib/fontisan/tables/cff/index.rb +237 -0
- data/lib/fontisan/tables/cff/index_builder.rb +170 -0
- data/lib/fontisan/tables/cff/private_dict.rb +284 -0
- data/lib/fontisan/tables/cff/top_dict.rb +236 -0
- data/lib/fontisan/tables/cff.rb +487 -0
- data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
- data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
- data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
- data/lib/fontisan/tables/cff2.rb +341 -0
- data/lib/fontisan/tables/cvar.rb +242 -0
- data/lib/fontisan/tables/fvar.rb +2 -2
- data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
- data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
- data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
- data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
- data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
- data/lib/fontisan/tables/glyf.rb +235 -0
- data/lib/fontisan/tables/gvar.rb +270 -0
- data/lib/fontisan/tables/hhea.rb +124 -0
- data/lib/fontisan/tables/hmtx.rb +287 -0
- data/lib/fontisan/tables/hvar.rb +191 -0
- data/lib/fontisan/tables/loca.rb +322 -0
- data/lib/fontisan/tables/maxp.rb +192 -0
- data/lib/fontisan/tables/mvar.rb +185 -0
- data/lib/fontisan/tables/name.rb +99 -30
- data/lib/fontisan/tables/variation_common.rb +346 -0
- data/lib/fontisan/tables/vvar.rb +234 -0
- data/lib/fontisan/true_type_collection.rb +156 -2
- data/lib/fontisan/true_type_font.rb +297 -11
- data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +18 -0
- data/lib/fontisan/utils/thread_pool.rb +134 -0
- data/lib/fontisan/validation/checksum_validator.rb +170 -0
- data/lib/fontisan/validation/consistency_validator.rb +197 -0
- data/lib/fontisan/validation/structure_validator.rb +198 -0
- data/lib/fontisan/validation/table_validator.rb +158 -0
- data/lib/fontisan/validation/validator.rb +152 -0
- data/lib/fontisan/variable/axis_normalizer.rb +215 -0
- data/lib/fontisan/variable/delta_applicator.rb +313 -0
- data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
- data/lib/fontisan/variable/instancer.rb +344 -0
- data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
- data/lib/fontisan/variable/region_matcher.rb +208 -0
- data/lib/fontisan/variable/static_font_builder.rb +213 -0
- data/lib/fontisan/variable/table_updater.rb +219 -0
- data/lib/fontisan/variation/blend_applier.rb +199 -0
- data/lib/fontisan/variation/cache.rb +298 -0
- data/lib/fontisan/variation/cache_key_builder.rb +162 -0
- data/lib/fontisan/variation/converter.rb +268 -0
- data/lib/fontisan/variation/data_extractor.rb +86 -0
- data/lib/fontisan/variation/delta_applier.rb +266 -0
- data/lib/fontisan/variation/delta_parser.rb +228 -0
- data/lib/fontisan/variation/inspector.rb +275 -0
- data/lib/fontisan/variation/instance_generator.rb +273 -0
- data/lib/fontisan/variation/interpolator.rb +231 -0
- data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
- data/lib/fontisan/variation/optimizer.rb +418 -0
- data/lib/fontisan/variation/parallel_generator.rb +150 -0
- data/lib/fontisan/variation/region_matcher.rb +221 -0
- data/lib/fontisan/variation/subsetter.rb +463 -0
- data/lib/fontisan/variation/table_accessor.rb +105 -0
- data/lib/fontisan/variation/validator.rb +345 -0
- data/lib/fontisan/variation/variation_context.rb +211 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/directory.rb +257 -0
- data/lib/fontisan/woff2/header.rb +101 -0
- data/lib/fontisan/woff2/table_transformer.rb +163 -0
- data/lib/fontisan/woff2_font.rb +712 -0
- data/lib/fontisan/woff_font.rb +483 -0
- data/lib/fontisan.rb +120 -0
- data/scripts/compare_stack_aware.rb +187 -0
- data/scripts/measure_optimization.rb +141 -0
- metadata +205 -4
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "conversion_strategy"
|
|
4
|
+
require_relative "../outline_extractor"
|
|
5
|
+
require_relative "../models/outline"
|
|
6
|
+
require_relative "../tables/cff/charstring_builder"
|
|
7
|
+
require_relative "../tables/cff/index_builder"
|
|
8
|
+
require_relative "../tables/cff/dict_builder"
|
|
9
|
+
require_relative "../tables/glyf/glyph_builder"
|
|
10
|
+
require_relative "../tables/glyf/compound_glyph_resolver"
|
|
11
|
+
require_relative "../optimizers/pattern_analyzer"
|
|
12
|
+
require_relative "../optimizers/subroutine_optimizer"
|
|
13
|
+
require_relative "../optimizers/subroutine_builder"
|
|
14
|
+
require_relative "../optimizers/charstring_rewriter"
|
|
15
|
+
require_relative "../hints/truetype_hint_extractor"
|
|
16
|
+
require_relative "../hints/postscript_hint_extractor"
|
|
17
|
+
require_relative "../hints/hint_converter"
|
|
18
|
+
require_relative "../hints/truetype_hint_applier"
|
|
19
|
+
require_relative "../hints/postscript_hint_applier"
|
|
20
|
+
require_relative "../tables/cff2"
|
|
21
|
+
require_relative "../variation/data_extractor"
|
|
22
|
+
require_relative "../variation/instance_generator"
|
|
23
|
+
require_relative "../variation/converter"
|
|
24
|
+
|
|
25
|
+
module Fontisan
|
|
26
|
+
module Converters
|
|
27
|
+
# Strategy for converting between TTF and OTF outline formats
|
|
28
|
+
#
|
|
29
|
+
# [`OutlineConverter`](lib/fontisan/converters/outline_converter.rb)
|
|
30
|
+
# handles conversion between TrueType (glyf/loca) and CFF outline formats.
|
|
31
|
+
# This involves:
|
|
32
|
+
# - Extracting glyph outlines from source format
|
|
33
|
+
# - Converting to universal [`Outline`](lib/fontisan/models/outline.rb) model
|
|
34
|
+
# - Building target format tables using specialized builders
|
|
35
|
+
# - Updating related tables (maxp, head)
|
|
36
|
+
# - Preserving all other font tables
|
|
37
|
+
# - Optionally preserving rendering hints
|
|
38
|
+
#
|
|
39
|
+
# **Conversion Details:**
|
|
40
|
+
#
|
|
41
|
+
# TTF → OTF:
|
|
42
|
+
# - Extract glyphs from glyf/loca tables
|
|
43
|
+
# - Convert TrueType quadratic curves to universal format
|
|
44
|
+
# - Build complete CFF table with CharStrings INDEX
|
|
45
|
+
# - Remove glyf/loca tables
|
|
46
|
+
# - Update maxp to version 0.5 (CFF format)
|
|
47
|
+
# - Update head table (clear indexToLocFormat)
|
|
48
|
+
#
|
|
49
|
+
# OTF → TTF:
|
|
50
|
+
# - Extract CharStrings from CFF table
|
|
51
|
+
# - Convert CFF cubic curves to universal format
|
|
52
|
+
# - Build glyf and loca tables
|
|
53
|
+
# - Remove CFF table
|
|
54
|
+
# - Update maxp to version 1.0 (TrueType format)
|
|
55
|
+
# - Update head table (set indexToLocFormat)
|
|
56
|
+
#
|
|
57
|
+
# @example Converting TTF to OTF
|
|
58
|
+
# converter = Fontisan::Converters::OutlineConverter.new
|
|
59
|
+
# otf_font = converter.convert(ttf_font, target_format: :otf)
|
|
60
|
+
#
|
|
61
|
+
# @example Converting OTF to TTF
|
|
62
|
+
# converter = Fontisan::Converters::OutlineConverter.new
|
|
63
|
+
# ttf_font = converter.convert(otf_font, target_format: :ttf)
|
|
64
|
+
#
|
|
65
|
+
# @example Converting with hint preservation
|
|
66
|
+
# converter = Fontisan::Converters::OutlineConverter.new
|
|
67
|
+
# otf_font = converter.convert(ttf_font, target_format: :otf, preserve_hints: true)
|
|
68
|
+
class OutlineConverter
|
|
69
|
+
include ConversionStrategy
|
|
70
|
+
|
|
71
|
+
# Supported outline formats
|
|
72
|
+
SUPPORTED_FORMATS = %i[ttf otf cff2].freeze
|
|
73
|
+
|
|
74
|
+
# @return [TrueTypeFont, OpenTypeFont] Source font
|
|
75
|
+
attr_reader :font
|
|
76
|
+
|
|
77
|
+
# Initialize converter
|
|
78
|
+
def initialize
|
|
79
|
+
@font = nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Convert font between TTF and OTF formats
|
|
83
|
+
#
|
|
84
|
+
# @param font [TrueTypeFont, OpenTypeFont] Source font
|
|
85
|
+
# @param options [Hash] Conversion options
|
|
86
|
+
# @option options [Symbol] :target_format Target format (:ttf or :otf)
|
|
87
|
+
# @option options [Boolean] :optimize_cff Enable CFF subroutine optimization (default: false)
|
|
88
|
+
# @option options [Boolean] :preserve_hints Preserve rendering hints (default: false)
|
|
89
|
+
# @option options [Boolean] :preserve_variations Keep variation data during conversion (default: true)
|
|
90
|
+
# @option options [Boolean] :generate_instance Generate static instance instead of variable font (default: false)
|
|
91
|
+
# @option options [Hash] :instance_coordinates Axis coordinates for instance generation (default: {})
|
|
92
|
+
# @return [Hash<String, String>] Map of table tags to binary data
|
|
93
|
+
def convert(font, options = {})
|
|
94
|
+
@font = font
|
|
95
|
+
@options = options
|
|
96
|
+
@optimize_cff = options.fetch(:optimize_cff, false)
|
|
97
|
+
@preserve_hints = options.fetch(:preserve_hints, false)
|
|
98
|
+
@preserve_variations = options.fetch(:preserve_variations, true)
|
|
99
|
+
@generate_instance = options.fetch(:generate_instance, false)
|
|
100
|
+
@instance_coordinates = options.fetch(:instance_coordinates, {})
|
|
101
|
+
target_format = options[:target_format] ||
|
|
102
|
+
detect_target_format(font)
|
|
103
|
+
validate(font, target_format)
|
|
104
|
+
|
|
105
|
+
source_format = detect_format(font)
|
|
106
|
+
|
|
107
|
+
# Check if we should generate a static instance instead
|
|
108
|
+
if @generate_instance && variable_font?(font)
|
|
109
|
+
return generate_static_instance(font, source_format, target_format)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
case [source_format, target_format]
|
|
113
|
+
when %i[ttf otf]
|
|
114
|
+
convert_ttf_to_otf(font, options)
|
|
115
|
+
when %i[otf ttf]
|
|
116
|
+
convert_otf_to_ttf(font)
|
|
117
|
+
when %i[cff2 ttf]
|
|
118
|
+
# CFF2 to TTF - treat CFF2 similar to OTF for now
|
|
119
|
+
convert_otf_to_ttf(font)
|
|
120
|
+
when %i[ttf cff2]
|
|
121
|
+
# TTF to CFF2 - for variable fonts
|
|
122
|
+
convert_ttf_to_otf(font, options)
|
|
123
|
+
else
|
|
124
|
+
raise Fontisan::Error,
|
|
125
|
+
"Unsupported conversion: #{source_format} → #{target_format}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Convert TrueType font to OpenType/CFF
|
|
130
|
+
#
|
|
131
|
+
# @param font [TrueTypeFont] Source font
|
|
132
|
+
# @param options [Hash] Conversion options (currently unused)
|
|
133
|
+
# @return [Hash<String, String>] Target tables
|
|
134
|
+
def convert_ttf_to_otf(font, options = {})
|
|
135
|
+
# Extract all glyphs from glyf table
|
|
136
|
+
outlines = extract_ttf_outlines(font)
|
|
137
|
+
|
|
138
|
+
# Extract hints if preservation is enabled
|
|
139
|
+
hints_per_glyph = @preserve_hints ? extract_ttf_hints(font) : {}
|
|
140
|
+
|
|
141
|
+
# Build CFF table from outlines and hints
|
|
142
|
+
cff_data = build_cff_table(outlines, font, hints_per_glyph)
|
|
143
|
+
|
|
144
|
+
# Copy all tables except glyf/loca
|
|
145
|
+
tables = copy_tables(font, %w[glyf loca])
|
|
146
|
+
|
|
147
|
+
# Add CFF table
|
|
148
|
+
tables["CFF "] = cff_data
|
|
149
|
+
|
|
150
|
+
# Update maxp table for CFF
|
|
151
|
+
tables["maxp"] = update_maxp_for_cff(font, outlines.length)
|
|
152
|
+
|
|
153
|
+
# Update head table for CFF
|
|
154
|
+
tables["head"] = update_head_for_cff(font)
|
|
155
|
+
|
|
156
|
+
tables
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Convert OpenType/CFF font to TrueType
|
|
160
|
+
#
|
|
161
|
+
# @param font [OpenTypeFont] Source font
|
|
162
|
+
# @return [Hash<String, String>] Target tables
|
|
163
|
+
def convert_otf_to_ttf(font)
|
|
164
|
+
# Extract all glyphs from CFF table
|
|
165
|
+
outlines = extract_cff_outlines(font)
|
|
166
|
+
|
|
167
|
+
# Extract hints if preservation is enabled
|
|
168
|
+
hints_per_glyph = @preserve_hints ? extract_cff_hints(font) : {}
|
|
169
|
+
|
|
170
|
+
# Build glyf and loca tables
|
|
171
|
+
glyf_data, loca_data, loca_format = build_glyf_loca_tables(outlines, hints_per_glyph)
|
|
172
|
+
|
|
173
|
+
# Copy all tables except CFF
|
|
174
|
+
tables = copy_tables(font, ["CFF ", "CFF2"])
|
|
175
|
+
|
|
176
|
+
# Add glyf and loca tables
|
|
177
|
+
tables["glyf"] = glyf_data
|
|
178
|
+
tables["loca"] = loca_data
|
|
179
|
+
|
|
180
|
+
# Update maxp table for TrueType
|
|
181
|
+
tables["maxp"] = update_maxp_for_truetype(font, outlines, loca_format)
|
|
182
|
+
|
|
183
|
+
# Update head table for TrueType
|
|
184
|
+
tables["head"] = update_head_for_truetype(font, loca_format)
|
|
185
|
+
|
|
186
|
+
tables
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Convert TrueType font to OpenType/CFF
|
|
190
|
+
#
|
|
191
|
+
# @return [Hash<String, String>] Target tables
|
|
192
|
+
def ttf_to_otf
|
|
193
|
+
raise Fontisan::Error, "No font loaded" unless @font
|
|
194
|
+
|
|
195
|
+
convert_ttf_to_otf(@font)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Convert OpenType/CFF font to TrueType
|
|
199
|
+
#
|
|
200
|
+
# @return [Hash<String, String>] Target tables
|
|
201
|
+
def otf_to_ttf
|
|
202
|
+
raise Fontisan::Error, "No font loaded" unless @font
|
|
203
|
+
|
|
204
|
+
convert_otf_to_ttf(@font)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Get supported conversions
|
|
208
|
+
#
|
|
209
|
+
# @return [Array<Array<Symbol>>] Supported conversion pairs
|
|
210
|
+
def supported_conversions
|
|
211
|
+
[
|
|
212
|
+
%i[ttf otf],
|
|
213
|
+
%i[otf ttf],
|
|
214
|
+
%i[cff2 ttf],
|
|
215
|
+
%i[ttf cff2],
|
|
216
|
+
]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Validate font for conversion
|
|
220
|
+
#
|
|
221
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font to validate
|
|
222
|
+
# @param target_format [Symbol] Target format
|
|
223
|
+
# @return [Boolean] True if valid
|
|
224
|
+
# @raise [ArgumentError] If font is invalid
|
|
225
|
+
# @raise [Error] If conversion is not supported
|
|
226
|
+
def validate(font, target_format)
|
|
227
|
+
raise ArgumentError, "Font cannot be nil" if font.nil?
|
|
228
|
+
|
|
229
|
+
unless font.respond_to?(:tables)
|
|
230
|
+
raise ArgumentError, "Font must respond to :tables"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
unless font.respond_to?(:table)
|
|
234
|
+
raise ArgumentError, "Font must respond to :table"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
source_format = detect_format(font)
|
|
238
|
+
unless supports?(source_format, target_format)
|
|
239
|
+
raise Fontisan::Error,
|
|
240
|
+
"Conversion #{source_format} → #{target_format} not supported"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Check that source font has required tables
|
|
244
|
+
validate_source_tables(font, source_format)
|
|
245
|
+
|
|
246
|
+
true
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Extract outlines from TrueType font
|
|
250
|
+
#
|
|
251
|
+
# @param font [TrueTypeFont] Source font
|
|
252
|
+
# @return [Array<Outline>] Array of outline objects
|
|
253
|
+
def extract_ttf_outlines(font)
|
|
254
|
+
# Get required tables
|
|
255
|
+
head = font.table("head")
|
|
256
|
+
maxp = font.table("maxp")
|
|
257
|
+
loca = font.table("loca")
|
|
258
|
+
glyf = font.table("glyf")
|
|
259
|
+
|
|
260
|
+
# Parse loca with context
|
|
261
|
+
loca.parse_with_context(head.index_to_loc_format, maxp.num_glyphs)
|
|
262
|
+
|
|
263
|
+
# Create resolver for compound glyphs
|
|
264
|
+
resolver = Tables::CompoundGlyphResolver.new(glyf, loca, head)
|
|
265
|
+
|
|
266
|
+
# Extract all glyphs
|
|
267
|
+
outlines = []
|
|
268
|
+
maxp.num_glyphs.times do |glyph_id|
|
|
269
|
+
glyph = glyf.glyph_for(glyph_id, loca, head)
|
|
270
|
+
|
|
271
|
+
outlines << if glyph.nil? || glyph.empty?
|
|
272
|
+
# Empty glyph - create empty outline
|
|
273
|
+
Models::Outline.new(
|
|
274
|
+
glyph_id: glyph_id,
|
|
275
|
+
commands: [],
|
|
276
|
+
bbox: { x_min: 0, y_min: 0, x_max: 0, y_max: 0 },
|
|
277
|
+
)
|
|
278
|
+
elsif glyph.simple?
|
|
279
|
+
# Convert simple glyph to outline
|
|
280
|
+
Models::Outline.from_truetype(glyph, glyph_id)
|
|
281
|
+
else
|
|
282
|
+
# Compound glyph - resolve to simple outline
|
|
283
|
+
resolver.resolve(glyph)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
outlines
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Extract outlines from CFF font
|
|
291
|
+
#
|
|
292
|
+
# @param font [OpenTypeFont] Source font
|
|
293
|
+
# @return [Array<Outline>] Array of outline objects
|
|
294
|
+
def extract_cff_outlines(font)
|
|
295
|
+
# Get CFF table
|
|
296
|
+
cff = font.table("CFF ")
|
|
297
|
+
raise Fontisan::Error, "CFF table not found" unless cff
|
|
298
|
+
|
|
299
|
+
# Get number of glyphs
|
|
300
|
+
num_glyphs = cff.glyph_count
|
|
301
|
+
|
|
302
|
+
# Extract all glyphs
|
|
303
|
+
outlines = []
|
|
304
|
+
num_glyphs.times do |glyph_id|
|
|
305
|
+
charstring = cff.charstring_for_glyph(glyph_id)
|
|
306
|
+
|
|
307
|
+
outlines << if charstring.nil? || charstring.path.empty?
|
|
308
|
+
# Empty glyph
|
|
309
|
+
Models::Outline.new(
|
|
310
|
+
glyph_id: glyph_id,
|
|
311
|
+
commands: [],
|
|
312
|
+
bbox: { x_min: 0, y_min: 0, x_max: 0, y_max: 0 },
|
|
313
|
+
)
|
|
314
|
+
else
|
|
315
|
+
# Convert CharString to outline
|
|
316
|
+
Models::Outline.from_cff(charstring, glyph_id)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
outlines
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Build CFF table from outlines
|
|
324
|
+
#
|
|
325
|
+
# @param outlines [Array<Outline>] Glyph outlines
|
|
326
|
+
# @param font [TrueTypeFont] Source font (for metadata)
|
|
327
|
+
# @return [String] CFF table binary data
|
|
328
|
+
def build_cff_table(outlines, font, hints_per_glyph)
|
|
329
|
+
# Build CharStrings INDEX from outlines
|
|
330
|
+
begin
|
|
331
|
+
charstrings = outlines.map do |outline|
|
|
332
|
+
builder = Tables::Cff::CharStringBuilder.new
|
|
333
|
+
if outline.empty?
|
|
334
|
+
builder.build_empty
|
|
335
|
+
else
|
|
336
|
+
builder.build(outline)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
rescue StandardError => e
|
|
340
|
+
raise Fontisan::Error, "Failed to build CharStrings: #{e.message}"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Apply subroutine optimization if enabled
|
|
344
|
+
local_subrs = []
|
|
345
|
+
|
|
346
|
+
if @optimize_cff
|
|
347
|
+
begin
|
|
348
|
+
charstrings, local_subrs = optimize_charstrings(charstrings)
|
|
349
|
+
rescue StandardError => e
|
|
350
|
+
# If optimization fails, fall back to unoptimized CharStrings
|
|
351
|
+
warn "CFF optimization failed: #{e.message}, using unoptimized CharStrings"
|
|
352
|
+
local_subrs = []
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Build font metadata
|
|
357
|
+
begin
|
|
358
|
+
font_name = extract_font_name(font)
|
|
359
|
+
rescue StandardError => e
|
|
360
|
+
raise Fontisan::Error, "Failed to extract font name: #{e.message}"
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Build all INDEXes
|
|
364
|
+
begin
|
|
365
|
+
header_size = 4
|
|
366
|
+
name_index_data = Tables::Cff::IndexBuilder.build([font_name])
|
|
367
|
+
string_index_data = Tables::Cff::IndexBuilder.build([]) # Empty strings
|
|
368
|
+
global_subr_index_data = Tables::Cff::IndexBuilder.build([]) # Empty global subrs
|
|
369
|
+
charstrings_index_data = Tables::Cff::IndexBuilder.build(charstrings)
|
|
370
|
+
local_subrs_index_data = Tables::Cff::IndexBuilder.build(local_subrs)
|
|
371
|
+
rescue StandardError => e
|
|
372
|
+
raise Fontisan::Error, "Failed to build CFF indexes: #{e.message}"
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Build Private DICT with Subrs offset if we have local subroutines
|
|
376
|
+
begin
|
|
377
|
+
private_dict_hash = {
|
|
378
|
+
default_width_x: 1000,
|
|
379
|
+
nominal_width_x: 0,
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
# If we have local subroutines, add Subrs offset
|
|
383
|
+
# Subrs offset is relative to Private DICT start
|
|
384
|
+
if local_subrs.any?
|
|
385
|
+
# Calculate size of Private DICT itself to know where Subrs starts
|
|
386
|
+
temp_private_dict_data = Tables::Cff::DictBuilder.build(private_dict_hash)
|
|
387
|
+
subrs_offset = temp_private_dict_data.bytesize
|
|
388
|
+
|
|
389
|
+
# Add Subrs offset to DICT
|
|
390
|
+
private_dict_hash[:subrs] = subrs_offset
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Build final Private DICT
|
|
394
|
+
private_dict_data = Tables::Cff::DictBuilder.build(private_dict_hash)
|
|
395
|
+
private_dict_size = private_dict_data.bytesize
|
|
396
|
+
rescue StandardError => e
|
|
397
|
+
raise Fontisan::Error, "Failed to build Private DICT: #{e.message}"
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Calculate offsets with iterative refinement
|
|
401
|
+
begin
|
|
402
|
+
# Initial pass
|
|
403
|
+
top_dict_index_start = header_size + name_index_data.bytesize
|
|
404
|
+
string_index_start = top_dict_index_start + 100 # Approximate
|
|
405
|
+
global_subr_index_start = string_index_start + string_index_data.bytesize
|
|
406
|
+
charstrings_offset = global_subr_index_start + global_subr_index_data.bytesize
|
|
407
|
+
|
|
408
|
+
# Build Top DICT
|
|
409
|
+
top_dict_hash = {
|
|
410
|
+
charset: 0,
|
|
411
|
+
encoding: 0,
|
|
412
|
+
charstrings: charstrings_offset,
|
|
413
|
+
}
|
|
414
|
+
top_dict_data = Tables::Cff::DictBuilder.build(top_dict_hash)
|
|
415
|
+
top_dict_index_data = Tables::Cff::IndexBuilder.build([top_dict_data])
|
|
416
|
+
|
|
417
|
+
# Recalculate with actual Top DICT size
|
|
418
|
+
string_index_start = top_dict_index_start + top_dict_index_data.bytesize
|
|
419
|
+
global_subr_index_start = string_index_start + string_index_data.bytesize
|
|
420
|
+
charstrings_offset = global_subr_index_start + global_subr_index_data.bytesize
|
|
421
|
+
private_dict_offset = charstrings_offset + charstrings_index_data.bytesize
|
|
422
|
+
|
|
423
|
+
# Update Top DICT with Private DICT info
|
|
424
|
+
top_dict_hash = {
|
|
425
|
+
charset: 0,
|
|
426
|
+
encoding: 0,
|
|
427
|
+
charstrings: charstrings_offset,
|
|
428
|
+
private: [private_dict_size, private_dict_offset],
|
|
429
|
+
}
|
|
430
|
+
top_dict_data = Tables::Cff::DictBuilder.build(top_dict_hash)
|
|
431
|
+
top_dict_index_data = Tables::Cff::IndexBuilder.build([top_dict_data])
|
|
432
|
+
|
|
433
|
+
# Final recalculation
|
|
434
|
+
string_index_start = top_dict_index_start + top_dict_index_data.bytesize
|
|
435
|
+
global_subr_index_start = string_index_start + string_index_data.bytesize
|
|
436
|
+
charstrings_offset = global_subr_index_start + global_subr_index_data.bytesize
|
|
437
|
+
private_dict_offset = charstrings_offset + charstrings_index_data.bytesize
|
|
438
|
+
|
|
439
|
+
# Final Top DICT
|
|
440
|
+
top_dict_hash = {
|
|
441
|
+
charset: 0,
|
|
442
|
+
encoding: 0,
|
|
443
|
+
charstrings: charstrings_offset,
|
|
444
|
+
private: [private_dict_size, private_dict_offset],
|
|
445
|
+
}
|
|
446
|
+
top_dict_data = Tables::Cff::DictBuilder.build(top_dict_hash)
|
|
447
|
+
top_dict_index_data = Tables::Cff::IndexBuilder.build([top_dict_data])
|
|
448
|
+
rescue StandardError => e
|
|
449
|
+
raise Fontisan::Error, "Failed to calculate CFF table offsets: #{e.message}"
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Build CFF Header
|
|
453
|
+
begin
|
|
454
|
+
header = [
|
|
455
|
+
1, # major version
|
|
456
|
+
0, # minor version
|
|
457
|
+
4, # header size
|
|
458
|
+
4, # offSize (will be in INDEX)
|
|
459
|
+
].pack("C4")
|
|
460
|
+
rescue StandardError => e
|
|
461
|
+
raise Fontisan::Error, "Failed to build CFF header: #{e.message}"
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Assemble complete CFF table
|
|
465
|
+
begin
|
|
466
|
+
header +
|
|
467
|
+
name_index_data +
|
|
468
|
+
top_dict_index_data +
|
|
469
|
+
string_index_data +
|
|
470
|
+
global_subr_index_data +
|
|
471
|
+
charstrings_index_data +
|
|
472
|
+
private_dict_data +
|
|
473
|
+
local_subrs_index_data
|
|
474
|
+
rescue StandardError => e
|
|
475
|
+
raise Fontisan::Error, "Failed to assemble CFF table: #{e.message}"
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Build glyf and loca tables from outlines
|
|
480
|
+
#
|
|
481
|
+
# @param outlines [Array<Outline>] Glyph outlines
|
|
482
|
+
# @return [Array<String, String, Integer>] [glyf_data, loca_data, loca_format]
|
|
483
|
+
def build_glyf_loca_tables(outlines, hints_per_glyph)
|
|
484
|
+
glyf_data = "".b
|
|
485
|
+
offsets = []
|
|
486
|
+
|
|
487
|
+
# Build each glyph
|
|
488
|
+
outlines.each do |outline|
|
|
489
|
+
offsets << glyf_data.bytesize
|
|
490
|
+
|
|
491
|
+
if outline.empty?
|
|
492
|
+
# Empty glyph - no data
|
|
493
|
+
next
|
|
494
|
+
end
|
|
495
|
+
|
|
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
|
|
509
|
+
glyf_data << glyph_data
|
|
510
|
+
|
|
511
|
+
# Add padding to 4-byte boundary
|
|
512
|
+
padding = (4 - (glyf_data.bytesize % 4)) % 4
|
|
513
|
+
glyf_data << ("\x00" * padding) if padding.positive?
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Add final offset
|
|
517
|
+
offsets << glyf_data.bytesize
|
|
518
|
+
|
|
519
|
+
# Build loca table
|
|
520
|
+
# Determine format based on max offset
|
|
521
|
+
max_offset = offsets.max
|
|
522
|
+
if max_offset <= 0x1FFFE
|
|
523
|
+
# Short format (offsets / 2)
|
|
524
|
+
loca_format = 0
|
|
525
|
+
loca_data = offsets.map { |off| off / 2 }.pack("n*")
|
|
526
|
+
else
|
|
527
|
+
# Long format
|
|
528
|
+
loca_format = 1
|
|
529
|
+
loca_data = offsets.pack("N*")
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
[glyf_data, loca_data, loca_format]
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# Copy non-outline tables from source to target
|
|
536
|
+
#
|
|
537
|
+
# @param font [TrueTypeFont, OpenTypeFont] Source font
|
|
538
|
+
# @param exclude_tags [Array<String>] Tags to exclude
|
|
539
|
+
# @return [Hash<String, String>] Copied tables
|
|
540
|
+
def copy_tables(font, exclude_tags = [])
|
|
541
|
+
tables = {}
|
|
542
|
+
|
|
543
|
+
font.table_data.each do |tag, data|
|
|
544
|
+
next if exclude_tags.include?(tag)
|
|
545
|
+
|
|
546
|
+
tables[tag] = data if data
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
tables
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Update maxp table for CFF format
|
|
553
|
+
#
|
|
554
|
+
# @param font [TrueTypeFont] Source font
|
|
555
|
+
# @param num_glyphs [Integer] Number of glyphs
|
|
556
|
+
# @return [String] Updated maxp table binary data
|
|
557
|
+
def update_maxp_for_cff(_font, num_glyphs)
|
|
558
|
+
# CFF uses maxp version 0.5 (0x00005000)
|
|
559
|
+
# Structure: version (4 bytes) + numGlyphs (2 bytes)
|
|
560
|
+
[Tables::Maxp::VERSION_0_5, num_glyphs].pack("Nn")
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Update maxp table for TrueType format
|
|
564
|
+
#
|
|
565
|
+
# @param font [OpenTypeFont] Source font
|
|
566
|
+
# @param outlines [Array<Outline>] Glyph outlines
|
|
567
|
+
# @param loca_format [Integer] Loca format (0 or 1)
|
|
568
|
+
# @return [String] Updated maxp table binary data
|
|
569
|
+
def update_maxp_for_truetype(font, outlines, _loca_format)
|
|
570
|
+
# Get source maxp
|
|
571
|
+
font.table("maxp")
|
|
572
|
+
num_glyphs = outlines.length
|
|
573
|
+
|
|
574
|
+
# Calculate statistics from outlines
|
|
575
|
+
max_points = 0
|
|
576
|
+
max_contours = 0
|
|
577
|
+
|
|
578
|
+
outlines.each do |outline|
|
|
579
|
+
next if outline.empty?
|
|
580
|
+
|
|
581
|
+
contours = outline.to_truetype_contours
|
|
582
|
+
max_contours = [max_contours, contours.length].max
|
|
583
|
+
|
|
584
|
+
contours.each do |contour|
|
|
585
|
+
max_points = [max_points, contour.length].max
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# Build maxp v1.0 table
|
|
590
|
+
# We'll use conservative defaults for instruction-related fields
|
|
591
|
+
[
|
|
592
|
+
Tables::Maxp::VERSION_1_0, # version
|
|
593
|
+
num_glyphs, # numGlyphs
|
|
594
|
+
max_points, # maxPoints
|
|
595
|
+
max_contours, # maxContours
|
|
596
|
+
0, # maxCompositePoints
|
|
597
|
+
0, # maxCompositeContours
|
|
598
|
+
2, # maxZones
|
|
599
|
+
0, # maxTwilightPoints
|
|
600
|
+
0, # maxStorage
|
|
601
|
+
0, # maxFunctionDefs
|
|
602
|
+
0, # maxInstructionDefs
|
|
603
|
+
0, # maxStackElements
|
|
604
|
+
0, # maxSizeOfInstructions
|
|
605
|
+
0, # maxComponentElements
|
|
606
|
+
0, # maxComponentDepth
|
|
607
|
+
].pack("Nnnnnnnnnnnnnnn")
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# Update head table for CFF format
|
|
611
|
+
#
|
|
612
|
+
# @param font [TrueTypeFont] Source font
|
|
613
|
+
# @return [String] Updated head table binary data
|
|
614
|
+
def update_head_for_cff(font)
|
|
615
|
+
font.table("head")
|
|
616
|
+
head_data = font.table_data["head"].dup
|
|
617
|
+
|
|
618
|
+
# For CFF fonts, indexToLocFormat is not relevant
|
|
619
|
+
# but we'll set it to 0 for consistency
|
|
620
|
+
# indexToLocFormat is at offset 50 (2 bytes)
|
|
621
|
+
head_data[50, 2] = [0].pack("n")
|
|
622
|
+
|
|
623
|
+
head_data
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# Update head table for TrueType format
|
|
627
|
+
#
|
|
628
|
+
# @param font [OpenTypeFont] Source font
|
|
629
|
+
# @param loca_format [Integer] Loca format (0=short, 1=long)
|
|
630
|
+
# @return [String] Updated head table binary data
|
|
631
|
+
def update_head_for_truetype(font, loca_format)
|
|
632
|
+
font.table("head")
|
|
633
|
+
head_data = font.table_data["head"].dup
|
|
634
|
+
|
|
635
|
+
# Set indexToLocFormat at offset 50 (2 bytes)
|
|
636
|
+
head_data[50, 2] = [loca_format].pack("n")
|
|
637
|
+
|
|
638
|
+
head_data
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# Extract font name from name table
|
|
642
|
+
#
|
|
643
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font
|
|
644
|
+
# @return [String] Font name
|
|
645
|
+
def extract_font_name(font)
|
|
646
|
+
name_table = font.table("name")
|
|
647
|
+
if name_table
|
|
648
|
+
font_name = name_table.english_name(Tables::Name::FAMILY)
|
|
649
|
+
return font_name.dup.force_encoding("ASCII-8BIT") if font_name
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
"UnnamedFont"
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# Optimize CharStrings using subroutine extraction
|
|
656
|
+
#
|
|
657
|
+
# @param charstrings [Array<String>] Original CharString bytes
|
|
658
|
+
# @return [Array<Array<String>, Array<String>>] [optimized_charstrings, local_subrs]
|
|
659
|
+
def optimize_charstrings(charstrings)
|
|
660
|
+
# Convert to hash format expected by PatternAnalyzer
|
|
661
|
+
charstrings_hash = {}
|
|
662
|
+
charstrings.each_with_index do |cs, index|
|
|
663
|
+
charstrings_hash[index] = cs
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# Analyze patterns
|
|
667
|
+
analyzer = Optimizers::PatternAnalyzer.new(
|
|
668
|
+
min_length: 10,
|
|
669
|
+
stack_aware: true
|
|
670
|
+
)
|
|
671
|
+
patterns = analyzer.analyze(charstrings_hash)
|
|
672
|
+
|
|
673
|
+
# Return original if no patterns found
|
|
674
|
+
return [charstrings, []] if patterns.empty?
|
|
675
|
+
|
|
676
|
+
# Optimize selection
|
|
677
|
+
optimizer = Optimizers::SubroutineOptimizer.new(patterns, max_subrs: 65_535)
|
|
678
|
+
selected_patterns = optimizer.optimize_selection
|
|
679
|
+
|
|
680
|
+
# Optimize ordering
|
|
681
|
+
selected_patterns = optimizer.optimize_ordering(selected_patterns)
|
|
682
|
+
|
|
683
|
+
# Return original if no patterns selected
|
|
684
|
+
return [charstrings, []] if selected_patterns.empty?
|
|
685
|
+
|
|
686
|
+
# Build subroutines
|
|
687
|
+
builder = Optimizers::SubroutineBuilder.new(selected_patterns, type: :local)
|
|
688
|
+
local_subrs = builder.build
|
|
689
|
+
|
|
690
|
+
# Build subroutine map
|
|
691
|
+
subroutine_map = {}
|
|
692
|
+
selected_patterns.each_with_index do |pattern, index|
|
|
693
|
+
subroutine_map[pattern.bytes] = index
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
# Rewrite CharStrings
|
|
697
|
+
rewriter = Optimizers::CharstringRewriter.new(subroutine_map, builder)
|
|
698
|
+
optimized_charstrings = charstrings.map.with_index do |charstring, glyph_id|
|
|
699
|
+
# Find patterns for this glyph
|
|
700
|
+
glyph_patterns = selected_patterns.select { |p| p.glyphs.include?(glyph_id) }
|
|
701
|
+
|
|
702
|
+
if glyph_patterns.empty?
|
|
703
|
+
charstring
|
|
704
|
+
else
|
|
705
|
+
rewriter.rewrite(charstring, glyph_patterns)
|
|
706
|
+
end
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
[optimized_charstrings, local_subrs]
|
|
710
|
+
rescue StandardError => e
|
|
711
|
+
# If optimization fails for any reason, return original CharStrings
|
|
712
|
+
warn "Optimization warning: #{e.message}"
|
|
713
|
+
[charstrings, []]
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# Generate static instance from variable font
|
|
717
|
+
#
|
|
718
|
+
# @param font [TrueTypeFont, OpenTypeFont] Variable font
|
|
719
|
+
# @param source_format [Symbol] Source format
|
|
720
|
+
# @param target_format [Symbol] Target format
|
|
721
|
+
# @return [Hash<String, String>] Static font tables
|
|
722
|
+
def generate_static_instance(font, source_format, target_format)
|
|
723
|
+
# Generate instance at specified coordinates
|
|
724
|
+
fvar = font.table("fvar")
|
|
725
|
+
axes = fvar ? fvar.axes : []
|
|
726
|
+
|
|
727
|
+
generator = Variation::InstanceGenerator.new(font, @instance_coordinates)
|
|
728
|
+
instance_tables = generator.generate
|
|
729
|
+
|
|
730
|
+
# If target format differs from source, convert outlines
|
|
731
|
+
if source_format != target_format
|
|
732
|
+
# Create temporary font with instance tables
|
|
733
|
+
temp_font = font.class.new
|
|
734
|
+
temp_font.instance_variable_set(:@table_data, instance_tables)
|
|
735
|
+
|
|
736
|
+
# Convert outline format
|
|
737
|
+
case [source_format, target_format]
|
|
738
|
+
when %i[ttf otf]
|
|
739
|
+
convert_ttf_to_otf(temp_font, @options)
|
|
740
|
+
when %i[otf ttf], %i[cff2 ttf]
|
|
741
|
+
convert_otf_to_ttf(temp_font)
|
|
742
|
+
else
|
|
743
|
+
instance_tables
|
|
744
|
+
end
|
|
745
|
+
else
|
|
746
|
+
instance_tables
|
|
747
|
+
end
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
# Convert variation data during outline conversion
|
|
751
|
+
#
|
|
752
|
+
# @param font [TrueTypeFont, OpenTypeFont] Source font
|
|
753
|
+
# @param target_format [Symbol] Target format
|
|
754
|
+
# @return [Hash, nil] Converted variation data or nil
|
|
755
|
+
def convert_variations(font, target_format)
|
|
756
|
+
return nil unless @preserve_variations
|
|
757
|
+
return nil unless variable_font?(font)
|
|
758
|
+
|
|
759
|
+
fvar = font.table("fvar")
|
|
760
|
+
return nil unless fvar
|
|
761
|
+
|
|
762
|
+
axes = fvar.axes
|
|
763
|
+
converter = Variation::Converter.new(font, axes)
|
|
764
|
+
|
|
765
|
+
# Get glyph count
|
|
766
|
+
maxp = font.table("maxp")
|
|
767
|
+
return nil unless maxp
|
|
768
|
+
|
|
769
|
+
glyph_count = maxp.num_glyphs
|
|
770
|
+
|
|
771
|
+
# Convert variation data for each glyph
|
|
772
|
+
variation_data = {}
|
|
773
|
+
glyph_count.times do |glyph_id|
|
|
774
|
+
source_format = detect_format(font)
|
|
775
|
+
|
|
776
|
+
data = case [source_format, target_format]
|
|
777
|
+
when %i[ttf otf], %i[ttf cff2]
|
|
778
|
+
# gvar → blend
|
|
779
|
+
converter.gvar_to_blend(glyph_id)
|
|
780
|
+
when %i[otf ttf], %i[cff2 ttf]
|
|
781
|
+
# blend → gvar
|
|
782
|
+
converter.blend_to_gvar(glyph_id)
|
|
783
|
+
else
|
|
784
|
+
nil
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
variation_data[glyph_id] = data if data
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
variation_data.empty? ? nil : variation_data
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# Detect font format from tables
|
|
794
|
+
#
|
|
795
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font to detect
|
|
796
|
+
# @return [Symbol] Format (:ttf, :otf, or :cff2)
|
|
797
|
+
# @raise [Error] If format cannot be detected
|
|
798
|
+
def detect_format(font)
|
|
799
|
+
# Check for CFF2 table first (OpenType variable fonts with CFF2 outlines)
|
|
800
|
+
if font.has_table?("CFF2")
|
|
801
|
+
:cff2
|
|
802
|
+
# Check for CFF table (OpenType/CFF)
|
|
803
|
+
elsif font.has_table?("CFF ")
|
|
804
|
+
:otf
|
|
805
|
+
# Check for glyf table (TrueType)
|
|
806
|
+
elsif font.has_table?("glyf")
|
|
807
|
+
:ttf
|
|
808
|
+
else
|
|
809
|
+
raise Fontisan::Error,
|
|
810
|
+
"Cannot detect font format: missing outline tables (CFF2, CFF, or glyf)"
|
|
811
|
+
end
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
# Detect target format as opposite of source
|
|
815
|
+
#
|
|
816
|
+
# @param font [TrueTypeFont, OpenTypeFont] Source font
|
|
817
|
+
# @return [Symbol] Target format
|
|
818
|
+
def detect_target_format(font)
|
|
819
|
+
source = detect_format(font)
|
|
820
|
+
case source
|
|
821
|
+
when :ttf
|
|
822
|
+
:otf
|
|
823
|
+
when :cff2
|
|
824
|
+
:ttf
|
|
825
|
+
else
|
|
826
|
+
:ttf
|
|
827
|
+
end
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# Validate source font has required tables
|
|
831
|
+
#
|
|
832
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font to validate
|
|
833
|
+
# @param format [Symbol] Font format
|
|
834
|
+
# @raise [Error] If required tables are missing
|
|
835
|
+
def validate_source_tables(font, format)
|
|
836
|
+
case format
|
|
837
|
+
when :ttf
|
|
838
|
+
unless font.has_table?("glyf") && font.has_table?("loca") &&
|
|
839
|
+
font.table("glyf") && font.table("loca")
|
|
840
|
+
raise Fontisan::MissingTableError,
|
|
841
|
+
"TrueType font missing required glyf or loca table"
|
|
842
|
+
end
|
|
843
|
+
when :cff2
|
|
844
|
+
unless font.has_table?("CFF2") && font.table("CFF2")
|
|
845
|
+
raise Fontisan::MissingTableError,
|
|
846
|
+
"CFF2 font missing required CFF2 table"
|
|
847
|
+
end
|
|
848
|
+
when :otf
|
|
849
|
+
unless (font.has_table?("CFF ") && font.table("CFF ")) ||
|
|
850
|
+
(font.has_table?("CFF2") && font.table("CFF2"))
|
|
851
|
+
raise Fontisan::MissingTableError,
|
|
852
|
+
"OpenType font missing required CFF or CFF2 table"
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# Common required tables
|
|
857
|
+
%w[head hhea maxp].each do |tag|
|
|
858
|
+
unless font.table(tag)
|
|
859
|
+
raise Fontisan::MissingTableError,
|
|
860
|
+
"Font missing required #{tag} table"
|
|
861
|
+
end
|
|
862
|
+
end
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
# Extract hints from TrueType font
|
|
866
|
+
#
|
|
867
|
+
# @param font [TrueTypeFont] Source font
|
|
868
|
+
# @return [Hash<Integer, Array<Hint>>] Map of glyph ID to hints
|
|
869
|
+
def extract_ttf_hints(font)
|
|
870
|
+
hints_per_glyph = {}
|
|
871
|
+
extractor = Hints::TrueTypeHintExtractor.new
|
|
872
|
+
|
|
873
|
+
# Get required tables
|
|
874
|
+
head = font.table("head")
|
|
875
|
+
maxp = font.table("maxp")
|
|
876
|
+
loca = font.table("loca")
|
|
877
|
+
glyf = font.table("glyf")
|
|
878
|
+
|
|
879
|
+
# Parse loca with context
|
|
880
|
+
loca.parse_with_context(head.index_to_loc_format, maxp.num_glyphs)
|
|
881
|
+
|
|
882
|
+
# Extract hints from each glyph
|
|
883
|
+
maxp.num_glyphs.times do |glyph_id|
|
|
884
|
+
glyph = glyf.glyph_for(glyph_id, loca, head)
|
|
885
|
+
next if glyph.nil? || glyph.empty?
|
|
886
|
+
|
|
887
|
+
hints = extractor.extract(glyph)
|
|
888
|
+
hints_per_glyph[glyph_id] = hints if hints.any?
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
hints_per_glyph
|
|
892
|
+
rescue StandardError => e
|
|
893
|
+
warn "Failed to extract TrueType hints: #{e.message}"
|
|
894
|
+
{}
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
# Extract hints from CFF font
|
|
898
|
+
#
|
|
899
|
+
# @param font [OpenTypeFont] Source font
|
|
900
|
+
# @return [Hash<Integer, Array<Hint>>] Map of glyph ID to hints
|
|
901
|
+
def extract_cff_hints(font)
|
|
902
|
+
hints_per_glyph = {}
|
|
903
|
+
extractor = Hints::PostScriptHintExtractor.new
|
|
904
|
+
|
|
905
|
+
# Get CFF table
|
|
906
|
+
cff = font.table("CFF ")
|
|
907
|
+
return {} unless cff
|
|
908
|
+
|
|
909
|
+
# Get number of glyphs
|
|
910
|
+
num_glyphs = cff.glyph_count
|
|
911
|
+
|
|
912
|
+
# Extract hints from each CharString
|
|
913
|
+
num_glyphs.times do |glyph_id|
|
|
914
|
+
charstring = cff.charstring_for_glyph(glyph_id)
|
|
915
|
+
next if charstring.nil?
|
|
916
|
+
|
|
917
|
+
hints = extractor.extract(charstring)
|
|
918
|
+
hints_per_glyph[glyph_id] = hints if hints.any?
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
hints_per_glyph
|
|
922
|
+
rescue StandardError => e
|
|
923
|
+
warn "Failed to extract CFF hints: #{e.message}"
|
|
924
|
+
{}
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
# Check if font is a variable font
|
|
928
|
+
#
|
|
929
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font to check
|
|
930
|
+
# @return [Boolean] True if font has variation tables
|
|
931
|
+
def variable_font?(font)
|
|
932
|
+
font.has_table?("fvar")
|
|
933
|
+
end
|
|
934
|
+
end
|
|
935
|
+
end
|
|
936
|
+
end
|