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,483 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Tables
|
|
5
|
+
# Represents a compound TrueType glyph composed of other glyphs
|
|
6
|
+
#
|
|
7
|
+
# A compound glyph is built by referencing other glyphs (components)
|
|
8
|
+
# and applying transformations to them. Each component references
|
|
9
|
+
# another glyph by ID and specifies positioning and optional scaling,
|
|
10
|
+
# rotation, or affine transformation.
|
|
11
|
+
#
|
|
12
|
+
# The glyph structure consists of:
|
|
13
|
+
# - Header: numberOfContours (-1), xMin, yMin, xMax, yMax (10 bytes)
|
|
14
|
+
# - Components: array of component descriptions (variable length)
|
|
15
|
+
# - Instructions: optional TrueType hinting instructions
|
|
16
|
+
#
|
|
17
|
+
# Each component has:
|
|
18
|
+
# - flags (uint16): component flags
|
|
19
|
+
# - glyphIndex (uint16): referenced glyph ID
|
|
20
|
+
# - arguments: positioning (arg1, arg2) - interpretation depends on flags
|
|
21
|
+
# - transformation: optional scale/rotation/affine matrix
|
|
22
|
+
#
|
|
23
|
+
# Component flags (16-bit) indicate:
|
|
24
|
+
# - Bit 0 (0x0001): ARG_1_AND_2_ARE_WORDS - arguments are 16-bit
|
|
25
|
+
# - Bit 1 (0x0002): ARGS_ARE_XY_VALUES - arguments are x,y offsets
|
|
26
|
+
# - Bit 2 (0x0004): ROUND_XY_TO_GRID - round x,y to grid
|
|
27
|
+
# - Bit 3 (0x0008): WE_HAVE_A_SCALE - uniform scale follows
|
|
28
|
+
# - Bit 5 (0x0020): MORE_COMPONENTS - more components follow
|
|
29
|
+
# - Bit 6 (0x0040): WE_HAVE_AN_X_AND_Y_SCALE - separate x,y scale
|
|
30
|
+
# - Bit 7 (0x0080): WE_HAVE_A_TWO_BY_TWO - 2x2 affine matrix
|
|
31
|
+
# - Bit 8 (0x0100): WE_HAVE_INSTRUCTIONS - instructions follow components
|
|
32
|
+
# - Bit 9 (0x0200): USE_MY_METRICS - use this component's metrics
|
|
33
|
+
# - Bit 10 (0x0400): OVERLAP_COMPOUND - component outlines overlap
|
|
34
|
+
# - Bit 11 (0x0800): SCALED_COMPONENT_OFFSET - scale offset values
|
|
35
|
+
# - Bit 12 (0x1000): UNSCALED_COMPONENT_OFFSET - don't scale offsets
|
|
36
|
+
#
|
|
37
|
+
# Reference: OpenType specification, glyf table - Compound Glyph Description
|
|
38
|
+
# https://docs.microsoft.com/en-us/typography/opentype/spec/glyf#compound-glyph-description
|
|
39
|
+
class CompoundGlyph
|
|
40
|
+
# Component flag constants
|
|
41
|
+
ARG_1_AND_2_ARE_WORDS = 0x0001
|
|
42
|
+
ARGS_ARE_XY_VALUES = 0x0002
|
|
43
|
+
ROUND_XY_TO_GRID = 0x0004
|
|
44
|
+
WE_HAVE_A_SCALE = 0x0008
|
|
45
|
+
MORE_COMPONENTS = 0x0020
|
|
46
|
+
WE_HAVE_AN_X_AND_Y_SCALE = 0x0040
|
|
47
|
+
WE_HAVE_A_TWO_BY_TWO = 0x0080
|
|
48
|
+
WE_HAVE_INSTRUCTIONS = 0x0100
|
|
49
|
+
USE_MY_METRICS = 0x0200
|
|
50
|
+
OVERLAP_COMPOUND = 0x0400
|
|
51
|
+
SCALED_COMPONENT_OFFSET = 0x0800
|
|
52
|
+
UNSCALED_COMPONENT_OFFSET = 0x1000
|
|
53
|
+
|
|
54
|
+
# Component data structure
|
|
55
|
+
Component = Struct.new(
|
|
56
|
+
:flags,
|
|
57
|
+
:glyph_index,
|
|
58
|
+
:arg1,
|
|
59
|
+
:arg2,
|
|
60
|
+
:scale_x,
|
|
61
|
+
:scale_y,
|
|
62
|
+
:scale_01,
|
|
63
|
+
:scale_10,
|
|
64
|
+
keyword_init: true,
|
|
65
|
+
) do
|
|
66
|
+
# Check if arguments are x,y offsets (vs point numbers)
|
|
67
|
+
def args_are_xy?
|
|
68
|
+
(flags & ARGS_ARE_XY_VALUES) != 0
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Check if using this component's metrics
|
|
72
|
+
def use_my_metrics?
|
|
73
|
+
(flags & USE_MY_METRICS) != 0
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Check if component has uniform scale
|
|
77
|
+
def has_scale?
|
|
78
|
+
(flags & WE_HAVE_A_SCALE) != 0
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check if component has separate x,y scale
|
|
82
|
+
def has_xy_scale?
|
|
83
|
+
(flags & WE_HAVE_AN_X_AND_Y_SCALE) != 0
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Check if component has 2x2 transformation matrix
|
|
87
|
+
def has_2x2?
|
|
88
|
+
(flags & WE_HAVE_A_TWO_BY_TWO) != 0
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Check if component overlaps with others
|
|
92
|
+
def overlap?
|
|
93
|
+
(flags & OVERLAP_COMPOUND) != 0
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Get transformation matrix as array [a, b, c, d, e, f]
|
|
97
|
+
# representing affine transformation: x' = a*x + c*y + e, y' = b*x + d*y + f
|
|
98
|
+
#
|
|
99
|
+
# @return [Array<Float>] Transformation matrix [a, b, c, d, e, f]
|
|
100
|
+
def transformation_matrix
|
|
101
|
+
if has_2x2?
|
|
102
|
+
[scale_x, scale_01, scale_10, scale_y, arg1, arg2]
|
|
103
|
+
elsif has_xy_scale?
|
|
104
|
+
[scale_x, 0.0, 0.0, scale_y, arg1, arg2]
|
|
105
|
+
elsif has_scale?
|
|
106
|
+
[scale_x, 0.0, 0.0, scale_x, arg1, arg2]
|
|
107
|
+
else
|
|
108
|
+
[1.0, 0.0, 0.0, 1.0, arg1, arg2]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Glyph header fields
|
|
114
|
+
attr_reader :glyph_id
|
|
115
|
+
attr_reader :x_min, :y_min, :x_max, :y_max, :instruction_length,
|
|
116
|
+
:instructions
|
|
117
|
+
|
|
118
|
+
# Compound glyph data
|
|
119
|
+
attr_reader :components
|
|
120
|
+
|
|
121
|
+
# Parse compound glyph data
|
|
122
|
+
#
|
|
123
|
+
# @param data [String] Binary glyph data
|
|
124
|
+
# @param glyph_id [Integer] Glyph ID for error reporting
|
|
125
|
+
# @return [CompoundGlyph] Parsed compound glyph
|
|
126
|
+
# @raise [Fontisan::CorruptedTableError] If data is insufficient or invalid
|
|
127
|
+
def self.parse(data, glyph_id)
|
|
128
|
+
glyph = new(glyph_id)
|
|
129
|
+
glyph.parse_data(data)
|
|
130
|
+
glyph
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Initialize a new compound glyph
|
|
134
|
+
#
|
|
135
|
+
# @param glyph_id [Integer] Glyph ID
|
|
136
|
+
def initialize(glyph_id)
|
|
137
|
+
@glyph_id = glyph_id
|
|
138
|
+
@components = []
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Parse glyph data
|
|
142
|
+
#
|
|
143
|
+
# @param data [String] Binary glyph data
|
|
144
|
+
# @raise [Fontisan::CorruptedTableError] If parsing fails
|
|
145
|
+
def parse_data(data)
|
|
146
|
+
io = StringIO.new(data)
|
|
147
|
+
io.set_encoding(Encoding::BINARY)
|
|
148
|
+
|
|
149
|
+
parse_header(io)
|
|
150
|
+
parse_components(io)
|
|
151
|
+
parse_instructions(io) if has_instructions?
|
|
152
|
+
|
|
153
|
+
validate_parsed_data!
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Check if this is a simple glyph
|
|
157
|
+
#
|
|
158
|
+
# @return [Boolean] Always false for CompoundGlyph
|
|
159
|
+
def simple?
|
|
160
|
+
false
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Check if this is a compound glyph
|
|
164
|
+
#
|
|
165
|
+
# @return [Boolean] Always true for CompoundGlyph
|
|
166
|
+
def compound?
|
|
167
|
+
true
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Check if glyph has no components
|
|
171
|
+
#
|
|
172
|
+
# @return [Boolean] True if no components
|
|
173
|
+
def empty?
|
|
174
|
+
components.empty?
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Get bounding box as array
|
|
178
|
+
#
|
|
179
|
+
# @return [Array<Integer>] Bounding box [xMin, yMin, xMax, yMax]
|
|
180
|
+
def bounding_box
|
|
181
|
+
[x_min, y_min, x_max, y_max]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Get all component glyph IDs (for dependency tracking)
|
|
185
|
+
#
|
|
186
|
+
# This method returns the glyph IDs of all components that make up
|
|
187
|
+
# this compound glyph. This is essential for subsetting operations,
|
|
188
|
+
# where all dependent glyphs must be included.
|
|
189
|
+
#
|
|
190
|
+
# @return [Array<Integer>] Array of component glyph IDs
|
|
191
|
+
#
|
|
192
|
+
# @example Getting component dependencies
|
|
193
|
+
# glyph = glyf.glyph_for(100, loca, head)
|
|
194
|
+
# if glyph.compound?
|
|
195
|
+
# deps = glyph.component_glyph_ids
|
|
196
|
+
# puts "Glyph 100 depends on: #{deps.join(', ')}"
|
|
197
|
+
# end
|
|
198
|
+
def component_glyph_ids
|
|
199
|
+
components.map(&:glyph_index)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Check if glyph uses a specific component
|
|
203
|
+
#
|
|
204
|
+
# @param glyph_id [Integer] Glyph ID to check
|
|
205
|
+
# @return [Boolean] True if glyph uses this component
|
|
206
|
+
def uses_component?(glyph_id)
|
|
207
|
+
component_glyph_ids.include?(glyph_id)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Get number of components
|
|
211
|
+
#
|
|
212
|
+
# @return [Integer] Component count
|
|
213
|
+
def num_components
|
|
214
|
+
components.length
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Check if any component uses metrics from referenced glyph
|
|
218
|
+
#
|
|
219
|
+
# @return [Boolean] True if any component has USE_MY_METRICS flag
|
|
220
|
+
def uses_component_metrics?
|
|
221
|
+
components.any?(&:use_my_metrics?)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Get the component that provides metrics (if any)
|
|
225
|
+
#
|
|
226
|
+
# @return [Component, nil] Component with USE_MY_METRICS flag, or nil
|
|
227
|
+
def metrics_component
|
|
228
|
+
components.find(&:use_my_metrics?)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
# Parse glyph header (10 bytes)
|
|
234
|
+
#
|
|
235
|
+
# @param io [StringIO] Input stream
|
|
236
|
+
# @raise [Fontisan::CorruptedTableError] If insufficient data
|
|
237
|
+
def parse_header(io)
|
|
238
|
+
header = io.read(10)
|
|
239
|
+
if header.nil? || header.length < 10
|
|
240
|
+
raise Fontisan::CorruptedTableError,
|
|
241
|
+
"Insufficient header data for compound glyph #{glyph_id}"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
values = header.unpack("n5")
|
|
245
|
+
num_contours = to_signed_16(values[0])
|
|
246
|
+
@x_min = to_signed_16(values[1])
|
|
247
|
+
@y_min = to_signed_16(values[2])
|
|
248
|
+
@x_max = to_signed_16(values[3])
|
|
249
|
+
@y_max = to_signed_16(values[4])
|
|
250
|
+
|
|
251
|
+
if num_contours != -1
|
|
252
|
+
raise Fontisan::CorruptedTableError,
|
|
253
|
+
"Compound glyph #{glyph_id} must have numberOfContours = -1, got #{num_contours}"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Parse all components
|
|
258
|
+
#
|
|
259
|
+
# @param io [StringIO] Input stream
|
|
260
|
+
# @raise [Fontisan::CorruptedTableError] If insufficient data
|
|
261
|
+
def parse_components(io)
|
|
262
|
+
loop do
|
|
263
|
+
component = parse_component(io)
|
|
264
|
+
@components << component
|
|
265
|
+
|
|
266
|
+
# Check if more components follow
|
|
267
|
+
break unless (component.flags & MORE_COMPONENTS) != 0
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Parse a single component
|
|
272
|
+
#
|
|
273
|
+
# @param io [StringIO] Input stream
|
|
274
|
+
# @return [Component] Parsed component
|
|
275
|
+
# @raise [Fontisan::CorruptedTableError] If insufficient data
|
|
276
|
+
def parse_component(io)
|
|
277
|
+
# Read flags and glyph index
|
|
278
|
+
header = io.read(4)
|
|
279
|
+
if header.nil? || header.length < 4
|
|
280
|
+
raise Fontisan::CorruptedTableError,
|
|
281
|
+
"Insufficient component header data for compound glyph #{glyph_id}"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
flags, glyph_index = header.unpack("n2")
|
|
285
|
+
|
|
286
|
+
# Parse arguments (position or point indices)
|
|
287
|
+
arg1, arg2 = parse_component_arguments(io, flags)
|
|
288
|
+
|
|
289
|
+
# Parse transformation (scale, rotation, or 2x2 matrix)
|
|
290
|
+
scale_x, scale_y, scale_01, scale_10 = parse_component_transformation(
|
|
291
|
+
io, flags
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
Component.new(
|
|
295
|
+
flags: flags,
|
|
296
|
+
glyph_index: glyph_index,
|
|
297
|
+
arg1: arg1,
|
|
298
|
+
arg2: arg2,
|
|
299
|
+
scale_x: scale_x,
|
|
300
|
+
scale_y: scale_y,
|
|
301
|
+
scale_01: scale_01,
|
|
302
|
+
scale_10: scale_10,
|
|
303
|
+
)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Parse component arguments (arg1, arg2)
|
|
307
|
+
#
|
|
308
|
+
# Arguments can be:
|
|
309
|
+
# - 8-bit or 16-bit depending on ARG_1_AND_2_ARE_WORDS flag
|
|
310
|
+
# - Interpreted as x,y offsets if ARGS_ARE_XY_VALUES is set
|
|
311
|
+
# - Otherwise interpreted as point indices for alignment
|
|
312
|
+
#
|
|
313
|
+
# @param io [StringIO] Input stream
|
|
314
|
+
# @param flags [Integer] Component flags
|
|
315
|
+
# @return [Array<Integer>] [arg1, arg2]
|
|
316
|
+
# @raise [Fontisan::CorruptedTableError] If insufficient data
|
|
317
|
+
def parse_component_arguments(io, flags)
|
|
318
|
+
if (flags & ARG_1_AND_2_ARE_WORDS).zero?
|
|
319
|
+
# 8-bit signed arguments
|
|
320
|
+
data = io.read(2)
|
|
321
|
+
if data.nil? || data.length < 2
|
|
322
|
+
raise Fontisan::CorruptedTableError,
|
|
323
|
+
"Insufficient argument data for compound glyph #{glyph_id}"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
values = data.unpack("C2")
|
|
327
|
+
[to_signed_8(values[0]), to_signed_8(values[1])]
|
|
328
|
+
else
|
|
329
|
+
# 16-bit signed arguments
|
|
330
|
+
data = io.read(4)
|
|
331
|
+
if data.nil? || data.length < 4
|
|
332
|
+
raise Fontisan::CorruptedTableError,
|
|
333
|
+
"Insufficient argument data for compound glyph #{glyph_id}"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
values = data.unpack("n2")
|
|
337
|
+
[to_signed_16(values[0]), to_signed_16(values[1])]
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Parse component transformation
|
|
342
|
+
#
|
|
343
|
+
# Transformation can be:
|
|
344
|
+
# - Uniform scale (1 value)
|
|
345
|
+
# - Separate x,y scale (2 values)
|
|
346
|
+
# - 2x2 affine matrix (4 values)
|
|
347
|
+
# - None (identity transformation)
|
|
348
|
+
#
|
|
349
|
+
# @param io [StringIO] Input stream
|
|
350
|
+
# @param flags [Integer] Component flags
|
|
351
|
+
# @return [Array<Float>] [scale_x, scale_y, scale_01, scale_10]
|
|
352
|
+
# @raise [Fontisan::CorruptedTableError] If insufficient data
|
|
353
|
+
def parse_component_transformation(io, flags)
|
|
354
|
+
if (flags & WE_HAVE_A_TWO_BY_TWO) != 0
|
|
355
|
+
# 2x2 transformation matrix (4 F2DOT14 values)
|
|
356
|
+
data = io.read(8)
|
|
357
|
+
if data.nil? || data.length < 8
|
|
358
|
+
raise Fontisan::CorruptedTableError,
|
|
359
|
+
"Insufficient 2x2 matrix data for compound glyph #{glyph_id}"
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
values = data.unpack("n4").map { |v| f2dot14_to_float(v) }
|
|
363
|
+
[values[0], values[3], values[1], values[2]] # [xscale, yscale, scale01, scale10]
|
|
364
|
+
elsif (flags & WE_HAVE_AN_X_AND_Y_SCALE) != 0
|
|
365
|
+
# Separate x and y scale (2 F2DOT14 values)
|
|
366
|
+
data = io.read(4)
|
|
367
|
+
if data.nil? || data.length < 4
|
|
368
|
+
raise Fontisan::CorruptedTableError,
|
|
369
|
+
"Insufficient x,y scale data for compound glyph #{glyph_id}"
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
values = data.unpack("n2").map { |v| f2dot14_to_float(v) }
|
|
373
|
+
[values[0], values[1], 0.0, 0.0]
|
|
374
|
+
elsif (flags & WE_HAVE_A_SCALE) != 0
|
|
375
|
+
# Uniform scale (1 F2DOT14 value)
|
|
376
|
+
data = io.read(2)
|
|
377
|
+
if data.nil? || data.length < 2
|
|
378
|
+
raise Fontisan::CorruptedTableError,
|
|
379
|
+
"Insufficient scale data for compound glyph #{glyph_id}"
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
scale = f2dot14_to_float(data.unpack1("n"))
|
|
383
|
+
[scale, scale, 0.0, 0.0]
|
|
384
|
+
else
|
|
385
|
+
# No transformation (identity)
|
|
386
|
+
[1.0, 1.0, 0.0, 0.0]
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Parse instructions if present
|
|
391
|
+
#
|
|
392
|
+
# @param io [StringIO] Input stream
|
|
393
|
+
# @raise [Fontisan::CorruptedTableError] If insufficient data
|
|
394
|
+
def parse_instructions(io)
|
|
395
|
+
length_data = io.read(2)
|
|
396
|
+
if length_data.nil? || length_data.length < 2
|
|
397
|
+
raise Fontisan::CorruptedTableError,
|
|
398
|
+
"Insufficient instruction length data for compound glyph #{glyph_id}"
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
@instruction_length = length_data.unpack1("n")
|
|
402
|
+
|
|
403
|
+
if @instruction_length.positive?
|
|
404
|
+
@instructions = io.read(@instruction_length)
|
|
405
|
+
if @instructions.nil? || @instructions.length < @instruction_length
|
|
406
|
+
raise Fontisan::CorruptedTableError,
|
|
407
|
+
"Insufficient instruction data for compound glyph #{glyph_id}"
|
|
408
|
+
end
|
|
409
|
+
else
|
|
410
|
+
@instructions = "".b
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Check if any component has instructions flag set
|
|
415
|
+
#
|
|
416
|
+
# @return [Boolean] True if instructions should be present
|
|
417
|
+
def has_instructions?
|
|
418
|
+
components.any? { |c| (c.flags & WE_HAVE_INSTRUCTIONS) != 0 }
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Validate parsed data consistency
|
|
422
|
+
#
|
|
423
|
+
# @raise [Fontisan::CorruptedTableError] If validation fails
|
|
424
|
+
def validate_parsed_data!
|
|
425
|
+
if components.empty?
|
|
426
|
+
raise Fontisan::CorruptedTableError,
|
|
427
|
+
"Compound glyph #{glyph_id} has no components"
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Check for duplicate USE_MY_METRICS flags
|
|
431
|
+
metrics_components = components.select(&:use_my_metrics?)
|
|
432
|
+
if metrics_components.length > 1
|
|
433
|
+
raise Fontisan::CorruptedTableError,
|
|
434
|
+
"Compound glyph #{glyph_id} has multiple components with USE_MY_METRICS flag"
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Validate component glyph indices
|
|
438
|
+
components.each_with_index do |component, i|
|
|
439
|
+
if component.glyph_index.nil? || component.glyph_index.negative?
|
|
440
|
+
raise Fontisan::CorruptedTableError,
|
|
441
|
+
"Invalid glyph index in component #{i} of compound glyph #{glyph_id}"
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Check for circular reference (component referencing self)
|
|
445
|
+
if component.glyph_index == glyph_id
|
|
446
|
+
raise Fontisan::CorruptedTableError,
|
|
447
|
+
"Circular reference: compound glyph #{glyph_id} references itself"
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Convert unsigned 16-bit value to signed
|
|
453
|
+
#
|
|
454
|
+
# @param value [Integer] Unsigned 16-bit value
|
|
455
|
+
# @return [Integer] Signed 16-bit value
|
|
456
|
+
def to_signed_16(value)
|
|
457
|
+
value > 0x7FFF ? value - 0x10000 : value
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Convert unsigned 8-bit value to signed
|
|
461
|
+
#
|
|
462
|
+
# @param value [Integer] Unsigned 8-bit value
|
|
463
|
+
# @return [Integer] Signed 8-bit value
|
|
464
|
+
def to_signed_8(value)
|
|
465
|
+
value > 0x7F ? value - 0x100 : value
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Convert F2DOT14 fixed-point to float
|
|
469
|
+
#
|
|
470
|
+
# F2DOT14 is a signed 2.14 fixed-point number:
|
|
471
|
+
# - 2 bits for integer part (including sign)
|
|
472
|
+
# - 14 bits for fractional part
|
|
473
|
+
# Range: -2.0 to ~1.99993896484375
|
|
474
|
+
#
|
|
475
|
+
# @param value [Integer] Unsigned 16-bit F2DOT14 value
|
|
476
|
+
# @return [Float] Float value
|
|
477
|
+
def f2dot14_to_float(value)
|
|
478
|
+
signed = to_signed_16(value)
|
|
479
|
+
signed / 16_384.0 # 2^14 = 16384
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../models/outline"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Tables
|
|
7
|
+
# Resolves compound glyphs into simple outlines
|
|
8
|
+
#
|
|
9
|
+
# [`CompoundGlyphResolver`](lib/fontisan/tables/glyf/compound_glyph_resolver.rb)
|
|
10
|
+
# handles the recursive resolution of compound (composite) glyphs in TrueType fonts.
|
|
11
|
+
# Compound glyphs are composed of references to other glyphs with transformation
|
|
12
|
+
# matrices applied. This resolver:
|
|
13
|
+
#
|
|
14
|
+
# - Recursively resolves component glyphs (which may themselves be compound)
|
|
15
|
+
# - Applies transformation matrices to each component
|
|
16
|
+
# - Merges all components into a single simple outline
|
|
17
|
+
# - Handles nested compound glyphs (compound glyphs referencing other compounds)
|
|
18
|
+
# - Detects and prevents circular references
|
|
19
|
+
#
|
|
20
|
+
# **Transformation Process:**
|
|
21
|
+
#
|
|
22
|
+
# Each component has a transformation matrix [a, b, c, d, e, f] representing:
|
|
23
|
+
# x' = a*x + c*y + e
|
|
24
|
+
# y' = b*x + d*y + f
|
|
25
|
+
#
|
|
26
|
+
# Where:
|
|
27
|
+
# - a, d: Scale factors for x and y
|
|
28
|
+
# - b, c: Rotation/skew components
|
|
29
|
+
# - e, f: Translation offsets (x, y)
|
|
30
|
+
#
|
|
31
|
+
# **Resolution Strategy:**
|
|
32
|
+
#
|
|
33
|
+
# 1. Start with compound glyph
|
|
34
|
+
# 2. For each component:
|
|
35
|
+
# a. Get component glyph (may be simple or compound)
|
|
36
|
+
# b. If compound, recursively resolve it first
|
|
37
|
+
# c. Apply component's transformation matrix
|
|
38
|
+
# d. Merge into result outline
|
|
39
|
+
# 3. Return merged simple outline
|
|
40
|
+
#
|
|
41
|
+
# @example Resolving a compound glyph
|
|
42
|
+
# resolver = CompoundGlyphResolver.new(glyf_table, loca_table, head_table)
|
|
43
|
+
# outline = resolver.resolve(compound_glyph)
|
|
44
|
+
#
|
|
45
|
+
# @example With circular reference detection
|
|
46
|
+
# visited = Set.new
|
|
47
|
+
# outline = resolver.resolve(compound_glyph, visited)
|
|
48
|
+
class CompoundGlyphResolver
|
|
49
|
+
# Maximum recursion depth to prevent infinite loops
|
|
50
|
+
MAX_DEPTH = 32
|
|
51
|
+
|
|
52
|
+
# @return [Glyf] The glyf table
|
|
53
|
+
attr_reader :glyf
|
|
54
|
+
|
|
55
|
+
# @return [Loca] The loca table
|
|
56
|
+
attr_reader :loca
|
|
57
|
+
|
|
58
|
+
# @return [Head] The head table
|
|
59
|
+
attr_reader :head
|
|
60
|
+
|
|
61
|
+
# Initialize resolver with required tables
|
|
62
|
+
#
|
|
63
|
+
# @param glyf [Glyf] Glyf table
|
|
64
|
+
# @param loca [Loca] Loca table
|
|
65
|
+
# @param head [Head] Head table
|
|
66
|
+
def initialize(glyf, loca, head)
|
|
67
|
+
@glyf = glyf
|
|
68
|
+
@loca = loca
|
|
69
|
+
@head = head
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Resolve a compound glyph into a simple outline
|
|
73
|
+
#
|
|
74
|
+
# @param compound_glyph [CompoundGlyph] Compound glyph to resolve
|
|
75
|
+
# @param visited [Set<Integer>] Set of visited glyph IDs (for circular ref detection)
|
|
76
|
+
# @param depth [Integer] Current recursion depth
|
|
77
|
+
# @return [Outline] Resolved simple outline
|
|
78
|
+
# @raise [Error] If circular reference detected or max depth exceeded
|
|
79
|
+
def resolve(compound_glyph, visited = Set.new, depth = 0)
|
|
80
|
+
# Check recursion depth
|
|
81
|
+
if depth > MAX_DEPTH
|
|
82
|
+
raise Fontisan::Error,
|
|
83
|
+
"Maximum recursion depth (#{MAX_DEPTH}) exceeded resolving compound glyph #{compound_glyph.glyph_id}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Check for circular reference
|
|
87
|
+
if visited.include?(compound_glyph.glyph_id)
|
|
88
|
+
raise Fontisan::Error,
|
|
89
|
+
"Circular reference detected in compound glyph #{compound_glyph.glyph_id}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Mark as visited
|
|
93
|
+
visited = visited.dup.add(compound_glyph.glyph_id)
|
|
94
|
+
|
|
95
|
+
# Start with empty merged outline
|
|
96
|
+
merged_outline = Models::Outline.new(
|
|
97
|
+
glyph_id: compound_glyph.glyph_id,
|
|
98
|
+
commands: [],
|
|
99
|
+
bbox: {
|
|
100
|
+
x_min: compound_glyph.x_min,
|
|
101
|
+
y_min: compound_glyph.y_min,
|
|
102
|
+
x_max: compound_glyph.x_max,
|
|
103
|
+
y_max: compound_glyph.y_max,
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Resolve each component
|
|
108
|
+
compound_glyph.components.each do |component|
|
|
109
|
+
# Get component glyph
|
|
110
|
+
component_glyph = glyf.glyph_for(component.glyph_index, loca, head)
|
|
111
|
+
|
|
112
|
+
# Skip empty components
|
|
113
|
+
next if component_glyph.nil? || component_glyph.empty?
|
|
114
|
+
|
|
115
|
+
# Get component outline (recursively if compound)
|
|
116
|
+
component_outline = if component_glyph.compound?
|
|
117
|
+
# Recursively resolve compound component
|
|
118
|
+
resolve(component_glyph, visited, depth + 1)
|
|
119
|
+
else
|
|
120
|
+
# Convert simple glyph to outline
|
|
121
|
+
Models::Outline.from_truetype(component_glyph, component.glyph_index)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Apply transformation matrix
|
|
125
|
+
matrix = component.transformation_matrix
|
|
126
|
+
transformed_outline = component_outline.transform(matrix)
|
|
127
|
+
|
|
128
|
+
# Merge into result
|
|
129
|
+
merged_outline.merge!(transformed_outline)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
merged_outline
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|