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,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Svg
|
|
5
|
+
# Generates SVG font-face element with font metadata
|
|
6
|
+
#
|
|
7
|
+
# [`FontFaceGenerator`](lib/fontisan/svg/font_face_generator.rb) extracts
|
|
8
|
+
# font metadata from font tables and formats it as SVG font-face attributes.
|
|
9
|
+
# This includes font family, style, weight, units-per-em, ascent, descent,
|
|
10
|
+
# and other font-level metrics.
|
|
11
|
+
#
|
|
12
|
+
# Responsibilities:
|
|
13
|
+
# - Extract font metadata from name, head, hhea, OS/2 tables
|
|
14
|
+
# - Format metadata as SVG font-face attributes
|
|
15
|
+
# - Handle missing or invalid metadata gracefully
|
|
16
|
+
# - Provide sensible defaults
|
|
17
|
+
#
|
|
18
|
+
# This class separates metadata extraction from XML generation, following
|
|
19
|
+
# separation of concerns principle.
|
|
20
|
+
#
|
|
21
|
+
# @example Generate font-face attributes
|
|
22
|
+
# generator = FontFaceGenerator.new(font)
|
|
23
|
+
# attributes = generator.generate_attributes
|
|
24
|
+
# # => { font_family: "Arial", units_per_em: 1000, ... }
|
|
25
|
+
class FontFaceGenerator
|
|
26
|
+
# @return [TrueTypeFont, OpenTypeFont] Font instance
|
|
27
|
+
attr_reader :font
|
|
28
|
+
|
|
29
|
+
# Initialize generator with font
|
|
30
|
+
#
|
|
31
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font to extract metadata from
|
|
32
|
+
# @raise [ArgumentError] If font is nil or invalid
|
|
33
|
+
def initialize(font)
|
|
34
|
+
raise ArgumentError, "Font cannot be nil" if font.nil?
|
|
35
|
+
|
|
36
|
+
unless font.respond_to?(:table)
|
|
37
|
+
raise ArgumentError, "Font must respond to :table method"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@font = font
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Generate font-face attributes
|
|
44
|
+
#
|
|
45
|
+
# Returns a hash of font-face attributes suitable for SVG rendering.
|
|
46
|
+
# All values are properly formatted for SVG.
|
|
47
|
+
#
|
|
48
|
+
# @return [Hash<Symbol, Object>] Font-face attributes
|
|
49
|
+
def generate_attributes
|
|
50
|
+
{
|
|
51
|
+
font_family: extract_font_family,
|
|
52
|
+
font_weight: extract_font_weight,
|
|
53
|
+
font_style: extract_font_style,
|
|
54
|
+
units_per_em: extract_units_per_em,
|
|
55
|
+
ascent: extract_ascent,
|
|
56
|
+
descent: extract_descent,
|
|
57
|
+
x_height: extract_x_height,
|
|
58
|
+
cap_height: extract_cap_height,
|
|
59
|
+
bbox: extract_bbox,
|
|
60
|
+
underline_position: extract_underline_position,
|
|
61
|
+
underline_thickness: extract_underline_thickness,
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Generate font-face element as XML string
|
|
66
|
+
#
|
|
67
|
+
# @param indent [String] Indentation string (default: " ")
|
|
68
|
+
# @return [String] XML font-face element
|
|
69
|
+
def generate_xml(indent: " ")
|
|
70
|
+
attrs = generate_attributes
|
|
71
|
+
|
|
72
|
+
# Build attribute string
|
|
73
|
+
attr_parts = []
|
|
74
|
+
attr_parts << "font-family=\"#{escape_xml(attrs[:font_family])}\""
|
|
75
|
+
attr_parts << "units-per-em=\"#{attrs[:units_per_em]}\""
|
|
76
|
+
attr_parts << "ascent=\"#{attrs[:ascent]}\""
|
|
77
|
+
attr_parts << "descent=\"#{attrs[:descent]}\""
|
|
78
|
+
|
|
79
|
+
# Optional attributes
|
|
80
|
+
attr_parts << "font-weight=\"#{attrs[:font_weight]}\"" if attrs[:font_weight]
|
|
81
|
+
attr_parts << "font-style=\"#{attrs[:font_style]}\"" if attrs[:font_style]
|
|
82
|
+
attr_parts << "x-height=\"#{attrs[:x_height]}\"" if attrs[:x_height]
|
|
83
|
+
attr_parts << "cap-height=\"#{attrs[:cap_height]}\"" if attrs[:cap_height]
|
|
84
|
+
attr_parts << "bbox=\"#{attrs[:bbox]}\"" if attrs[:bbox]
|
|
85
|
+
attr_parts << "underline-position=\"#{attrs[:underline_position]}\"" if attrs[:underline_position]
|
|
86
|
+
attr_parts << "underline-thickness=\"#{attrs[:underline_thickness]}\"" if attrs[:underline_thickness]
|
|
87
|
+
|
|
88
|
+
"#{indent}<font-face #{attr_parts.join(' ')}/>"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Extract font family name from name table
|
|
94
|
+
#
|
|
95
|
+
# @return [String] Font family name
|
|
96
|
+
def extract_font_family
|
|
97
|
+
name_table = font.table("name")
|
|
98
|
+
return "Unknown" unless name_table
|
|
99
|
+
|
|
100
|
+
# Try to get font family name (name ID 1)
|
|
101
|
+
family_name = name_table.font_family.first
|
|
102
|
+
return family_name if family_name && !family_name.empty?
|
|
103
|
+
|
|
104
|
+
# Fallback to full font name (name ID 4)
|
|
105
|
+
full_name = name_table.font_name.first
|
|
106
|
+
return full_name if full_name && !full_name.empty?
|
|
107
|
+
|
|
108
|
+
"Unknown"
|
|
109
|
+
rescue StandardError
|
|
110
|
+
"Unknown"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Extract font weight from OS/2 table
|
|
114
|
+
#
|
|
115
|
+
# @return [Integer, nil] Font weight (100-900) or nil
|
|
116
|
+
def extract_font_weight
|
|
117
|
+
os2 = font.table("OS/2")
|
|
118
|
+
return nil unless os2
|
|
119
|
+
|
|
120
|
+
weight = os2.weight_class
|
|
121
|
+
return nil unless weight&.positive?
|
|
122
|
+
|
|
123
|
+
weight
|
|
124
|
+
rescue StandardError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Extract font style from OS/2 or name table
|
|
129
|
+
#
|
|
130
|
+
# @return [String, nil] Font style ("normal", "italic", "oblique") or nil
|
|
131
|
+
def extract_font_style
|
|
132
|
+
os2 = font.table("OS/2")
|
|
133
|
+
if os2
|
|
134
|
+
# Check italic bit in fsSelection
|
|
135
|
+
fs_selection = os2.fs_selection
|
|
136
|
+
return "italic" if fs_selection && (fs_selection & 0x01) != 0
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Check name table for style
|
|
140
|
+
name_table = font.table("name")
|
|
141
|
+
if name_table
|
|
142
|
+
subfamily = name_table.font_subfamily.first
|
|
143
|
+
return "italic" if subfamily&.match?(/italic/i)
|
|
144
|
+
return "oblique" if subfamily&.match?(/oblique/i)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
"normal"
|
|
148
|
+
rescue StandardError
|
|
149
|
+
"normal"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Extract units per em from head table
|
|
153
|
+
#
|
|
154
|
+
# @return [Integer] Units per em (default: 1000)
|
|
155
|
+
def extract_units_per_em
|
|
156
|
+
head = font.table("head")
|
|
157
|
+
return 1000 unless head
|
|
158
|
+
|
|
159
|
+
units = head.units_per_em
|
|
160
|
+
return 1000 unless units
|
|
161
|
+
|
|
162
|
+
units.to_i
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Extract ascent from hhea table
|
|
166
|
+
#
|
|
167
|
+
# @return [Integer] Font ascent
|
|
168
|
+
def extract_ascent
|
|
169
|
+
hhea = font.table("hhea")
|
|
170
|
+
return 800 unless hhea
|
|
171
|
+
|
|
172
|
+
ascent = hhea.ascent
|
|
173
|
+
return 800 unless ascent
|
|
174
|
+
|
|
175
|
+
ascent.to_i
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Extract descent from hhea table
|
|
179
|
+
#
|
|
180
|
+
# @return [Integer] Font descent (typically negative)
|
|
181
|
+
def extract_descent
|
|
182
|
+
hhea = font.table("hhea")
|
|
183
|
+
return -200 unless hhea
|
|
184
|
+
|
|
185
|
+
descent = hhea.descent
|
|
186
|
+
return -200 unless descent
|
|
187
|
+
|
|
188
|
+
descent.to_i
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Extract x-height from OS/2 table
|
|
192
|
+
#
|
|
193
|
+
# @return [Integer, nil] X-height or nil
|
|
194
|
+
def extract_x_height
|
|
195
|
+
os2 = font.table("OS/2")
|
|
196
|
+
return nil unless os2
|
|
197
|
+
|
|
198
|
+
x_height = os2.x_height
|
|
199
|
+
return nil unless x_height&.positive?
|
|
200
|
+
|
|
201
|
+
x_height
|
|
202
|
+
rescue StandardError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Extract cap-height from OS/2 table
|
|
207
|
+
#
|
|
208
|
+
# @return [Integer, nil] Cap height or nil
|
|
209
|
+
def extract_cap_height
|
|
210
|
+
os2 = font.table("OS/2")
|
|
211
|
+
return nil unless os2
|
|
212
|
+
|
|
213
|
+
cap_height = os2.cap_height
|
|
214
|
+
return nil unless cap_height&.positive?
|
|
215
|
+
|
|
216
|
+
cap_height
|
|
217
|
+
rescue StandardError
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Extract bounding box from head table
|
|
222
|
+
#
|
|
223
|
+
# @return [String, nil] Bounding box "xMin yMin xMax yMax" or nil
|
|
224
|
+
def extract_bbox
|
|
225
|
+
head = font.table("head")
|
|
226
|
+
return nil unless head
|
|
227
|
+
|
|
228
|
+
# SVG font bbox format: "xMin yMin xMax yMax"
|
|
229
|
+
"#{head.x_min} #{head.y_min} #{head.x_max} #{head.y_max}"
|
|
230
|
+
rescue StandardError
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Extract underline position from post table
|
|
235
|
+
#
|
|
236
|
+
# @return [Integer, nil] Underline position or nil
|
|
237
|
+
def extract_underline_position
|
|
238
|
+
post = font.table("post")
|
|
239
|
+
return nil unless post
|
|
240
|
+
|
|
241
|
+
position = post.underline_position
|
|
242
|
+
return nil unless position
|
|
243
|
+
|
|
244
|
+
position
|
|
245
|
+
rescue StandardError
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Extract underline thickness from post table
|
|
250
|
+
#
|
|
251
|
+
# @return [Integer, nil] Underline thickness or nil
|
|
252
|
+
def extract_underline_thickness
|
|
253
|
+
post = font.table("post")
|
|
254
|
+
return nil unless post
|
|
255
|
+
|
|
256
|
+
thickness = post.underline_thickness
|
|
257
|
+
return nil unless thickness&.positive?
|
|
258
|
+
|
|
259
|
+
thickness
|
|
260
|
+
rescue StandardError
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Escape XML special characters
|
|
265
|
+
#
|
|
266
|
+
# @param text [String] Text to escape
|
|
267
|
+
# @return [String] Escaped text
|
|
268
|
+
def escape_xml(text)
|
|
269
|
+
text.to_s
|
|
270
|
+
.gsub("&", "&")
|
|
271
|
+
.gsub("<", "<")
|
|
272
|
+
.gsub(">", ">")
|
|
273
|
+
.gsub("\"", """)
|
|
274
|
+
.gsub("'", "'")
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "font_face_generator"
|
|
4
|
+
require_relative "glyph_generator"
|
|
5
|
+
require_relative "view_box_calculator"
|
|
6
|
+
|
|
7
|
+
module Fontisan
|
|
8
|
+
module Svg
|
|
9
|
+
# Generates complete SVG font XML structure
|
|
10
|
+
#
|
|
11
|
+
# [`FontGenerator`](lib/fontisan/svg/font_generator.rb) orchestrates all
|
|
12
|
+
# SVG font generation components to produce a complete SVG font document.
|
|
13
|
+
# It coordinates FontFaceGenerator, GlyphGenerator, and ViewBoxCalculator
|
|
14
|
+
# to build valid SVG font XML.
|
|
15
|
+
#
|
|
16
|
+
# Responsibilities:
|
|
17
|
+
# - Generate complete SVG font XML structure
|
|
18
|
+
# - Coordinate sub-generators (font-face, glyphs)
|
|
19
|
+
# - Create proper XML namespaces and structure
|
|
20
|
+
# - Handle font ID and default advance width
|
|
21
|
+
# - Format XML with proper indentation
|
|
22
|
+
#
|
|
23
|
+
# This is the main orchestrator for SVG font generation, following the
|
|
24
|
+
# single responsibility principle by delegating specific tasks to
|
|
25
|
+
# specialized generators.
|
|
26
|
+
#
|
|
27
|
+
# @example Generate complete SVG font
|
|
28
|
+
# generator = FontGenerator.new(font, glyph_data)
|
|
29
|
+
# svg_xml = generator.generate
|
|
30
|
+
# File.write("font.svg", svg_xml)
|
|
31
|
+
class FontGenerator
|
|
32
|
+
# @return [TrueTypeFont, OpenTypeFont] Font instance
|
|
33
|
+
attr_reader :font
|
|
34
|
+
|
|
35
|
+
# @return [Hash] Glyph data map (glyph_id => {outline, unicode, name, advance})
|
|
36
|
+
attr_reader :glyph_data
|
|
37
|
+
|
|
38
|
+
# @return [Hash] Generation options
|
|
39
|
+
attr_reader :options
|
|
40
|
+
|
|
41
|
+
# Initialize generator
|
|
42
|
+
#
|
|
43
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font to generate SVG for
|
|
44
|
+
# @param glyph_data [Hash] Glyph data map
|
|
45
|
+
# @param options [Hash] Generation options
|
|
46
|
+
# @option options [Boolean] :pretty_print Pretty print XML (default: true)
|
|
47
|
+
# @option options [String] :font_id Font ID for SVG (default: from font name)
|
|
48
|
+
# @option options [Integer] :default_advance Default advance width (default: 500)
|
|
49
|
+
# @raise [ArgumentError] If font or glyph_data is invalid
|
|
50
|
+
def initialize(font, glyph_data, options = {})
|
|
51
|
+
validate_parameters!(font, glyph_data)
|
|
52
|
+
|
|
53
|
+
@font = font
|
|
54
|
+
@glyph_data = glyph_data
|
|
55
|
+
@options = default_options.merge(options)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Generate complete SVG font XML
|
|
59
|
+
#
|
|
60
|
+
# Creates a complete SVG document with embedded font definition.
|
|
61
|
+
# The structure follows SVG 1.1 font specification.
|
|
62
|
+
#
|
|
63
|
+
# @return [String] Complete SVG font XML
|
|
64
|
+
def generate
|
|
65
|
+
parts = []
|
|
66
|
+
parts << xml_declaration
|
|
67
|
+
parts << svg_opening_tag
|
|
68
|
+
parts << " <defs>"
|
|
69
|
+
parts << generate_font_element
|
|
70
|
+
parts << " </defs>"
|
|
71
|
+
parts << svg_closing_tag
|
|
72
|
+
|
|
73
|
+
parts.join("\n")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Validate initialization parameters
|
|
79
|
+
#
|
|
80
|
+
# @param font [Object] Font to validate
|
|
81
|
+
# @param glyph_data [Object] Glyph data to validate
|
|
82
|
+
# @raise [ArgumentError] If validation fails
|
|
83
|
+
def validate_parameters!(font, glyph_data)
|
|
84
|
+
raise ArgumentError, "Font cannot be nil" if font.nil?
|
|
85
|
+
|
|
86
|
+
unless font.respond_to?(:table)
|
|
87
|
+
raise ArgumentError, "Font must respond to :table method"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
unless glyph_data.is_a?(Hash)
|
|
91
|
+
raise ArgumentError,
|
|
92
|
+
"glyph_data must be a Hash, got: #{glyph_data.class}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Get default options
|
|
97
|
+
#
|
|
98
|
+
# @return [Hash] Default options
|
|
99
|
+
def default_options
|
|
100
|
+
{
|
|
101
|
+
pretty_print: true,
|
|
102
|
+
font_id: nil,
|
|
103
|
+
default_advance: 500,
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Generate XML declaration
|
|
108
|
+
#
|
|
109
|
+
# @return [String] XML declaration
|
|
110
|
+
def xml_declaration
|
|
111
|
+
'<?xml version="1.0" encoding="UTF-8"?>'
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Generate SVG opening tag
|
|
115
|
+
#
|
|
116
|
+
# @return [String] SVG opening tag with namespaces
|
|
117
|
+
def svg_opening_tag
|
|
118
|
+
'<svg xmlns="http://www.w3.org/2000/svg">'
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Generate SVG closing tag
|
|
122
|
+
#
|
|
123
|
+
# @return [String] SVG closing tag
|
|
124
|
+
def svg_closing_tag
|
|
125
|
+
"</svg>"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Generate font element with all glyphs
|
|
129
|
+
#
|
|
130
|
+
# @return [String] Complete font element
|
|
131
|
+
def generate_font_element
|
|
132
|
+
parts = []
|
|
133
|
+
|
|
134
|
+
# Font opening tag
|
|
135
|
+
font_id = options[:font_id] || extract_font_id
|
|
136
|
+
default_advance = options[:default_advance]
|
|
137
|
+
parts << " <font id=\"#{escape_xml(font_id)}\" horiz-adv-x=\"#{default_advance}\">"
|
|
138
|
+
|
|
139
|
+
# Font-face element
|
|
140
|
+
parts << generate_font_face
|
|
141
|
+
|
|
142
|
+
# Missing glyph
|
|
143
|
+
parts << generate_missing_glyph
|
|
144
|
+
|
|
145
|
+
# All glyphs
|
|
146
|
+
parts << generate_glyphs
|
|
147
|
+
|
|
148
|
+
# Font closing tag
|
|
149
|
+
parts << " </font>"
|
|
150
|
+
|
|
151
|
+
parts.join("\n")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Generate font-face element
|
|
155
|
+
#
|
|
156
|
+
# @return [String] Font-face XML
|
|
157
|
+
def generate_font_face
|
|
158
|
+
face_generator = FontFaceGenerator.new(font)
|
|
159
|
+
face_generator.generate_xml(indent: " ")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Generate missing-glyph element
|
|
163
|
+
#
|
|
164
|
+
# @return [String] Missing-glyph XML
|
|
165
|
+
def generate_missing_glyph
|
|
166
|
+
calculator = create_calculator
|
|
167
|
+
glyph_generator = GlyphGenerator.new(calculator)
|
|
168
|
+
glyph_generator.generate_missing_glyph(
|
|
169
|
+
advance_width: options[:default_advance],
|
|
170
|
+
indent: " ",
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Generate all glyph elements
|
|
175
|
+
#
|
|
176
|
+
# @return [String] All glyph XML elements
|
|
177
|
+
def generate_glyphs
|
|
178
|
+
calculator = create_calculator
|
|
179
|
+
glyph_generator = GlyphGenerator.new(calculator)
|
|
180
|
+
|
|
181
|
+
glyph_xmls = glyph_data.map do |_glyph_id, data|
|
|
182
|
+
next unless data[:outline]
|
|
183
|
+
|
|
184
|
+
glyph_generator.generate_glyph_xml(
|
|
185
|
+
data[:outline],
|
|
186
|
+
unicode: data[:unicode],
|
|
187
|
+
glyph_name: data[:name],
|
|
188
|
+
advance_width: data[:advance],
|
|
189
|
+
indent: " ",
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
glyph_xmls.compact.join("\n")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Create ViewBoxCalculator
|
|
197
|
+
#
|
|
198
|
+
# @return [ViewBoxCalculator] Calculator instance
|
|
199
|
+
def create_calculator
|
|
200
|
+
head = font.table("head")
|
|
201
|
+
hhea = font.table("hhea")
|
|
202
|
+
|
|
203
|
+
units_per_em = head&.units_per_em&.to_i || 1000
|
|
204
|
+
ascent = hhea&.ascent&.to_i || 800
|
|
205
|
+
descent = hhea&.descent&.to_i || -200
|
|
206
|
+
|
|
207
|
+
ViewBoxCalculator.new(
|
|
208
|
+
units_per_em: units_per_em,
|
|
209
|
+
ascent: ascent,
|
|
210
|
+
descent: descent,
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Extract font ID from font name
|
|
215
|
+
#
|
|
216
|
+
# @return [String] Font ID
|
|
217
|
+
def extract_font_id
|
|
218
|
+
name_table = font.table("name")
|
|
219
|
+
return "Font" unless name_table
|
|
220
|
+
|
|
221
|
+
# Try PostScript name first (name ID 6)
|
|
222
|
+
ps_name = name_table.postscript_name.first
|
|
223
|
+
return sanitize_font_id(ps_name) if ps_name && !ps_name.empty?
|
|
224
|
+
|
|
225
|
+
# Fallback to font family (name ID 1)
|
|
226
|
+
family_name = name_table.font_family.first
|
|
227
|
+
return sanitize_font_id(family_name) if family_name && !family_name.empty?
|
|
228
|
+
|
|
229
|
+
"Font"
|
|
230
|
+
rescue StandardError
|
|
231
|
+
"Font"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Sanitize font ID for XML
|
|
235
|
+
#
|
|
236
|
+
# @param name [String] Font name
|
|
237
|
+
# @return [String] Sanitized ID
|
|
238
|
+
def sanitize_font_id(name)
|
|
239
|
+
# Remove invalid XML ID characters
|
|
240
|
+
# XML IDs must start with letter or underscore
|
|
241
|
+
# Can contain letters, digits, hyphens, underscores, periods
|
|
242
|
+
sanitized = name.gsub(/[^a-zA-Z0-9\-_.]/, "_")
|
|
243
|
+
|
|
244
|
+
# Ensure starts with letter or underscore
|
|
245
|
+
sanitized = "_#{sanitized}" if /\A[^a-zA-Z_]/.match?(sanitized)
|
|
246
|
+
|
|
247
|
+
sanitized
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Escape XML special characters
|
|
251
|
+
#
|
|
252
|
+
# @param text [String] Text to escape
|
|
253
|
+
# @return [String] Escaped text
|
|
254
|
+
def escape_xml(text)
|
|
255
|
+
text.to_s
|
|
256
|
+
.gsub("&", "&")
|
|
257
|
+
.gsub("<", "<")
|
|
258
|
+
.gsub(">", ">")
|
|
259
|
+
.gsub("\"", """)
|
|
260
|
+
.gsub("'", "'")
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "view_box_calculator"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Svg
|
|
7
|
+
# Generates SVG glyph elements from glyph outlines
|
|
8
|
+
#
|
|
9
|
+
# [`GlyphGenerator`](lib/fontisan/svg/glyph_generator.rb) converts
|
|
10
|
+
# [`GlyphOutline`](lib/fontisan/models/glyph_outline.rb) objects to SVG
|
|
11
|
+
# `<glyph>` elements with proper path data and coordinate transformations.
|
|
12
|
+
#
|
|
13
|
+
# Responsibilities:
|
|
14
|
+
# - Transform glyph outline to SVG path with Y-axis flip
|
|
15
|
+
# - Generate SVG glyph element with attributes
|
|
16
|
+
# - Handle unicode and glyph name mapping
|
|
17
|
+
# - Calculate horizontal advance width
|
|
18
|
+
# - Format path data with proper precision
|
|
19
|
+
#
|
|
20
|
+
# This class uses ViewBoxCalculator for coordinate transformations and
|
|
21
|
+
# GlyphOutline's to_svg_path method for path generation.
|
|
22
|
+
#
|
|
23
|
+
# @example Generate SVG glyph element
|
|
24
|
+
# generator = GlyphGenerator.new(calculator)
|
|
25
|
+
# xml = generator.generate_glyph_xml(outline, unicode: "A", advance_width: 600)
|
|
26
|
+
class GlyphGenerator
|
|
27
|
+
# @return [ViewBoxCalculator] Coordinate calculator
|
|
28
|
+
attr_reader :calculator
|
|
29
|
+
|
|
30
|
+
# Initialize generator with calculator
|
|
31
|
+
#
|
|
32
|
+
# @param calculator [ViewBoxCalculator] Coordinate transformation calculator
|
|
33
|
+
# @raise [ArgumentError] If calculator is nil
|
|
34
|
+
def initialize(calculator)
|
|
35
|
+
raise ArgumentError, "Calculator cannot be nil" if calculator.nil?
|
|
36
|
+
|
|
37
|
+
@calculator = calculator
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Generate SVG glyph element
|
|
41
|
+
#
|
|
42
|
+
# @param outline [Models::GlyphOutline] Glyph outline
|
|
43
|
+
# @param unicode [String, nil] Unicode character
|
|
44
|
+
# @param glyph_name [String, nil] Glyph name
|
|
45
|
+
# @param advance_width [Integer] Horizontal advance width
|
|
46
|
+
# @param indent [String] Indentation string
|
|
47
|
+
# @return [String] XML glyph element
|
|
48
|
+
def generate_glyph_xml(outline, unicode: nil, glyph_name: nil,
|
|
49
|
+
advance_width: 0, indent: " ")
|
|
50
|
+
# Build attribute parts
|
|
51
|
+
attr_parts = []
|
|
52
|
+
|
|
53
|
+
attr_parts << "unicode=\"#{escape_xml(unicode)}\"" if unicode
|
|
54
|
+
attr_parts << "glyph-name=\"#{escape_xml(glyph_name)}\"" if glyph_name
|
|
55
|
+
attr_parts << "horiz-adv-x=\"#{advance_width}\"" if advance_width&.positive?
|
|
56
|
+
|
|
57
|
+
# Generate SVG path with Y-axis transformation
|
|
58
|
+
path_data = generate_svg_path(outline)
|
|
59
|
+
attr_parts << "d=\"#{path_data}\"" if path_data && !path_data.empty?
|
|
60
|
+
|
|
61
|
+
"#{indent}<glyph #{attr_parts.join(' ')}/>"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Generate missing-glyph element
|
|
65
|
+
#
|
|
66
|
+
# @param advance_width [Integer] Default advance width
|
|
67
|
+
# @param indent [String] Indentation string
|
|
68
|
+
# @return [String] XML missing-glyph element
|
|
69
|
+
def generate_missing_glyph(advance_width: 500, indent: " ")
|
|
70
|
+
"#{indent}<missing-glyph horiz-adv-x=\"#{advance_width}\"/>"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Generate SVG path data with coordinate transformation
|
|
74
|
+
#
|
|
75
|
+
# Transforms the glyph outline from font space to SVG space by flipping
|
|
76
|
+
# the Y-axis. Font coordinates use Y-up (ascender positive), while SVG
|
|
77
|
+
# uses Y-down (origin at top).
|
|
78
|
+
#
|
|
79
|
+
# @param outline [Models::GlyphOutline] Glyph outline
|
|
80
|
+
# @return [String] SVG path data
|
|
81
|
+
def generate_svg_path(outline)
|
|
82
|
+
return "" if outline.empty?
|
|
83
|
+
|
|
84
|
+
path_parts = outline.contours.map do |contour|
|
|
85
|
+
build_transformed_contour_path(contour)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
path_parts.join(" ")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Build SVG path for a contour with Y-axis transformation
|
|
94
|
+
#
|
|
95
|
+
# @param contour [Array<Hash>] Array of point hashes
|
|
96
|
+
# @return [String] SVG path string for this contour
|
|
97
|
+
def build_transformed_contour_path(contour)
|
|
98
|
+
return "" if contour.empty?
|
|
99
|
+
|
|
100
|
+
parts = []
|
|
101
|
+
i = 0
|
|
102
|
+
|
|
103
|
+
# Move to first point (with Y-axis flip)
|
|
104
|
+
first = contour[i]
|
|
105
|
+
svg_y = calculator.transform_y(first[:y])
|
|
106
|
+
parts << "M #{first[:x]} #{svg_y}"
|
|
107
|
+
i += 1
|
|
108
|
+
|
|
109
|
+
# Process remaining points
|
|
110
|
+
while i < contour.length
|
|
111
|
+
point = contour[i]
|
|
112
|
+
|
|
113
|
+
if point[:on_curve]
|
|
114
|
+
# Line to on-curve point (with Y-axis flip)
|
|
115
|
+
svg_y = calculator.transform_y(point[:y])
|
|
116
|
+
parts << "L #{point[:x]} #{svg_y}"
|
|
117
|
+
i += 1
|
|
118
|
+
else
|
|
119
|
+
# Off-curve point - quadratic curve control point
|
|
120
|
+
control = point
|
|
121
|
+
control_svg_y = calculator.transform_y(control[:y])
|
|
122
|
+
i += 1
|
|
123
|
+
|
|
124
|
+
if i < contour.length && !contour[i][:on_curve]
|
|
125
|
+
# Two consecutive off-curve points
|
|
126
|
+
# Implied on-curve point at midpoint
|
|
127
|
+
next_control = contour[i]
|
|
128
|
+
implied_x = (control[:x] + next_control[:x]) / 2.0
|
|
129
|
+
implied_y = (control[:y] + next_control[:y]) / 2.0
|
|
130
|
+
implied_svg_y = calculator.transform_y(implied_y)
|
|
131
|
+
parts << "Q #{control[:x]} #{control_svg_y} #{implied_x} #{implied_svg_y}"
|
|
132
|
+
elsif i < contour.length
|
|
133
|
+
# Next point is on-curve - end of quadratic curve
|
|
134
|
+
end_point = contour[i]
|
|
135
|
+
end_svg_y = calculator.transform_y(end_point[:y])
|
|
136
|
+
parts << "Q #{control[:x]} #{control_svg_y} #{end_point[:x]} #{end_svg_y}"
|
|
137
|
+
i += 1
|
|
138
|
+
else
|
|
139
|
+
# Off-curve point is last - curves back to first point
|
|
140
|
+
first_svg_y = calculator.transform_y(first[:y])
|
|
141
|
+
parts << "Q #{control[:x]} #{control_svg_y} #{first[:x]} #{first_svg_y}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Close path
|
|
147
|
+
parts << "Z"
|
|
148
|
+
|
|
149
|
+
parts.join(" ")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Escape XML special characters
|
|
153
|
+
#
|
|
154
|
+
# @param text [String, nil] Text to escape
|
|
155
|
+
# @return [String] Escaped text
|
|
156
|
+
def escape_xml(text)
|
|
157
|
+
return "" if text.nil?
|
|
158
|
+
|
|
159
|
+
text.to_s
|
|
160
|
+
.gsub("&", "&")
|
|
161
|
+
.gsub("<", "<")
|
|
162
|
+
.gsub(">", ">")
|
|
163
|
+
.gsub("\"", """)
|
|
164
|
+
.gsub("'", "'")
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|