fontisan 0.1.0 → 0.2.1
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 +672 -69
- data/Gemfile +1 -0
- data/LICENSE +5 -1
- data/README.adoc +1477 -297
- data/Rakefile +63 -41
- 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 +364 -4
- data/lib/fontisan/collection/builder.rb +341 -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 +317 -0
- data/lib/fontisan/collection/writer.rb +306 -0
- data/lib/fontisan/commands/base_command.rb +24 -1
- data/lib/fontisan/commands/convert_command.rb +218 -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 +286 -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 +203 -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 +79 -0
- data/lib/fontisan/converters/conversion_strategy.rb +96 -0
- data/lib/fontisan/converters/format_converter.rb +408 -0
- data/lib/fontisan/converters/outline_converter.rb +998 -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 +122 -15
- data/lib/fontisan/font_writer.rb +302 -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 +310 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +266 -0
- data/lib/fontisan/hints/postscript_hint_extractor.rb +354 -0
- data/lib/fontisan/hints/truetype_hint_applier.rb +117 -0
- data/lib/fontisan/hints/truetype_hint_extractor.rb +289 -0
- data/lib/fontisan/loading_modes.rb +115 -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 +405 -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 +321 -19
- data/lib/fontisan/open_type_font_extensions.rb +54 -0
- 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/pipeline/format_detector.rb +249 -0
- data/lib/fontisan/pipeline/output_writer.rb +154 -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 +411 -0
- data/lib/fontisan/pipeline/variation_resolver.rb +165 -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 +934 -0
- data/lib/fontisan/tables/cff/charstring_builder.rb +356 -0
- data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
- data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -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 +257 -0
- data/lib/fontisan/tables/cff/encoding.rb +274 -0
- data/lib/fontisan/tables/cff/header.rb +102 -0
- data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -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/offset_recalculator.rb +70 -0
- data/lib/fontisan/tables/cff/private_dict.rb +284 -0
- data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
- data/lib/fontisan/tables/cff/table_builder.rb +221 -0
- data/lib/fontisan/tables/cff/top_dict.rb +236 -0
- data/lib/fontisan/tables/cff.rb +489 -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/private_dict_blend_handler.rb +246 -0
- data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
- data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
- data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
- data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
- data/lib/fontisan/tables/cff2.rb +346 -0
- data/lib/fontisan/tables/cvar.rb +203 -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 +231 -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 +321 -20
- data/lib/fontisan/true_type_font_extensions.rb +54 -0
- data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +60 -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/validation/variable_font_validator.rb +218 -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 +375 -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/instance_writer.rb +341 -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/tuple_variation_header.rb +51 -0
- data/lib/fontisan/variation/validator.rb +345 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
- data/lib/fontisan/variation/variation_context.rb +211 -0
- data/lib/fontisan/variation/variation_preserver.rb +288 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/version.rb.orig +9 -0
- data/lib/fontisan/woff2/directory.rb +257 -0
- data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
- data/lib/fontisan/woff2/header.rb +101 -0
- data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
- data/lib/fontisan/woff2/table_transformer.rb +163 -0
- data/lib/fontisan/woff2_font.rb +717 -0
- data/lib/fontisan/woff_font.rb +488 -0
- data/lib/fontisan.rb +132 -0
- data/scripts/compare_stack_aware.rb +187 -0
- data/scripts/measure_optimization.rb +141 -0
- metadata +234 -4
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "models/glyph_outline"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
# Extracts glyph outlines from font tables
|
|
7
|
+
#
|
|
8
|
+
# [`OutlineExtractor`](lib/fontisan/outline_extractor.rb) provides a unified
|
|
9
|
+
# interface for extracting glyph outline data from both TrueType (glyf table)
|
|
10
|
+
# and CFF (Compact Font Format) fonts. It uses a strategy pattern to handle
|
|
11
|
+
# the different outline formats transparently.
|
|
12
|
+
#
|
|
13
|
+
# The extractor:
|
|
14
|
+
# - Automatically detects font format (TrueType vs CFF)
|
|
15
|
+
# - Handles simple glyphs (direct outline data)
|
|
16
|
+
# - Handles compound glyphs (recursively resolves components)
|
|
17
|
+
# - Returns standardized [`GlyphOutline`](lib/fontisan/models/glyph_outline.rb) objects
|
|
18
|
+
#
|
|
19
|
+
# This class is responsible for extraction only, not business logic or
|
|
20
|
+
# presentation. It's designed to be composed with
|
|
21
|
+
# [`GlyphAccessor`](lib/fontisan/glyph_accessor.rb) for higher-level operations.
|
|
22
|
+
#
|
|
23
|
+
# @example Extracting a glyph outline
|
|
24
|
+
# extractor = Fontisan::OutlineExtractor.new(font)
|
|
25
|
+
# outline = extractor.extract(65) # 'A' character
|
|
26
|
+
#
|
|
27
|
+
# puts outline.contour_count
|
|
28
|
+
# puts outline.to_svg_path
|
|
29
|
+
#
|
|
30
|
+
# @example Using with GlyphAccessor
|
|
31
|
+
# accessor = Fontisan::GlyphAccessor.new(font)
|
|
32
|
+
# outline = accessor.outline_for_char('A')
|
|
33
|
+
#
|
|
34
|
+
# Reference: [`docs/GETTING_STARTED.md:125-172`](docs/GETTING_STARTED.md:125)
|
|
35
|
+
class OutlineExtractor
|
|
36
|
+
# @return [TrueTypeFont, OpenTypeFont] Font instance
|
|
37
|
+
attr_reader :font
|
|
38
|
+
|
|
39
|
+
# Initialize a new outline extractor
|
|
40
|
+
#
|
|
41
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font to extract outlines from
|
|
42
|
+
# @raise [ArgumentError] If font is nil or doesn't have required tables
|
|
43
|
+
def initialize(font)
|
|
44
|
+
raise ArgumentError, "Font cannot be nil" if font.nil?
|
|
45
|
+
|
|
46
|
+
unless font.respond_to?(:table)
|
|
47
|
+
raise ArgumentError, "Font must respond to :table method"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@font = font
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Extract outline for a specific glyph
|
|
54
|
+
#
|
|
55
|
+
# This method automatically detects the font format and delegates to
|
|
56
|
+
# the appropriate extraction strategy. For compound glyphs (TrueType only),
|
|
57
|
+
# it recursively resolves component outlines and combines them with
|
|
58
|
+
# proper transformations.
|
|
59
|
+
#
|
|
60
|
+
# @param glyph_id [Integer] The glyph index (0-based, 0 is .notdef)
|
|
61
|
+
# @return [Models::GlyphOutline, nil] The outline or nil if glyph not
|
|
62
|
+
# found or empty
|
|
63
|
+
# @raise [ArgumentError] If glyph_id is invalid
|
|
64
|
+
# @raise [Fontisan::MissingTableError] If required tables are missing
|
|
65
|
+
#
|
|
66
|
+
# @example Extract a simple glyph
|
|
67
|
+
# outline = extractor.extract(65)
|
|
68
|
+
# puts "Glyph has #{outline.contour_count} contours"
|
|
69
|
+
#
|
|
70
|
+
# @example Handle empty glyphs (like space)
|
|
71
|
+
# outline = extractor.extract(space_glyph_id)
|
|
72
|
+
# # => nil (empty glyphs return nil)
|
|
73
|
+
def extract(glyph_id)
|
|
74
|
+
validate_glyph_id!(glyph_id)
|
|
75
|
+
|
|
76
|
+
if cff_font?
|
|
77
|
+
extract_cff_outline(glyph_id)
|
|
78
|
+
elsif truetype_font?
|
|
79
|
+
extract_truetype_outline(glyph_id)
|
|
80
|
+
else
|
|
81
|
+
raise Fontisan::MissingTableError,
|
|
82
|
+
"Font has neither glyf nor CFF table"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Check if this is a CFF font
|
|
89
|
+
#
|
|
90
|
+
# @return [Boolean] True if font has CFF table
|
|
91
|
+
def cff_font?
|
|
92
|
+
font.table(Constants::CFF_TAG) != nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Check if this is a TrueType font
|
|
96
|
+
#
|
|
97
|
+
# @return [Boolean] True if font has glyf table
|
|
98
|
+
def truetype_font?
|
|
99
|
+
font.has_table?("glyf")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Validate glyph ID
|
|
103
|
+
#
|
|
104
|
+
# @param glyph_id [Integer] Glyph ID to validate
|
|
105
|
+
# @raise [ArgumentError] If glyph ID is invalid
|
|
106
|
+
def validate_glyph_id!(glyph_id)
|
|
107
|
+
if glyph_id.nil?
|
|
108
|
+
raise ArgumentError, "glyph_id cannot be nil"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
if glyph_id.negative?
|
|
112
|
+
raise ArgumentError, "glyph_id must be >= 0, got: #{glyph_id}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
maxp = font.table("maxp")
|
|
116
|
+
if maxp && glyph_id >= maxp.num_glyphs
|
|
117
|
+
raise ArgumentError,
|
|
118
|
+
"glyph_id #{glyph_id} exceeds number of glyphs (#{maxp.num_glyphs})"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Extract outline from TrueType glyph
|
|
123
|
+
#
|
|
124
|
+
# Handles both simple and compound glyphs. For compound glyphs,
|
|
125
|
+
# recursively resolves component outlines and applies transformations.
|
|
126
|
+
#
|
|
127
|
+
# @param glyph_id [Integer] Glyph ID
|
|
128
|
+
# @return [Models::GlyphOutline, nil] Outline or nil if empty
|
|
129
|
+
# @raise [Fontisan::MissingTableError] If required tables are missing
|
|
130
|
+
def extract_truetype_outline(glyph_id)
|
|
131
|
+
glyph = get_truetype_glyph(glyph_id)
|
|
132
|
+
return nil unless glyph
|
|
133
|
+
|
|
134
|
+
# Handle empty glyphs (space, etc.)
|
|
135
|
+
return nil if glyph.respond_to?(:empty?) && glyph.empty?
|
|
136
|
+
|
|
137
|
+
if glyph.simple?
|
|
138
|
+
extract_simple_outline(glyph)
|
|
139
|
+
elsif glyph.compound?
|
|
140
|
+
extract_compound_outline(glyph)
|
|
141
|
+
else
|
|
142
|
+
raise Fontisan::Error, "Unknown glyph type: #{glyph.class}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Extract outline from CFF glyph
|
|
147
|
+
#
|
|
148
|
+
# CFF glyphs don't have compound structures, so extraction is
|
|
149
|
+
# straightforward from the CharString.
|
|
150
|
+
#
|
|
151
|
+
# @param glyph_id [Integer] Glyph ID
|
|
152
|
+
# @return [Models::GlyphOutline, nil] Outline or nil if empty
|
|
153
|
+
# @raise [Fontisan::MissingTableError] If CFF table is missing
|
|
154
|
+
def extract_cff_outline(glyph_id)
|
|
155
|
+
cff = font.table(Constants::CFF_TAG)
|
|
156
|
+
raise_missing_table!(Constants::CFF_TAG) unless cff
|
|
157
|
+
|
|
158
|
+
# Get CharString for glyph
|
|
159
|
+
charstring = cff.charstring_for_glyph(glyph_id)
|
|
160
|
+
return nil unless charstring
|
|
161
|
+
|
|
162
|
+
# CharString has path data
|
|
163
|
+
path = charstring.path
|
|
164
|
+
return nil if path.empty?
|
|
165
|
+
|
|
166
|
+
# Convert CharString path to contours
|
|
167
|
+
contours = convert_cff_path_to_contours(path)
|
|
168
|
+
return nil if contours.empty?
|
|
169
|
+
|
|
170
|
+
# Get bounding box from CharString
|
|
171
|
+
bbox_array = charstring.bounding_box
|
|
172
|
+
return nil unless bbox_array
|
|
173
|
+
|
|
174
|
+
bbox = {
|
|
175
|
+
x_min: bbox_array[0],
|
|
176
|
+
y_min: bbox_array[1],
|
|
177
|
+
x_max: bbox_array[2],
|
|
178
|
+
y_max: bbox_array[3],
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
Models::GlyphOutline.new(
|
|
182
|
+
glyph_id: glyph_id,
|
|
183
|
+
contours: contours,
|
|
184
|
+
bbox: bbox,
|
|
185
|
+
)
|
|
186
|
+
rescue StandardError => e
|
|
187
|
+
warn "Failed to extract CFF outline for glyph #{glyph_id}: #{e.message}"
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Get TrueType glyph from glyf table
|
|
192
|
+
#
|
|
193
|
+
# @param glyph_id [Integer] Glyph ID
|
|
194
|
+
# @return [SimpleGlyph, CompoundGlyph, nil] Glyph object
|
|
195
|
+
# @raise [Fontisan::MissingTableError] If required tables are missing
|
|
196
|
+
def get_truetype_glyph(glyph_id)
|
|
197
|
+
glyf = font.table("glyf")
|
|
198
|
+
raise_missing_table!("glyf") unless glyf
|
|
199
|
+
|
|
200
|
+
loca = font.table("loca")
|
|
201
|
+
raise_missing_table!("loca") unless loca
|
|
202
|
+
|
|
203
|
+
head = font.table("head")
|
|
204
|
+
raise_missing_table!("head") unless head
|
|
205
|
+
|
|
206
|
+
# Ensure loca is parsed
|
|
207
|
+
unless loca.parsed?
|
|
208
|
+
maxp = font.table("maxp")
|
|
209
|
+
raise_missing_table!("maxp") unless maxp
|
|
210
|
+
loca.parse_with_context(head.index_to_loc_format, maxp.num_glyphs)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
glyf.glyph_for(glyph_id, loca, head)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Extract outline from a simple TrueType glyph
|
|
217
|
+
#
|
|
218
|
+
# @param glyph [SimpleGlyph] Simple glyph object
|
|
219
|
+
# @return [Models::GlyphOutline] Outline object
|
|
220
|
+
def extract_simple_outline(glyph)
|
|
221
|
+
contours = []
|
|
222
|
+
|
|
223
|
+
# Process each contour
|
|
224
|
+
glyph.num_contours.times do |contour_index|
|
|
225
|
+
points = glyph.points_for_contour(contour_index)
|
|
226
|
+
contours << points if points && !points.empty?
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
bbox = {
|
|
230
|
+
x_min: glyph.x_min,
|
|
231
|
+
y_min: glyph.y_min,
|
|
232
|
+
x_max: glyph.x_max,
|
|
233
|
+
y_max: glyph.y_max,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
Models::GlyphOutline.new(
|
|
237
|
+
glyph_id: glyph.glyph_id,
|
|
238
|
+
contours: contours,
|
|
239
|
+
bbox: bbox,
|
|
240
|
+
)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Extract outline from a compound TrueType glyph
|
|
244
|
+
#
|
|
245
|
+
# Recursively resolves component glyphs and applies transformations
|
|
246
|
+
# to combine them into a single outline.
|
|
247
|
+
#
|
|
248
|
+
# @param glyph [CompoundGlyph] Compound glyph object
|
|
249
|
+
# @return [Models::GlyphOutline] Combined outline object
|
|
250
|
+
def extract_compound_outline(glyph)
|
|
251
|
+
all_contours = []
|
|
252
|
+
combined_bbox = nil
|
|
253
|
+
|
|
254
|
+
# Process each component
|
|
255
|
+
glyph.components.each do |component|
|
|
256
|
+
component_outline = extract(component.glyph_index)
|
|
257
|
+
next unless component_outline
|
|
258
|
+
|
|
259
|
+
# Get transformation matrix
|
|
260
|
+
matrix = component.transformation_matrix
|
|
261
|
+
|
|
262
|
+
# Transform component contours
|
|
263
|
+
transformed_contours = transform_contours(
|
|
264
|
+
component_outline.contours,
|
|
265
|
+
matrix,
|
|
266
|
+
)
|
|
267
|
+
all_contours.concat(transformed_contours)
|
|
268
|
+
|
|
269
|
+
# Update combined bounding box
|
|
270
|
+
component_bbox = component_outline.bbox
|
|
271
|
+
combined_bbox = merge_bboxes(combined_bbox, component_bbox, matrix)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Use original bbox if we couldn't compute one
|
|
275
|
+
combined_bbox ||= {
|
|
276
|
+
x_min: glyph.x_min,
|
|
277
|
+
y_min: glyph.y_min,
|
|
278
|
+
x_max: glyph.x_max,
|
|
279
|
+
y_max: glyph.y_max,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
Models::GlyphOutline.new(
|
|
283
|
+
glyph_id: glyph.glyph_id,
|
|
284
|
+
contours: all_contours,
|
|
285
|
+
bbox: combined_bbox,
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Transform contours using an affine transformation matrix
|
|
290
|
+
#
|
|
291
|
+
# @param contours [Array<Array<Hash>>] Original contours
|
|
292
|
+
# @param matrix [Array<Float>] Transformation matrix [a, b, c, d, e, f]
|
|
293
|
+
# @return [Array<Array<Hash>>] Transformed contours
|
|
294
|
+
def transform_contours(contours, matrix)
|
|
295
|
+
a, b, c, d, e, f = matrix
|
|
296
|
+
|
|
297
|
+
contours.map do |contour|
|
|
298
|
+
contour.map do |point|
|
|
299
|
+
x = point[:x]
|
|
300
|
+
y = point[:y]
|
|
301
|
+
|
|
302
|
+
# Apply affine transformation: x' = a*x + c*y + e, y' = b*x + d*y + f
|
|
303
|
+
new_x = (a * x + c * y + e).round
|
|
304
|
+
new_y = (b * x + d * y + f).round
|
|
305
|
+
|
|
306
|
+
{
|
|
307
|
+
x: new_x,
|
|
308
|
+
y: new_y,
|
|
309
|
+
on_curve: point[:on_curve],
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Merge two bounding boxes
|
|
316
|
+
#
|
|
317
|
+
# @param bbox1 [Hash, nil] First bounding box
|
|
318
|
+
# @param bbox2 [Hash] Second bounding box
|
|
319
|
+
# @param matrix [Array<Float>] Transformation matrix for bbox2
|
|
320
|
+
# @return [Hash] Merged bounding box
|
|
321
|
+
def merge_bboxes(bbox1, bbox2, matrix)
|
|
322
|
+
# Transform bbox2 corners
|
|
323
|
+
a, b, c, d, e, f = matrix
|
|
324
|
+
|
|
325
|
+
corners = [
|
|
326
|
+
[bbox2[:x_min], bbox2[:y_min]],
|
|
327
|
+
[bbox2[:x_max], bbox2[:y_min]],
|
|
328
|
+
[bbox2[:x_min], bbox2[:y_max]],
|
|
329
|
+
[bbox2[:x_max], bbox2[:y_max]],
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
transformed_corners = corners.map do |x, y|
|
|
333
|
+
[
|
|
334
|
+
(a * x + c * y + e).round,
|
|
335
|
+
(b * x + d * y + f).round,
|
|
336
|
+
]
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
transformed_bbox = {
|
|
340
|
+
x_min: transformed_corners.map(&:first).min,
|
|
341
|
+
y_min: transformed_corners.map(&:last).min,
|
|
342
|
+
x_max: transformed_corners.map(&:first).max,
|
|
343
|
+
y_max: transformed_corners.map(&:last).max,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return transformed_bbox unless bbox1
|
|
347
|
+
|
|
348
|
+
# Merge with existing bbox
|
|
349
|
+
{
|
|
350
|
+
x_min: [bbox1[:x_min], transformed_bbox[:x_min]].min,
|
|
351
|
+
y_min: [bbox1[:y_min], transformed_bbox[:y_min]].min,
|
|
352
|
+
x_max: [bbox1[:x_max], transformed_bbox[:x_max]].max,
|
|
353
|
+
y_max: [bbox1[:y_max], transformed_bbox[:y_max]].max,
|
|
354
|
+
}
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Convert CFF CharString path to contours
|
|
358
|
+
#
|
|
359
|
+
# CFF paths are stored as arrays of command hashes. We need to
|
|
360
|
+
# convert them to the contour format used by GlyphOutline.
|
|
361
|
+
#
|
|
362
|
+
# @param path [Array<Hash>] CharString path data
|
|
363
|
+
# @return [Array<Array<Hash>>] Contours array
|
|
364
|
+
def convert_cff_path_to_contours(path)
|
|
365
|
+
contours = []
|
|
366
|
+
current_contour = []
|
|
367
|
+
|
|
368
|
+
path.each do |cmd|
|
|
369
|
+
case cmd[:type]
|
|
370
|
+
when :move_to
|
|
371
|
+
# Start new contour
|
|
372
|
+
contours << current_contour unless current_contour.empty?
|
|
373
|
+
current_contour = []
|
|
374
|
+
current_contour << {
|
|
375
|
+
x: cmd[:x].round,
|
|
376
|
+
y: cmd[:y].round,
|
|
377
|
+
on_curve: true,
|
|
378
|
+
}
|
|
379
|
+
when :line_to
|
|
380
|
+
current_contour << {
|
|
381
|
+
x: cmd[:x].round,
|
|
382
|
+
y: cmd[:y].round,
|
|
383
|
+
on_curve: true,
|
|
384
|
+
}
|
|
385
|
+
when :curve_to
|
|
386
|
+
# CFF uses cubic Bézier curves
|
|
387
|
+
# For now, we'll add control points and end point
|
|
388
|
+
# This is a simplification - proper handling would require
|
|
389
|
+
# converting cubic to quadratic or keeping cubic format
|
|
390
|
+
current_contour << {
|
|
391
|
+
x: cmd[:x1].round,
|
|
392
|
+
y: cmd[:y1].round,
|
|
393
|
+
on_curve: false,
|
|
394
|
+
}
|
|
395
|
+
current_contour << {
|
|
396
|
+
x: cmd[:x2].round,
|
|
397
|
+
y: cmd[:y2].round,
|
|
398
|
+
on_curve: false,
|
|
399
|
+
}
|
|
400
|
+
current_contour << {
|
|
401
|
+
x: cmd[:x].round,
|
|
402
|
+
y: cmd[:y].round,
|
|
403
|
+
on_curve: true,
|
|
404
|
+
}
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Add final contour
|
|
409
|
+
contours << current_contour unless current_contour.empty?
|
|
410
|
+
|
|
411
|
+
contours
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Raise MissingTableError
|
|
415
|
+
#
|
|
416
|
+
# @param table_tag [String] Table tag
|
|
417
|
+
# @raise [Fontisan::MissingTableError]
|
|
418
|
+
def raise_missing_table!(table_tag)
|
|
419
|
+
raise Fontisan::MissingTableError,
|
|
420
|
+
"Required table '#{table_tag}' not found in font"
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../font_loader"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Pipeline
|
|
7
|
+
# Detects font format and capabilities
|
|
8
|
+
#
|
|
9
|
+
# This class analyzes font files to determine:
|
|
10
|
+
# - Format: TTF, OTF, TTC, OTC, WOFF, WOFF2, SVG
|
|
11
|
+
# - Variation type: static, gvar (TrueType variable), CFF2 (OpenType variable)
|
|
12
|
+
# - Capabilities: outline type, variation support, collection support
|
|
13
|
+
#
|
|
14
|
+
# Used by the universal transformation pipeline to determine conversion
|
|
15
|
+
# strategies and validate compatibility.
|
|
16
|
+
#
|
|
17
|
+
# @example Detecting a font's format
|
|
18
|
+
# detector = FormatDetector.new("font.ttf")
|
|
19
|
+
# info = detector.detect
|
|
20
|
+
# puts info[:format] # => :ttf
|
|
21
|
+
# puts info[:variation_type] # => :gvar
|
|
22
|
+
# puts info[:capabilities][:outline] # => :truetype
|
|
23
|
+
class FormatDetector
|
|
24
|
+
# @return [String] Path to font file
|
|
25
|
+
attr_reader :file_path
|
|
26
|
+
|
|
27
|
+
# @return [TrueTypeFont, OpenTypeFont, TrueTypeCollection, OpenTypeCollection, nil] Loaded font
|
|
28
|
+
attr_reader :font
|
|
29
|
+
|
|
30
|
+
# Initialize detector
|
|
31
|
+
#
|
|
32
|
+
# @param file_path [String] Path to font file
|
|
33
|
+
def initialize(file_path)
|
|
34
|
+
@file_path = file_path
|
|
35
|
+
@font = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Detect format and capabilities
|
|
39
|
+
#
|
|
40
|
+
# @return [Hash] Detection results with :format, :variation_type, :capabilities
|
|
41
|
+
def detect
|
|
42
|
+
load_font
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
format: detect_format,
|
|
46
|
+
variation_type: detect_variation,
|
|
47
|
+
capabilities: detect_capabilities,
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Detect font format
|
|
52
|
+
#
|
|
53
|
+
# @return [Symbol] One of :ttf, :otf, :ttc, :otc, :woff, :woff2, :svg
|
|
54
|
+
def detect_format
|
|
55
|
+
# Check for SVG first (from file extension even if font failed to load)
|
|
56
|
+
return :svg if @file_path.end_with?(".svg")
|
|
57
|
+
|
|
58
|
+
return :unknown unless @font
|
|
59
|
+
|
|
60
|
+
# Use is_a? for proper class checking
|
|
61
|
+
case @font
|
|
62
|
+
when Fontisan::TrueTypeCollection
|
|
63
|
+
:ttc
|
|
64
|
+
when Fontisan::OpenTypeCollection
|
|
65
|
+
:otc
|
|
66
|
+
when Fontisan::TrueTypeFont
|
|
67
|
+
if @file_path.end_with?(".woff")
|
|
68
|
+
:woff
|
|
69
|
+
elsif @file_path.end_with?(".woff2")
|
|
70
|
+
:woff2
|
|
71
|
+
else
|
|
72
|
+
:ttf
|
|
73
|
+
end
|
|
74
|
+
when Fontisan::OpenTypeFont
|
|
75
|
+
if @file_path.end_with?(".woff")
|
|
76
|
+
:woff
|
|
77
|
+
elsif @file_path.end_with?(".woff2")
|
|
78
|
+
:woff2
|
|
79
|
+
else
|
|
80
|
+
:otf
|
|
81
|
+
end
|
|
82
|
+
else
|
|
83
|
+
:unknown
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Detect variation type
|
|
88
|
+
#
|
|
89
|
+
# @return [Symbol] One of :static, :gvar, :cff2
|
|
90
|
+
def detect_variation
|
|
91
|
+
return :static unless @font
|
|
92
|
+
|
|
93
|
+
# Collections don't have has_table? method
|
|
94
|
+
# Return :static for collections (variation detection would need to load first font)
|
|
95
|
+
return :static if collection?
|
|
96
|
+
|
|
97
|
+
# Check for variable font tables
|
|
98
|
+
if @font.has_table?("fvar")
|
|
99
|
+
# Variable font detected - check variation type
|
|
100
|
+
if @font.has_table?("gvar")
|
|
101
|
+
:gvar # TrueType variable font
|
|
102
|
+
elsif @font.has_table?("CFF2")
|
|
103
|
+
:cff2 # OpenType variable font (CFF2)
|
|
104
|
+
else
|
|
105
|
+
:static # Has fvar but no variation data (shouldn't happen)
|
|
106
|
+
end
|
|
107
|
+
else
|
|
108
|
+
:static
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Detect font capabilities
|
|
113
|
+
#
|
|
114
|
+
# @return [Hash] Capabilities hash
|
|
115
|
+
def detect_capabilities
|
|
116
|
+
return default_capabilities unless @font
|
|
117
|
+
|
|
118
|
+
# Check if this is a collection
|
|
119
|
+
is_collection = collection?
|
|
120
|
+
|
|
121
|
+
font_to_check = if is_collection
|
|
122
|
+
# Collections don't have fonts method, need to load first font
|
|
123
|
+
nil # Will handle in API usage
|
|
124
|
+
else
|
|
125
|
+
@font
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# For collections, return basic capabilities
|
|
129
|
+
if is_collection
|
|
130
|
+
return {
|
|
131
|
+
outline: :unknown, # Would need to load first font to know
|
|
132
|
+
variation: false, # Would need to load first font to know
|
|
133
|
+
collection: true,
|
|
134
|
+
tables: [],
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
return default_capabilities unless font_to_check
|
|
139
|
+
|
|
140
|
+
{
|
|
141
|
+
outline: detect_outline_type(font_to_check),
|
|
142
|
+
variation: detect_variation != :static,
|
|
143
|
+
collection: false,
|
|
144
|
+
tables: available_tables(font_to_check),
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Check if font is a collection
|
|
149
|
+
#
|
|
150
|
+
# @return [Boolean] True if collection (TTC/OTC)
|
|
151
|
+
def collection?
|
|
152
|
+
@font.is_a?(Fontisan::TrueTypeCollection) ||
|
|
153
|
+
@font.is_a?(Fontisan::OpenTypeCollection)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Check if font is variable
|
|
157
|
+
#
|
|
158
|
+
# @return [Boolean] True if variable font
|
|
159
|
+
def variable?
|
|
160
|
+
detect_variation != :static
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Check if format is compatible with target
|
|
164
|
+
#
|
|
165
|
+
# @param target_format [Symbol] Target format (:ttf, :otf, etc.)
|
|
166
|
+
# @return [Boolean] True if conversion is possible
|
|
167
|
+
def compatible_with?(target_format)
|
|
168
|
+
current_format = detect_format
|
|
169
|
+
variation_type = detect_variation
|
|
170
|
+
|
|
171
|
+
# Same format is always compatible
|
|
172
|
+
return true if current_format == target_format
|
|
173
|
+
|
|
174
|
+
# Collection formats
|
|
175
|
+
if %i[ttc otc].include?(current_format)
|
|
176
|
+
return %i[ttc otc].include?(target_format)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Variable font constraints
|
|
180
|
+
if variation_type == :static
|
|
181
|
+
# Static fonts can convert to any format
|
|
182
|
+
true
|
|
183
|
+
else
|
|
184
|
+
case variation_type
|
|
185
|
+
when :gvar
|
|
186
|
+
# TrueType variable can convert to TrueType formats
|
|
187
|
+
%i[ttf ttc woff woff2].include?(target_format)
|
|
188
|
+
when :cff2
|
|
189
|
+
# OpenType variable can convert to OpenType formats
|
|
190
|
+
%i[otf otc woff woff2].include?(target_format)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
# Load font from file
|
|
198
|
+
def load_font
|
|
199
|
+
# Check if it's a collection first
|
|
200
|
+
@font = if FontLoader.collection?(@file_path)
|
|
201
|
+
FontLoader.load_collection(@file_path)
|
|
202
|
+
else
|
|
203
|
+
FontLoader.load(@file_path, mode: :full)
|
|
204
|
+
end
|
|
205
|
+
rescue StandardError => e
|
|
206
|
+
warn "Failed to load font: #{e.message}"
|
|
207
|
+
@font = nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Detect outline type
|
|
211
|
+
#
|
|
212
|
+
# @param font [Font] Font object
|
|
213
|
+
# @return [Symbol] :truetype or :cff
|
|
214
|
+
def detect_outline_type(font)
|
|
215
|
+
if font.has_table?("glyf") || font.has_table?("gvar")
|
|
216
|
+
:truetype
|
|
217
|
+
elsif font.has_table?("CFF ") || font.has_table?("CFF2")
|
|
218
|
+
:cff
|
|
219
|
+
else
|
|
220
|
+
:unknown
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Get available tables
|
|
225
|
+
#
|
|
226
|
+
# @param font [Font] Font object
|
|
227
|
+
# @return [Array<String>] List of table tags
|
|
228
|
+
def available_tables(font)
|
|
229
|
+
return [] unless font.respond_to?(:table_names)
|
|
230
|
+
|
|
231
|
+
font.table_names
|
|
232
|
+
rescue StandardError
|
|
233
|
+
[]
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Default capabilities when font cannot be loaded
|
|
237
|
+
#
|
|
238
|
+
# @return [Hash] Default capabilities
|
|
239
|
+
def default_capabilities
|
|
240
|
+
{
|
|
241
|
+
outline: :unknown,
|
|
242
|
+
variation: false,
|
|
243
|
+
collection: false,
|
|
244
|
+
tables: [],
|
|
245
|
+
}
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|