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,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../models/font_export"
|
|
4
|
+
require_relative "table_serializer"
|
|
5
|
+
require_relative "ttx_generator"
|
|
6
|
+
require_relative "transformers/font_to_ttx"
|
|
7
|
+
require_relative "../utilities/checksum_calculator"
|
|
8
|
+
|
|
9
|
+
module Fontisan
|
|
10
|
+
module Export
|
|
11
|
+
# Exporter orchestrates font export to YAML/JSON/TTX
|
|
12
|
+
#
|
|
13
|
+
# Main entry point for exporting fonts to debugging formats.
|
|
14
|
+
# Handles table extraction, serialization, and metadata generation.
|
|
15
|
+
#
|
|
16
|
+
# @example Exporting a font to YAML
|
|
17
|
+
# exporter = Exporter.new(font, "font.ttf")
|
|
18
|
+
# export = exporter.export(format: :yaml)
|
|
19
|
+
# File.write("font.yaml", export.to_yaml)
|
|
20
|
+
#
|
|
21
|
+
# @example Exporting to TTX format
|
|
22
|
+
# exporter = Exporter.new(font, "font.ttf")
|
|
23
|
+
# ttx_xml = exporter.to_ttx
|
|
24
|
+
# File.write("font.ttx", ttx_xml)
|
|
25
|
+
#
|
|
26
|
+
# @example Selective table export
|
|
27
|
+
# export = exporter.export(tables: ["head", "name", "cmap"])
|
|
28
|
+
class Exporter
|
|
29
|
+
# Initialize exporter
|
|
30
|
+
#
|
|
31
|
+
# @param font [TrueTypeFont, OpenTypeFont] The font to export
|
|
32
|
+
# @param source_path [String] Path to source font file
|
|
33
|
+
# @param options [Hash] Export options
|
|
34
|
+
# @option options [Symbol] :binary_format Format for binary data (:hex or :base64)
|
|
35
|
+
def initialize(font, source_path, options = {})
|
|
36
|
+
@font = font
|
|
37
|
+
@source_path = source_path
|
|
38
|
+
@binary_format = options.fetch(:binary_format, :hex)
|
|
39
|
+
@serializer = TableSerializer.new(binary_format: @binary_format)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Export font to FontExport model
|
|
43
|
+
#
|
|
44
|
+
# @param options [Hash] Export options
|
|
45
|
+
# @option options [Array<String>] :tables Specific tables to export (default: all)
|
|
46
|
+
# @option options [Symbol] :format Output format (:yaml, :json, or :ttx)
|
|
47
|
+
# @return [Models::FontExport, String] The export model or TTX XML string
|
|
48
|
+
def export(options = {})
|
|
49
|
+
format = options[:format] || :yaml
|
|
50
|
+
|
|
51
|
+
if format == :ttx
|
|
52
|
+
to_ttx(options)
|
|
53
|
+
else
|
|
54
|
+
export_to_model(options)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Export font and return as YAML string
|
|
59
|
+
#
|
|
60
|
+
# @param options [Hash] Export options
|
|
61
|
+
# @return [String] YAML representation
|
|
62
|
+
def to_yaml(options = {})
|
|
63
|
+
export_model = export_to_model(options)
|
|
64
|
+
export_model.to_yaml
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Export font and return as JSON string
|
|
68
|
+
#
|
|
69
|
+
# @param options [Hash] Export options
|
|
70
|
+
# @return [String] JSON representation
|
|
71
|
+
def to_json(options = {})
|
|
72
|
+
export_model = export_to_model(options)
|
|
73
|
+
export_model.to_json
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Export font and return as TTX XML string
|
|
77
|
+
#
|
|
78
|
+
# Uses model-based architecture with FontToTtx transformer
|
|
79
|
+
# and lutaml-model serialization.
|
|
80
|
+
#
|
|
81
|
+
# @param options [Hash] Export options
|
|
82
|
+
# @option options [Array<String>] :tables Specific tables to export
|
|
83
|
+
# @option options [Boolean] :pretty Pretty-print XML (default: true)
|
|
84
|
+
# @option options [Integer] :indent Indentation spaces (default: 2)
|
|
85
|
+
# @return [String] TTX XML representation
|
|
86
|
+
def to_ttx(options = {})
|
|
87
|
+
# Use new model-based architecture
|
|
88
|
+
transformer = Transformers::FontToTtx.new(@font)
|
|
89
|
+
ttx_model = transformer.transform(options)
|
|
90
|
+
|
|
91
|
+
# Let lutaml-model handle XML serialization
|
|
92
|
+
ttx_model.to_xml(
|
|
93
|
+
pretty: options.fetch(:pretty, true),
|
|
94
|
+
indent: options.fetch(:indent, 2),
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# Export to FontExport model
|
|
101
|
+
#
|
|
102
|
+
# @param options [Hash] Export options
|
|
103
|
+
# @return [Models::FontExport] The export model
|
|
104
|
+
def export_to_model(options = {})
|
|
105
|
+
table_list = options[:tables] || :all
|
|
106
|
+
|
|
107
|
+
export_model = Models::FontExport.new
|
|
108
|
+
export_model.metadata = build_metadata
|
|
109
|
+
export_model.header = build_header
|
|
110
|
+
|
|
111
|
+
tables_to_export = select_tables(table_list)
|
|
112
|
+
tables_to_export.each do |tag|
|
|
113
|
+
export_table(export_model, tag)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
export_model
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Build export metadata
|
|
120
|
+
#
|
|
121
|
+
# @return [Models::FontExport::Metadata]
|
|
122
|
+
def build_metadata
|
|
123
|
+
Models::FontExport::Metadata.new.tap do |meta|
|
|
124
|
+
meta.source_file = @source_path
|
|
125
|
+
meta.export_date = Time.now.utc.iso8601
|
|
126
|
+
meta.exporter_version = Fontisan::VERSION
|
|
127
|
+
meta.font_format = detect_font_format
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Build font header information
|
|
132
|
+
#
|
|
133
|
+
# @return [Models::FontExport::Header]
|
|
134
|
+
def build_header
|
|
135
|
+
Models::FontExport::Header.new.tap do |header|
|
|
136
|
+
header.sfnt_version = format_sfnt_version(@font.header.sfnt_version.to_i)
|
|
137
|
+
header.num_tables = @font.tables.size
|
|
138
|
+
header.search_range = @font.header.search_range.to_i
|
|
139
|
+
header.entry_selector = @font.header.entry_selector.to_i
|
|
140
|
+
header.range_shift = @font.header.range_shift.to_i
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Select tables to export
|
|
145
|
+
#
|
|
146
|
+
# @param table_list [Symbol, Array<String>] :all or list of table tags
|
|
147
|
+
# @return [Array<String>] Table tags to export
|
|
148
|
+
def select_tables(table_list)
|
|
149
|
+
if table_list == :all
|
|
150
|
+
@font.table_names
|
|
151
|
+
else
|
|
152
|
+
available = @font.table_names
|
|
153
|
+
requested = Array(table_list).map(&:to_s)
|
|
154
|
+
requested.select { |tag| available.include?(tag) }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Export a single table
|
|
159
|
+
#
|
|
160
|
+
# @param export_model [Models::FontExport] The export model
|
|
161
|
+
# @param tag [String] The table tag
|
|
162
|
+
# @return [void]
|
|
163
|
+
def export_table(export_model, tag)
|
|
164
|
+
table = @font.table(tag)
|
|
165
|
+
return unless table
|
|
166
|
+
|
|
167
|
+
checksum = calculate_table_checksum(tag)
|
|
168
|
+
serialized = @serializer.serialize(table, tag)
|
|
169
|
+
|
|
170
|
+
export_model.add_table(
|
|
171
|
+
tag: tag,
|
|
172
|
+
checksum: format_checksum(checksum),
|
|
173
|
+
parsed: serialized[:parsed],
|
|
174
|
+
data: serialized[:data],
|
|
175
|
+
fields: serialized[:fields],
|
|
176
|
+
)
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
# If serialization fails, store as binary
|
|
179
|
+
export_binary_fallback(export_model, tag, e)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Export table as binary fallback on error
|
|
183
|
+
#
|
|
184
|
+
# @param export_model [Models::FontExport] The export model
|
|
185
|
+
# @param tag [String] The table tag
|
|
186
|
+
# @param error [StandardError] The error that occurred
|
|
187
|
+
# @return [void]
|
|
188
|
+
def export_binary_fallback(export_model, tag, error)
|
|
189
|
+
table = @font.table(tag)
|
|
190
|
+
binary_data = table.respond_to?(:to_binary_s) ? table.to_binary_s : ""
|
|
191
|
+
checksum = calculate_table_checksum(tag)
|
|
192
|
+
|
|
193
|
+
export_model.add_table(
|
|
194
|
+
tag: tag,
|
|
195
|
+
checksum: format_checksum(checksum),
|
|
196
|
+
parsed: false,
|
|
197
|
+
data: @serializer.send(:encode_binary, binary_data),
|
|
198
|
+
fields: { error: error.message }.to_json,
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Calculate table checksum
|
|
203
|
+
#
|
|
204
|
+
# @param tag [String] Table tag
|
|
205
|
+
# @return [Integer] Checksum value
|
|
206
|
+
def calculate_table_checksum(tag)
|
|
207
|
+
table_entry = @font.tables.find { |entry| entry.tag == tag }
|
|
208
|
+
return 0 unless table_entry
|
|
209
|
+
|
|
210
|
+
if table_entry.respond_to?(:checksum)
|
|
211
|
+
table_entry.checksum.to_i
|
|
212
|
+
else
|
|
213
|
+
# Calculate from binary data
|
|
214
|
+
table = @font.table(tag)
|
|
215
|
+
data = table.respond_to?(:to_binary_s) ? table.to_binary_s : ""
|
|
216
|
+
Utilities::ChecksumCalculator.calculate(data)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Format checksum as hex string
|
|
221
|
+
#
|
|
222
|
+
# @param checksum [Integer] Checksum value
|
|
223
|
+
# @return [String] Hex string (e.g., "0x12345678")
|
|
224
|
+
def format_checksum(checksum)
|
|
225
|
+
"0x#{checksum.to_s(16).upcase.rjust(8, '0')}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Format SFNT version
|
|
229
|
+
#
|
|
230
|
+
# @param version [Integer] SFNT version
|
|
231
|
+
# @return [String] Formatted version
|
|
232
|
+
def format_sfnt_version(version)
|
|
233
|
+
case version
|
|
234
|
+
when 0x00010000
|
|
235
|
+
"0x00010000 (TrueType)"
|
|
236
|
+
when 0x4F54544F # 'OTTO'
|
|
237
|
+
"0x4F54544F (OpenType/CFF)"
|
|
238
|
+
else
|
|
239
|
+
"0x#{version.to_s(16).upcase}"
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Detect font format
|
|
244
|
+
#
|
|
245
|
+
# @return [String] Font format name
|
|
246
|
+
def detect_font_format
|
|
247
|
+
case @font.class.name
|
|
248
|
+
when /TrueType/
|
|
249
|
+
"TrueType"
|
|
250
|
+
when /OpenType/
|
|
251
|
+
"OpenType"
|
|
252
|
+
when /Woff2/
|
|
253
|
+
"WOFF2"
|
|
254
|
+
when /Woff/
|
|
255
|
+
"WOFF"
|
|
256
|
+
else
|
|
257
|
+
"Unknown"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Export
|
|
8
|
+
# TableSerializer handles serialization of individual font tables
|
|
9
|
+
#
|
|
10
|
+
# Uses strategy pattern to serialize different table types:
|
|
11
|
+
# - Fully parsed tables: Use Lutaml::Model serialization
|
|
12
|
+
# - Binary tables: Encode as hex or base64
|
|
13
|
+
# - Special tables: Custom serialization logic
|
|
14
|
+
#
|
|
15
|
+
# @example Serializing a parsed table
|
|
16
|
+
# serializer = TableSerializer.new(binary_format: :hex)
|
|
17
|
+
# data = serializer.serialize(head_table, "head")
|
|
18
|
+
#
|
|
19
|
+
# @example Serializing binary table
|
|
20
|
+
# data = serializer.serialize_binary(raw_data, "DSIG")
|
|
21
|
+
class TableSerializer
|
|
22
|
+
# Tables that have full Lutaml::Model parsing support
|
|
23
|
+
FULLY_PARSED_TABLES = %w[
|
|
24
|
+
head hhea maxp post OS/2 name
|
|
25
|
+
fvar HVAR VVAR MVAR cvar gvar
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# Tables that should be stored as binary
|
|
29
|
+
BINARY_ONLY_TABLES = %w[
|
|
30
|
+
cvt fpgm prep gasp DSIG GDEF GPOS GSUB
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# Initialize table serializer
|
|
34
|
+
#
|
|
35
|
+
# @param binary_format [Symbol] Format for binary data (:hex or :base64)
|
|
36
|
+
def initialize(binary_format: :hex)
|
|
37
|
+
@binary_format = binary_format
|
|
38
|
+
validate_binary_format!
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Serialize a table to exportable format
|
|
42
|
+
#
|
|
43
|
+
# @param table [Object] The table object
|
|
44
|
+
# @param tag [String] The table tag
|
|
45
|
+
# @return [Hash] Serialized table data
|
|
46
|
+
def serialize(table, tag)
|
|
47
|
+
if fully_parsed?(tag)
|
|
48
|
+
serialize_parsed(table, tag)
|
|
49
|
+
elsif binary_only?(tag)
|
|
50
|
+
serialize_binary(table.to_binary_s, tag)
|
|
51
|
+
else
|
|
52
|
+
serialize_mixed(table, tag)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Serialize a parsed table
|
|
57
|
+
#
|
|
58
|
+
# @param table [Object] The table object with Lutaml::Model
|
|
59
|
+
# @param tag [String] The table tag
|
|
60
|
+
# @return [Hash] Serialized data with parsed flag
|
|
61
|
+
def serialize_parsed(table, tag)
|
|
62
|
+
fields = extract_fields(table)
|
|
63
|
+
{
|
|
64
|
+
tag: tag,
|
|
65
|
+
parsed: true,
|
|
66
|
+
fields: fields.to_json,
|
|
67
|
+
data: nil,
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Serialize a binary-only table
|
|
72
|
+
#
|
|
73
|
+
# @param data [String] Binary data
|
|
74
|
+
# @param tag [String] The table tag
|
|
75
|
+
# @return [Hash] Serialized data with binary content
|
|
76
|
+
def serialize_binary(data, tag)
|
|
77
|
+
encoded = encode_binary(data)
|
|
78
|
+
{
|
|
79
|
+
tag: tag,
|
|
80
|
+
parsed: false,
|
|
81
|
+
data: encoded,
|
|
82
|
+
fields: nil,
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Serialize tables with mixed content (summary + binary)
|
|
87
|
+
#
|
|
88
|
+
# @param table [Object] The table object
|
|
89
|
+
# @param tag [String] The table tag
|
|
90
|
+
# @return [Hash] Serialized data with both fields and binary
|
|
91
|
+
def serialize_mixed(table, tag)
|
|
92
|
+
summary = create_summary(table, tag)
|
|
93
|
+
binary = table.respond_to?(:to_binary_s) ? table.to_binary_s : ""
|
|
94
|
+
|
|
95
|
+
{
|
|
96
|
+
tag: tag,
|
|
97
|
+
parsed: true,
|
|
98
|
+
fields: summary.to_json,
|
|
99
|
+
data: encode_binary(binary),
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# Check if table is fully parsed
|
|
106
|
+
#
|
|
107
|
+
# @param tag [String] Table tag
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
def fully_parsed?(tag)
|
|
110
|
+
FULLY_PARSED_TABLES.include?(tag)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Check if table is binary-only
|
|
114
|
+
#
|
|
115
|
+
# @param tag [String] Table tag
|
|
116
|
+
# @return [Boolean]
|
|
117
|
+
def binary_only?(tag)
|
|
118
|
+
BINARY_ONLY_TABLES.include?(tag)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Extract fields from a parsed table
|
|
122
|
+
#
|
|
123
|
+
# @param table [Object] The table object
|
|
124
|
+
# @return [Hash] Field names and values
|
|
125
|
+
def extract_fields(table)
|
|
126
|
+
fields = {}
|
|
127
|
+
|
|
128
|
+
# Get all instance variables
|
|
129
|
+
table.instance_variables.each do |var|
|
|
130
|
+
name = var.to_s.delete("@")
|
|
131
|
+
value = table.instance_variable_get(var)
|
|
132
|
+
fields[name] = serialize_value(value)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
fields
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Serialize individual field value
|
|
139
|
+
#
|
|
140
|
+
# @param value [Object] The value to serialize
|
|
141
|
+
# @return [Object] Serialized value
|
|
142
|
+
def serialize_value(value)
|
|
143
|
+
case value
|
|
144
|
+
when Integer, Float, String, TrueClass, FalseClass, NilClass
|
|
145
|
+
value
|
|
146
|
+
when Array
|
|
147
|
+
value.map { |v| serialize_value(v) }
|
|
148
|
+
when Hash
|
|
149
|
+
value.transform_values { |v| serialize_value(v) }
|
|
150
|
+
when Time
|
|
151
|
+
value.iso8601
|
|
152
|
+
else
|
|
153
|
+
# For complex objects, try to extract fields
|
|
154
|
+
if value.respond_to?(:instance_variables)
|
|
155
|
+
extract_fields(value)
|
|
156
|
+
else
|
|
157
|
+
value.to_s
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Create summary for mixed-content tables
|
|
163
|
+
#
|
|
164
|
+
# @param table [Object] The table object
|
|
165
|
+
# @param tag [String] Table tag
|
|
166
|
+
# @return [Hash] Summary information
|
|
167
|
+
def create_summary(table, tag)
|
|
168
|
+
case tag
|
|
169
|
+
when "glyf"
|
|
170
|
+
create_glyf_summary(table)
|
|
171
|
+
when "loca"
|
|
172
|
+
create_loca_summary(table)
|
|
173
|
+
when "cmap"
|
|
174
|
+
create_cmap_summary(table)
|
|
175
|
+
when "CFF"
|
|
176
|
+
create_cff_summary(table)
|
|
177
|
+
else
|
|
178
|
+
{ type: "binary", size: table.to_binary_s.bytesize }
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Create glyf table summary
|
|
183
|
+
#
|
|
184
|
+
# @param table [Object] glyf table
|
|
185
|
+
# @return [Hash] Summary
|
|
186
|
+
def create_glyf_summary(table)
|
|
187
|
+
{
|
|
188
|
+
type: "glyf",
|
|
189
|
+
num_glyphs: table.respond_to?(:glyphs) ? table.glyphs.length : 0,
|
|
190
|
+
note: "Outline data stored as binary",
|
|
191
|
+
}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Create loca table summary
|
|
195
|
+
#
|
|
196
|
+
# @param table [Object] loca table
|
|
197
|
+
# @return [Hash] Summary
|
|
198
|
+
def create_loca_summary(table)
|
|
199
|
+
{
|
|
200
|
+
type: "loca",
|
|
201
|
+
num_offsets: table.respond_to?(:offsets) ? table.offsets.length : 0,
|
|
202
|
+
format: table.respond_to?(:format) ? table.format : nil,
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Create cmap table summary
|
|
207
|
+
#
|
|
208
|
+
# @param table [Object] cmap table
|
|
209
|
+
# @return [Hash] Summary
|
|
210
|
+
def create_cmap_summary(table)
|
|
211
|
+
{
|
|
212
|
+
type: "cmap",
|
|
213
|
+
version: table.respond_to?(:version) ? table.version : 0,
|
|
214
|
+
note: "Character mappings stored as binary",
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Create CFF table summary
|
|
219
|
+
#
|
|
220
|
+
# @param table [Object] CFF table
|
|
221
|
+
# @return [Hash] Summary
|
|
222
|
+
def create_cff_summary(_table)
|
|
223
|
+
{
|
|
224
|
+
type: "CFF",
|
|
225
|
+
note: "CharString data stored as binary",
|
|
226
|
+
}
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Encode binary data based on format
|
|
230
|
+
#
|
|
231
|
+
# @param data [String] Binary data
|
|
232
|
+
# @return [String] Encoded data
|
|
233
|
+
def encode_binary(data)
|
|
234
|
+
case @binary_format
|
|
235
|
+
when :hex
|
|
236
|
+
data.unpack1("H*")
|
|
237
|
+
when :base64
|
|
238
|
+
Base64.strict_encode64(data)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Validate binary format option
|
|
243
|
+
#
|
|
244
|
+
# @raise [ArgumentError] if format is invalid
|
|
245
|
+
def validate_binary_format!
|
|
246
|
+
valid_formats = %i[hex base64]
|
|
247
|
+
return if valid_formats.include?(@binary_format)
|
|
248
|
+
|
|
249
|
+
raise ArgumentError,
|
|
250
|
+
"Invalid binary format: #{@binary_format}. " \
|
|
251
|
+
"Must be one of: #{valid_formats.join(', ')}"
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../models/ttx/ttfont"
|
|
4
|
+
require_relative "../../models/ttx/glyph_order"
|
|
5
|
+
require_relative "head_transformer"
|
|
6
|
+
require_relative "name_transformer"
|
|
7
|
+
require_relative "os2_transformer"
|
|
8
|
+
require_relative "post_transformer"
|
|
9
|
+
require_relative "hhea_transformer"
|
|
10
|
+
require_relative "maxp_transformer"
|
|
11
|
+
|
|
12
|
+
module Fontisan
|
|
13
|
+
module Export
|
|
14
|
+
module Transformers
|
|
15
|
+
# FontToTtx orchestrates font to TTX transformation
|
|
16
|
+
#
|
|
17
|
+
# Main transformer that coordinates conversion of a complete
|
|
18
|
+
# font to TTX format using individual table transformers.
|
|
19
|
+
# Follows model-to-model transformation principles with
|
|
20
|
+
# clean separation of concerns.
|
|
21
|
+
class FontToTtx
|
|
22
|
+
# Initialize transformer
|
|
23
|
+
#
|
|
24
|
+
# @param font [TrueTypeFont, OpenTypeFont] Source font
|
|
25
|
+
def initialize(font)
|
|
26
|
+
@font = font
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Transform font to TTX model
|
|
30
|
+
#
|
|
31
|
+
# @param options [Hash] Transformation options
|
|
32
|
+
# @option options [Array<String>] :tables Specific tables to include
|
|
33
|
+
# @return [Models::Ttx::TtFont] Complete TTX model
|
|
34
|
+
def transform(options = {})
|
|
35
|
+
table_list = options[:tables] || :all
|
|
36
|
+
|
|
37
|
+
Models::Ttx::TtFont.new.tap do |ttx|
|
|
38
|
+
ttx.sfnt_version = format_sfnt_version(@font.header.sfnt_version.to_i)
|
|
39
|
+
ttx.ttlib_version = "4.0"
|
|
40
|
+
ttx.glyph_order = build_glyph_order
|
|
41
|
+
|
|
42
|
+
# Transform specific tables
|
|
43
|
+
tables_to_transform = select_tables(table_list)
|
|
44
|
+
tables_to_transform.each do |tag|
|
|
45
|
+
transform_table(ttx, tag)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Build glyph order model
|
|
53
|
+
#
|
|
54
|
+
# @return [Models::Ttx::GlyphOrder] Glyph order model
|
|
55
|
+
def build_glyph_order
|
|
56
|
+
Models::Ttx::GlyphOrder.new.tap do |glyph_order|
|
|
57
|
+
glyph_order.glyph_ids = build_glyph_ids
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Build glyph ID entries
|
|
62
|
+
#
|
|
63
|
+
# @return [Array<Models::Ttx::GlyphId>] Glyph ID models
|
|
64
|
+
def build_glyph_ids
|
|
65
|
+
Array.new(glyph_count) do |glyph_id|
|
|
66
|
+
Models::Ttx::GlyphId.new.tap do |gid|
|
|
67
|
+
gid.id = glyph_id
|
|
68
|
+
gid.name = get_glyph_name(glyph_id)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Transform individual table
|
|
74
|
+
#
|
|
75
|
+
# @param ttx [Models::Ttx::TtFont] TTX model being built
|
|
76
|
+
# @param tag [String] Table tag
|
|
77
|
+
# @return [void]
|
|
78
|
+
def transform_table(ttx, tag)
|
|
79
|
+
table = @font.table(tag)
|
|
80
|
+
return unless table
|
|
81
|
+
|
|
82
|
+
case tag
|
|
83
|
+
when "head"
|
|
84
|
+
ttx.head_table = HeadTransformer.transform(table)
|
|
85
|
+
when "hhea"
|
|
86
|
+
ttx.hhea_table = HheaTransformer.transform(table)
|
|
87
|
+
when "maxp"
|
|
88
|
+
ttx.maxp_table = MaxpTransformer.transform(table)
|
|
89
|
+
when "name"
|
|
90
|
+
ttx.name_table = NameTransformer.transform(table)
|
|
91
|
+
when "OS/2"
|
|
92
|
+
ttx.os2_table = Os2Transformer.transform(table)
|
|
93
|
+
when "post"
|
|
94
|
+
ttx.post_table = PostTransformer.transform(table)
|
|
95
|
+
else
|
|
96
|
+
# Fallback to binary table
|
|
97
|
+
binary_table = transform_binary_table(tag, table)
|
|
98
|
+
ttx.binary_tables ||= []
|
|
99
|
+
ttx.binary_tables << binary_table if binary_table
|
|
100
|
+
end
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
# On error, fall back to binary representation
|
|
103
|
+
warn "Error transforming #{tag}: #{e.message}"
|
|
104
|
+
binary_table = transform_binary_table(tag, table)
|
|
105
|
+
ttx.binary_tables ||= []
|
|
106
|
+
ttx.binary_tables << binary_table if binary_table
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Transform table to binary representation
|
|
110
|
+
#
|
|
111
|
+
# @param tag [String] Table tag
|
|
112
|
+
# @param table [Object] Table object
|
|
113
|
+
# @return [Models::Ttx::Tables::BinaryTable, nil] Binary table model
|
|
114
|
+
def transform_binary_table(tag, table)
|
|
115
|
+
binary_data = table.respond_to?(:to_binary_s) ? table.to_binary_s : ""
|
|
116
|
+
return nil if binary_data.empty?
|
|
117
|
+
|
|
118
|
+
Models::Ttx::Tables::BinaryTable.new.tap do |bin_table|
|
|
119
|
+
bin_table.tag = tag
|
|
120
|
+
bin_table.hexdata = binary_data
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Select tables to transform
|
|
125
|
+
#
|
|
126
|
+
# @param table_list [Symbol, Array<String>] :all or list of tags
|
|
127
|
+
# @return [Array<String>] Table tags to transform
|
|
128
|
+
def select_tables(table_list)
|
|
129
|
+
if table_list == :all
|
|
130
|
+
@font.table_names
|
|
131
|
+
else
|
|
132
|
+
available = @font.table_names
|
|
133
|
+
requested = Array(table_list).map(&:to_s)
|
|
134
|
+
requested.select { |tag| available.include?(tag) }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Get number of glyphs
|
|
139
|
+
#
|
|
140
|
+
# @return [Integer] Number of glyphs
|
|
141
|
+
def glyph_count
|
|
142
|
+
maxp = @font.table("maxp")
|
|
143
|
+
maxp ? maxp.num_glyphs.to_i : 0
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Get glyph name by ID
|
|
147
|
+
#
|
|
148
|
+
# @param glyph_id [Integer] Glyph ID
|
|
149
|
+
# @return [String] Glyph name
|
|
150
|
+
def get_glyph_name(glyph_id)
|
|
151
|
+
post = @font.table("post")
|
|
152
|
+
if post.respond_to?(:glyph_names) && post.glyph_names
|
|
153
|
+
post.glyph_names[glyph_id] || ".notdef"
|
|
154
|
+
elsif glyph_id.zero?
|
|
155
|
+
".notdef"
|
|
156
|
+
else
|
|
157
|
+
"glyph#{glyph_id.to_s.rjust(5, '0')}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Format SFNT version
|
|
162
|
+
#
|
|
163
|
+
# @param version [Integer] SFNT version
|
|
164
|
+
# @return [String] Formatted version as escaped bytes
|
|
165
|
+
def format_sfnt_version(version)
|
|
166
|
+
bytes = [version].pack("N").bytes
|
|
167
|
+
"\\x#{bytes.map { |b| b.to_s(16).rjust(2, '0') }.join('\\x')}"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|