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,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,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "options"
|
|
4
|
+
require_relative "profile"
|
|
5
|
+
require_relative "glyph_mapping"
|
|
6
|
+
require_relative "table_subsetter"
|
|
7
|
+
require_relative "../font_writer"
|
|
8
|
+
|
|
9
|
+
module Fontisan
|
|
10
|
+
module Subset
|
|
11
|
+
# Main font subsetting engine
|
|
12
|
+
#
|
|
13
|
+
# The [`Builder`](lib/fontisan/subset/builder.rb) class orchestrates the entire
|
|
14
|
+
# subsetting process:
|
|
15
|
+
# 1. Validates input parameters
|
|
16
|
+
# 2. Calculates glyph closure (including composite dependencies)
|
|
17
|
+
# 3. Builds glyph ID mapping (old GID → new GID)
|
|
18
|
+
# 4. Subsets each table according to the selected profile
|
|
19
|
+
# 5. Assembles the final subset font binary
|
|
20
|
+
#
|
|
21
|
+
# The subsetting process ensures that .notdef (GID 0) is always included
|
|
22
|
+
# as the first glyph, as required by the OpenType specification.
|
|
23
|
+
#
|
|
24
|
+
# @example Basic subsetting
|
|
25
|
+
# font = Fontisan::TrueTypeFont.from_file('font.ttf')
|
|
26
|
+
# builder = Fontisan::Subset::Builder.new(
|
|
27
|
+
# font,
|
|
28
|
+
# [0, 65, 66, 67], # .notdef, A, B, C
|
|
29
|
+
# Options.new(profile: 'pdf')
|
|
30
|
+
# )
|
|
31
|
+
# subset_data = builder.build
|
|
32
|
+
#
|
|
33
|
+
# @example Subsetting with retain_gids
|
|
34
|
+
# options = Options.new(profile: 'pdf', retain_gids: true)
|
|
35
|
+
# builder = Fontisan::Subset::Builder.new(font, glyph_ids, options)
|
|
36
|
+
# subset_data = builder.build
|
|
37
|
+
#
|
|
38
|
+
# @example Web subsetting with dropped hints
|
|
39
|
+
# options = Options.new(profile: 'web', drop_hints: true, drop_names: true)
|
|
40
|
+
# builder = Fontisan::Subset::Builder.new(font, glyph_ids, options)
|
|
41
|
+
# subset_data = builder.build
|
|
42
|
+
#
|
|
43
|
+
# Reference: [`docs/ttfunk-feature-analysis.md:455-492`](docs/ttfunk-feature-analysis.md:455)
|
|
44
|
+
class Builder
|
|
45
|
+
# Font instance to subset
|
|
46
|
+
# @return [TrueTypeFont, OpenTypeFont]
|
|
47
|
+
attr_reader :font
|
|
48
|
+
|
|
49
|
+
# Base set of glyph IDs requested for subsetting
|
|
50
|
+
# @return [Array<Integer>]
|
|
51
|
+
attr_reader :glyph_ids
|
|
52
|
+
|
|
53
|
+
# Subsetting options
|
|
54
|
+
# @return [Options]
|
|
55
|
+
attr_reader :options
|
|
56
|
+
|
|
57
|
+
# Complete set of glyph IDs after closure calculation
|
|
58
|
+
# @return [Set<Integer>]
|
|
59
|
+
attr_reader :closure
|
|
60
|
+
|
|
61
|
+
# Glyph ID mapping (old GID → new GID)
|
|
62
|
+
# @return [GlyphMapping]
|
|
63
|
+
attr_reader :mapping
|
|
64
|
+
|
|
65
|
+
# Initialize a new subsetting builder
|
|
66
|
+
#
|
|
67
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font to subset
|
|
68
|
+
# @param glyph_ids [Array<Integer>] Base glyph IDs to include
|
|
69
|
+
# @param options [Options, Hash] Subsetting options
|
|
70
|
+
# @raise [ArgumentError] If parameters are invalid
|
|
71
|
+
#
|
|
72
|
+
# @example
|
|
73
|
+
# builder = Builder.new(font, [0, 65, 66], Options.new(profile: 'pdf'))
|
|
74
|
+
def initialize(font, glyph_ids, options = {})
|
|
75
|
+
@font = font
|
|
76
|
+
@glyph_ids = Array(glyph_ids)
|
|
77
|
+
@options = options.is_a?(Options) ? options : Options.new(options)
|
|
78
|
+
@closure = nil
|
|
79
|
+
@mapping = nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Build the subset font
|
|
83
|
+
#
|
|
84
|
+
# This is the main entry point that performs the entire subsetting
|
|
85
|
+
# workflow:
|
|
86
|
+
# 1. Validates all input parameters
|
|
87
|
+
# 2. Calculates the glyph closure (composite dependencies)
|
|
88
|
+
# 3. Builds the glyph ID mapping
|
|
89
|
+
# 4. Subsets all required tables
|
|
90
|
+
# 5. Assembles the final font binary
|
|
91
|
+
#
|
|
92
|
+
# @return [String] Binary data of the subset font
|
|
93
|
+
# @raise [ArgumentError] If validation fails
|
|
94
|
+
# @raise [Fontisan::SubsettingError] If subsetting fails
|
|
95
|
+
#
|
|
96
|
+
# @example
|
|
97
|
+
# subset_binary = builder.build
|
|
98
|
+
# File.binwrite('subset.ttf', subset_binary)
|
|
99
|
+
def build
|
|
100
|
+
validate_input!
|
|
101
|
+
calculate_closure
|
|
102
|
+
build_mapping
|
|
103
|
+
tables = subset_tables
|
|
104
|
+
assemble_font(tables)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# Validate input parameters
|
|
110
|
+
#
|
|
111
|
+
# Ensures that the font, glyph IDs, and options are all valid for
|
|
112
|
+
# subsetting. Checks that required tables exist and that glyph IDs
|
|
113
|
+
# are within valid range.
|
|
114
|
+
#
|
|
115
|
+
# @raise [ArgumentError] If validation fails
|
|
116
|
+
def validate_input!
|
|
117
|
+
raise ArgumentError, "Font cannot be nil" if font.nil?
|
|
118
|
+
|
|
119
|
+
unless font.respond_to?(:table)
|
|
120
|
+
raise ArgumentError, "Font must respond to :table method"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Validate options
|
|
124
|
+
options.validate!
|
|
125
|
+
|
|
126
|
+
# Ensure we have at least one glyph ID
|
|
127
|
+
if glyph_ids.empty?
|
|
128
|
+
raise ArgumentError, "At least one glyph ID must be provided"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Validate that required tables exist
|
|
132
|
+
validate_required_tables!
|
|
133
|
+
|
|
134
|
+
# Validate glyph IDs are within range
|
|
135
|
+
validate_glyph_ids!
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Validate that required tables exist in the font
|
|
139
|
+
#
|
|
140
|
+
# @raise [Fontisan::MissingTableError] If required tables are missing
|
|
141
|
+
def validate_required_tables!
|
|
142
|
+
required = %w[head maxp]
|
|
143
|
+
required.each do |tag|
|
|
144
|
+
table = font.table(tag)
|
|
145
|
+
next if table
|
|
146
|
+
|
|
147
|
+
raise Fontisan::MissingTableError,
|
|
148
|
+
"Required table '#{tag}' not found in font"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Validate that all glyph IDs are within valid range
|
|
153
|
+
#
|
|
154
|
+
# @raise [ArgumentError] If any glyph ID is invalid
|
|
155
|
+
def validate_glyph_ids!
|
|
156
|
+
maxp = font.table("maxp")
|
|
157
|
+
num_glyphs = maxp.num_glyphs
|
|
158
|
+
|
|
159
|
+
glyph_ids.each do |gid|
|
|
160
|
+
if gid.nil? || gid.negative?
|
|
161
|
+
raise ArgumentError, "Invalid glyph ID: #{gid.inspect}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
if gid >= num_glyphs
|
|
165
|
+
raise ArgumentError,
|
|
166
|
+
"Glyph ID #{gid} exceeds font's glyph count " \
|
|
167
|
+
"(#{num_glyphs})"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Calculate glyph closure
|
|
173
|
+
#
|
|
174
|
+
# Uses [`GlyphAccessor`](lib/fontisan/glyph_accessor.rb) to recursively
|
|
175
|
+
# collect all glyphs needed, including component glyphs referenced by
|
|
176
|
+
# composite glyphs. Always ensures GID 0 (.notdef) is included.
|
|
177
|
+
#
|
|
178
|
+
# The closure is stored in the `@closure` instance variable as a Set.
|
|
179
|
+
def calculate_closure
|
|
180
|
+
accessor = Fontisan::GlyphAccessor.new(font)
|
|
181
|
+
|
|
182
|
+
# Ensure .notdef (GID 0) is included if specified in options
|
|
183
|
+
base_gids = glyph_ids.dup
|
|
184
|
+
base_gids.unshift(0) if options.include_notdef && !base_gids.include?(0)
|
|
185
|
+
|
|
186
|
+
# Calculate closure using GlyphAccessor
|
|
187
|
+
@closure = accessor.closure_for(base_gids)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Build glyph mapping
|
|
191
|
+
#
|
|
192
|
+
# Creates a [`GlyphMapping`](lib/fontisan/subset/glyph_mapping.rb)
|
|
193
|
+
# object that maps old glyph IDs to new glyph IDs. The mapping respects
|
|
194
|
+
# the `retain_gids` option:
|
|
195
|
+
# - Compact mode (retain_gids: false): Sequential renumbering
|
|
196
|
+
# - Retain mode (retain_gids: true): Preserve original GIDs
|
|
197
|
+
#
|
|
198
|
+
# The mapping is stored in the `@mapping` instance variable.
|
|
199
|
+
def build_mapping
|
|
200
|
+
@mapping = GlyphMapping.new(
|
|
201
|
+
closure.to_a,
|
|
202
|
+
retain_gids: options.retain_gids,
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Subset all tables according to profile
|
|
207
|
+
#
|
|
208
|
+
# For each table specified in the subsetting profile, performs
|
|
209
|
+
# table-specific subsetting operations using [`TableSubsetter`](lib/fontisan/subset/table_subsetter.rb).
|
|
210
|
+
# Tables not in the profile are excluded from the subset font.
|
|
211
|
+
#
|
|
212
|
+
# @return [Hash<String, String>] Hash of table tag => binary data
|
|
213
|
+
# @raise [Fontisan::SubsettingError] If table subsetting fails
|
|
214
|
+
def subset_tables
|
|
215
|
+
profile_tables = Profile.for_name(options.profile)
|
|
216
|
+
subset = {}
|
|
217
|
+
|
|
218
|
+
# Create table subsetter
|
|
219
|
+
subsetter = TableSubsetter.new(font, mapping, options)
|
|
220
|
+
|
|
221
|
+
profile_tables.each do |tag|
|
|
222
|
+
table = font.table(tag)
|
|
223
|
+
next unless table
|
|
224
|
+
|
|
225
|
+
begin
|
|
226
|
+
subset[tag] = subsetter.subset_table(tag, table)
|
|
227
|
+
rescue StandardError => e
|
|
228
|
+
raise Fontisan::SubsettingError,
|
|
229
|
+
"Failed to subset table '#{tag}': #{e.message}"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
subset
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Assemble final font
|
|
237
|
+
#
|
|
238
|
+
# Builds the complete font binary from subset tables, including:
|
|
239
|
+
# - Offset table (font directory)
|
|
240
|
+
# - Table directory entries
|
|
241
|
+
# - Table data
|
|
242
|
+
# - Proper padding and checksums
|
|
243
|
+
#
|
|
244
|
+
# @param tables [Hash<String, String>] Table tag => binary data
|
|
245
|
+
# @return [String] Complete font binary
|
|
246
|
+
def assemble_font(tables)
|
|
247
|
+
# Determine sfnt version based on font type
|
|
248
|
+
sfnt_version = determine_sfnt_version(tables)
|
|
249
|
+
|
|
250
|
+
# Use FontWriter to assemble the complete font
|
|
251
|
+
FontWriter.write_font(tables, sfnt_version: sfnt_version)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Determine the sfnt version for the font
|
|
255
|
+
#
|
|
256
|
+
# @param tables [Hash<String, String>] Table tag => binary data
|
|
257
|
+
# @return [Integer] sfnt version number
|
|
258
|
+
def determine_sfnt_version(tables)
|
|
259
|
+
# If font has CFF or CFF2 table, use OpenType version
|
|
260
|
+
if tables.key?("CFF ") || tables.key?("CFF2")
|
|
261
|
+
0x4F54544F # 'OTTO' for OpenType/CFF
|
|
262
|
+
else
|
|
263
|
+
0x00010000 # 1.0 for TrueType
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|