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,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Models
|
|
7
|
+
# FontExport represents complete font structure for export to YAML/JSON
|
|
8
|
+
#
|
|
9
|
+
# This model encapsulates the entire font structure including header
|
|
10
|
+
# information, all tables (parsed and binary), and metadata. It supports
|
|
11
|
+
# round-trip conversion: font → export → import → font.
|
|
12
|
+
#
|
|
13
|
+
# @example Exporting a font
|
|
14
|
+
# export = FontExport.new(source_file: "font.ttf")
|
|
15
|
+
# export.extract_from_font(font)
|
|
16
|
+
# yaml_output = export.to_yaml
|
|
17
|
+
#
|
|
18
|
+
# @example Importing from YAML
|
|
19
|
+
# export = FontExport.from_yaml(yaml_string)
|
|
20
|
+
# font = export.rebuild_font
|
|
21
|
+
class FontExport < Lutaml::Model::Serializable
|
|
22
|
+
# Metadata about the export
|
|
23
|
+
class Metadata < Lutaml::Model::Serializable
|
|
24
|
+
attribute :source_file, :string
|
|
25
|
+
attribute :export_date, :string
|
|
26
|
+
attribute :exporter_version, :string
|
|
27
|
+
attribute :font_format, :string
|
|
28
|
+
|
|
29
|
+
yaml do
|
|
30
|
+
map "source_file", to: :source_file
|
|
31
|
+
map "export_date", to: :export_date
|
|
32
|
+
map "exporter_version", to: :exporter_version
|
|
33
|
+
map "font_format", to: :font_format
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
json do
|
|
37
|
+
map "source_file", to: :source_file
|
|
38
|
+
map "export_date", to: :export_date
|
|
39
|
+
map "exporter_version", to: :exporter_version
|
|
40
|
+
map "font_format", to: :font_format
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Font header information
|
|
45
|
+
class Header < Lutaml::Model::Serializable
|
|
46
|
+
attribute :sfnt_version, :string
|
|
47
|
+
attribute :num_tables, :integer
|
|
48
|
+
attribute :search_range, :integer
|
|
49
|
+
attribute :entry_selector, :integer
|
|
50
|
+
attribute :range_shift, :integer
|
|
51
|
+
|
|
52
|
+
yaml do
|
|
53
|
+
map "sfnt_version", to: :sfnt_version
|
|
54
|
+
map "num_tables", to: :num_tables
|
|
55
|
+
map "search_range", to: :search_range
|
|
56
|
+
map "entry_selector", to: :entry_selector
|
|
57
|
+
map "range_shift", to: :range_shift
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
json do
|
|
61
|
+
map "sfnt_version", to: :sfnt_version
|
|
62
|
+
map "num_tables", to: :num_tables
|
|
63
|
+
map "search_range", to: :search_range
|
|
64
|
+
map "entry_selector", to: :entry_selector
|
|
65
|
+
map "range_shift", to: :range_shift
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Individual table export
|
|
70
|
+
class TableExport < Lutaml::Model::Serializable
|
|
71
|
+
attribute :tag, :string
|
|
72
|
+
attribute :checksum, :string
|
|
73
|
+
attribute :parsed, :boolean, default: -> { false }
|
|
74
|
+
attribute :data, :string, default: -> {}
|
|
75
|
+
attribute :fields, :string, default: -> {}
|
|
76
|
+
|
|
77
|
+
yaml do
|
|
78
|
+
map "tag", to: :tag
|
|
79
|
+
map "checksum", to: :checksum
|
|
80
|
+
map "parsed", to: :parsed
|
|
81
|
+
map "data", to: :data
|
|
82
|
+
map "fields", to: :fields
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
json do
|
|
86
|
+
map "tag", to: :tag
|
|
87
|
+
map "checksum", to: :checksum
|
|
88
|
+
map "parsed", to: :parsed
|
|
89
|
+
map "data", to: :data
|
|
90
|
+
map "fields", to: :fields
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
attribute :metadata, Metadata
|
|
95
|
+
attribute :header, Header
|
|
96
|
+
attribute :tables, TableExport, collection: true, default: -> { [] }
|
|
97
|
+
|
|
98
|
+
yaml do
|
|
99
|
+
map "metadata", to: :metadata
|
|
100
|
+
map "header", to: :header
|
|
101
|
+
map "tables", to: :tables
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
json do
|
|
105
|
+
map "metadata", to: :metadata
|
|
106
|
+
map "header", to: :header
|
|
107
|
+
map "tables", to: :tables
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Find a table by tag
|
|
111
|
+
#
|
|
112
|
+
# @param tag [String] The table tag (e.g., "head", "name")
|
|
113
|
+
# @return [TableExport, nil] The table or nil if not found
|
|
114
|
+
def find_table(tag)
|
|
115
|
+
tables.find { |t| t.tag == tag }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get all parsed tables
|
|
119
|
+
#
|
|
120
|
+
# @return [Array<TableExport>] Array of parsed tables
|
|
121
|
+
def parsed_tables
|
|
122
|
+
tables.select(&:parsed)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Get all binary-only tables
|
|
126
|
+
#
|
|
127
|
+
# @return [Array<TableExport>] Array of binary tables
|
|
128
|
+
def binary_tables
|
|
129
|
+
tables.reject(&:parsed)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Add a table to the export
|
|
133
|
+
#
|
|
134
|
+
# @param tag [String] Table tag
|
|
135
|
+
# @param checksum [String] Table checksum
|
|
136
|
+
# @param parsed [Boolean] Whether table is parsed
|
|
137
|
+
# @param data [String, nil] Binary data (hex/base64)
|
|
138
|
+
# @param fields [Hash, nil] Parsed fields as Hash/JSON
|
|
139
|
+
# @return [void]
|
|
140
|
+
def add_table(tag:, checksum:, parsed: false, data: nil, fields: nil)
|
|
141
|
+
tables << TableExport.new(
|
|
142
|
+
tag: tag,
|
|
143
|
+
checksum: checksum,
|
|
144
|
+
parsed: parsed,
|
|
145
|
+
data: data,
|
|
146
|
+
fields: fields,
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Validate export structure
|
|
151
|
+
#
|
|
152
|
+
# @return [Boolean] True if export is valid
|
|
153
|
+
def valid?
|
|
154
|
+
!metadata.nil? && !header.nil? && !tables.empty?
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Models
|
|
7
|
+
# Model for quick font summary
|
|
8
|
+
#
|
|
9
|
+
# Represents a brief overview of an individual font file.
|
|
10
|
+
# Used by LsCommand when operating on TTF/OTF files.
|
|
11
|
+
#
|
|
12
|
+
# @example Creating a font summary
|
|
13
|
+
# summary = FontSummary.new(
|
|
14
|
+
# font_path: "font.ttf",
|
|
15
|
+
# family_name: "Helvetica",
|
|
16
|
+
# subfamily_name: "Regular",
|
|
17
|
+
# font_format: "TrueType",
|
|
18
|
+
# num_glyphs: 268,
|
|
19
|
+
# num_tables: 14
|
|
20
|
+
# )
|
|
21
|
+
class FontSummary < Lutaml::Model::Serializable
|
|
22
|
+
attribute :font_path, :string
|
|
23
|
+
attribute :family_name, :string
|
|
24
|
+
attribute :subfamily_name, :string
|
|
25
|
+
attribute :font_format, :string
|
|
26
|
+
attribute :num_glyphs, :integer
|
|
27
|
+
attribute :num_tables, :integer
|
|
28
|
+
|
|
29
|
+
yaml do
|
|
30
|
+
map "font_path", to: :font_path
|
|
31
|
+
map "family_name", to: :family_name
|
|
32
|
+
map "subfamily_name", to: :subfamily_name
|
|
33
|
+
map "font_format", to: :font_format
|
|
34
|
+
map "num_glyphs", to: :num_glyphs
|
|
35
|
+
map "num_tables", to: :num_tables
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
json do
|
|
39
|
+
map "font_path", to: :font_path
|
|
40
|
+
map "family_name", to: :family_name
|
|
41
|
+
map "subfamily_name", to: :subfamily_name
|
|
42
|
+
map "font_format", to: :font_format
|
|
43
|
+
map "num_glyphs", to: :num_glyphs
|
|
44
|
+
map "num_tables", to: :num_tables
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Models
|
|
5
|
+
# Represents a glyph's outline data with conversion capabilities
|
|
6
|
+
#
|
|
7
|
+
# [`GlyphOutline`](lib/fontisan/models/glyph_outline.rb) is a pure data
|
|
8
|
+
# model that stores glyph outline information extracted from font tables.
|
|
9
|
+
# It provides methods to convert the outline data to various formats
|
|
10
|
+
# (SVG paths, drawing commands) for rendering and manipulation.
|
|
11
|
+
#
|
|
12
|
+
# The outline consists of:
|
|
13
|
+
# - Contours: Array of closed paths, each containing points
|
|
14
|
+
# - Points: All points from all contours (flattened for easy access)
|
|
15
|
+
# - Bounding box: The glyph's bounding rectangle
|
|
16
|
+
# - Glyph ID: The identifier of this glyph
|
|
17
|
+
#
|
|
18
|
+
# This class is immutable after construction to ensure data integrity.
|
|
19
|
+
#
|
|
20
|
+
# @example Creating an outline
|
|
21
|
+
# outline = Fontisan::Models::GlyphOutline.new(
|
|
22
|
+
# glyph_id: 65,
|
|
23
|
+
# contours: [
|
|
24
|
+
# [
|
|
25
|
+
# { x: 100, y: 0, on_curve: true },
|
|
26
|
+
# { x: 200, y: 700, on_curve: true },
|
|
27
|
+
# { x: 300, y: 0, on_curve: true }
|
|
28
|
+
# ]
|
|
29
|
+
# ],
|
|
30
|
+
# bbox: { x_min: 100, y_min: 0, x_max: 300, y_max: 700 }
|
|
31
|
+
# )
|
|
32
|
+
#
|
|
33
|
+
# @example Converting to SVG
|
|
34
|
+
# svg_path = outline.to_svg_path
|
|
35
|
+
# # => "M 100 0 L 200 700 L 300 0 Z"
|
|
36
|
+
#
|
|
37
|
+
# @example Getting drawing commands
|
|
38
|
+
# commands = outline.to_commands
|
|
39
|
+
# # => [[:move_to, 100, 0], [:line_to, 200, 700], [:line_to, 300, 0], [:close_path]]
|
|
40
|
+
#
|
|
41
|
+
# Reference: [`docs/GETTING_STARTED.md:66-121`](docs/GETTING_STARTED.md:66)
|
|
42
|
+
class GlyphOutline
|
|
43
|
+
# @return [Integer] The glyph identifier
|
|
44
|
+
attr_reader :glyph_id
|
|
45
|
+
|
|
46
|
+
# @return [Array<Array<Hash>>] Array of contours, each containing points
|
|
47
|
+
# Each point hash has keys: :x, :y, :on_curve
|
|
48
|
+
attr_reader :contours
|
|
49
|
+
|
|
50
|
+
# @return [Array<Hash>] All points from all contours (flattened)
|
|
51
|
+
attr_reader :points
|
|
52
|
+
|
|
53
|
+
# @return [Hash] Bounding box with keys: :x_min, :y_min, :x_max, :y_max
|
|
54
|
+
attr_reader :bbox
|
|
55
|
+
|
|
56
|
+
# Initialize a new glyph outline
|
|
57
|
+
#
|
|
58
|
+
# @param glyph_id [Integer] The glyph identifier
|
|
59
|
+
# @param contours [Array<Array<Hash>>] Array of contours, each containing points
|
|
60
|
+
# Each point must have :x, :y, and :on_curve keys
|
|
61
|
+
# @param bbox [Hash] Bounding box with :x_min, :y_min, :x_max, :y_max keys
|
|
62
|
+
# @raise [ArgumentError] If required parameters are missing or invalid
|
|
63
|
+
def initialize(glyph_id:, contours:, bbox:)
|
|
64
|
+
validate_parameters!(glyph_id, contours, bbox)
|
|
65
|
+
|
|
66
|
+
@glyph_id = glyph_id.freeze
|
|
67
|
+
@contours = deep_freeze(contours)
|
|
68
|
+
@points = extract_all_points(contours).freeze
|
|
69
|
+
@bbox = bbox.freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Convert outline to SVG path data
|
|
73
|
+
#
|
|
74
|
+
# Generates SVG path commands from the outline contours. Each contour
|
|
75
|
+
# becomes a closed path, with move_to for the first point, line_to or
|
|
76
|
+
# curve_to for subsequent points, and an explicit close path.
|
|
77
|
+
#
|
|
78
|
+
# @return [String] SVG path commands (e.g., "M 100 0 L 200 700 Z")
|
|
79
|
+
def to_svg_path
|
|
80
|
+
return "" if empty?
|
|
81
|
+
|
|
82
|
+
path_parts = contours.map do |contour|
|
|
83
|
+
build_contour_path(contour)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
path_parts.join(" ")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Convert to drawing commands
|
|
90
|
+
#
|
|
91
|
+
# Returns an array of drawing command arrays that can be used to render
|
|
92
|
+
# the glyph. Each command is an array with the command type as the first
|
|
93
|
+
# element and coordinates as subsequent elements.
|
|
94
|
+
#
|
|
95
|
+
# Command types:
|
|
96
|
+
# - :move_to - Move to a point without drawing
|
|
97
|
+
# - :line_to - Draw a straight line to a point
|
|
98
|
+
# - :curve_to - Draw a quadratic Bézier curve (TrueType) or cubic curve (CFF)
|
|
99
|
+
# - :close_path - Close the current path
|
|
100
|
+
#
|
|
101
|
+
# @return [Array<Array>] Array of [command, *args] arrays
|
|
102
|
+
#
|
|
103
|
+
# @example
|
|
104
|
+
# commands = outline.to_commands
|
|
105
|
+
# # => [
|
|
106
|
+
# # [:move_to, 100, 0],
|
|
107
|
+
# # [:line_to, 200, 700],
|
|
108
|
+
# # [:line_to, 300, 0],
|
|
109
|
+
# # [:close_path]
|
|
110
|
+
# # ]
|
|
111
|
+
def to_commands
|
|
112
|
+
return [] if empty?
|
|
113
|
+
|
|
114
|
+
commands = []
|
|
115
|
+
contours.each do |contour|
|
|
116
|
+
commands.concat(build_contour_commands(contour))
|
|
117
|
+
end
|
|
118
|
+
commands
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Check if outline is empty (e.g., space glyph)
|
|
122
|
+
#
|
|
123
|
+
# @return [Boolean] True if the glyph has no contours
|
|
124
|
+
def empty?
|
|
125
|
+
contours.empty?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Number of points in outline
|
|
129
|
+
#
|
|
130
|
+
# @return [Integer] Total number of points across all contours
|
|
131
|
+
def point_count
|
|
132
|
+
points.length
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Number of contours in outline
|
|
136
|
+
#
|
|
137
|
+
# @return [Integer] Number of contours
|
|
138
|
+
def contour_count
|
|
139
|
+
contours.length
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# String representation for debugging
|
|
143
|
+
#
|
|
144
|
+
# @return [String] Human-readable representation
|
|
145
|
+
def to_s
|
|
146
|
+
"#<#{self.class.name} glyph_id=#{glyph_id} " \
|
|
147
|
+
"contours=#{contour_count} points=#{point_count} " \
|
|
148
|
+
"bbox=#{bbox.inspect}>"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
alias inspect to_s
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
# Validate initialization parameters
|
|
156
|
+
#
|
|
157
|
+
# @param glyph_id [Integer] Glyph ID to validate
|
|
158
|
+
# @param contours [Array] Contours to validate
|
|
159
|
+
# @param bbox [Hash] Bounding box to validate
|
|
160
|
+
# @raise [ArgumentError] If validation fails
|
|
161
|
+
def validate_parameters!(glyph_id, contours, bbox)
|
|
162
|
+
if glyph_id.nil? || !glyph_id.is_a?(Integer) || glyph_id.negative?
|
|
163
|
+
raise ArgumentError,
|
|
164
|
+
"glyph_id must be a non-negative Integer, got: #{glyph_id.inspect}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
unless contours.is_a?(Array)
|
|
168
|
+
raise ArgumentError,
|
|
169
|
+
"contours must be an Array, got: #{contours.class}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
unless bbox.is_a?(Hash)
|
|
173
|
+
raise ArgumentError,
|
|
174
|
+
"bbox must be a Hash, got: #{bbox.class}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
required_bbox_keys = %i[x_min y_min x_max y_max]
|
|
178
|
+
missing_keys = required_bbox_keys - bbox.keys
|
|
179
|
+
unless missing_keys.empty?
|
|
180
|
+
raise ArgumentError,
|
|
181
|
+
"bbox missing required keys: #{missing_keys.join(', ')}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Validate contours structure
|
|
185
|
+
contours.each_with_index do |contour, i|
|
|
186
|
+
unless contour.is_a?(Array)
|
|
187
|
+
raise ArgumentError,
|
|
188
|
+
"contour #{i} must be an Array, got: #{contour.class}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
contour.each_with_index do |point, j|
|
|
192
|
+
unless point.is_a?(Hash)
|
|
193
|
+
raise ArgumentError,
|
|
194
|
+
"point #{j} in contour #{i} must be a Hash, got: #{point.class}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
required_point_keys = %i[x y on_curve]
|
|
198
|
+
missing_keys = required_point_keys - point.keys
|
|
199
|
+
unless missing_keys.empty?
|
|
200
|
+
raise ArgumentError,
|
|
201
|
+
"point #{j} in contour #{i} missing keys: #{missing_keys.join(', ')}"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Extract all points from contours into a flat array
|
|
208
|
+
#
|
|
209
|
+
# @param contours [Array<Array<Hash>>] Array of contours
|
|
210
|
+
# @return [Array<Hash>] Flattened array of all points
|
|
211
|
+
def extract_all_points(contours)
|
|
212
|
+
contours.flatten(1)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Deep freeze nested arrays and hashes for immutability
|
|
216
|
+
#
|
|
217
|
+
# @param obj [Array, Hash, Object] Object to freeze
|
|
218
|
+
# @return [Object] Frozen object
|
|
219
|
+
def deep_freeze(obj)
|
|
220
|
+
case obj
|
|
221
|
+
when Array
|
|
222
|
+
obj.map { |item| deep_freeze(item) }.freeze
|
|
223
|
+
when Hash
|
|
224
|
+
obj.transform_values { |value| deep_freeze(value) }.freeze
|
|
225
|
+
else
|
|
226
|
+
obj.freeze
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Build SVG path commands for a contour
|
|
231
|
+
#
|
|
232
|
+
# @param contour [Array<Hash>] Array of point hashes
|
|
233
|
+
# @return [String] SVG path string for this contour
|
|
234
|
+
def build_contour_path(contour)
|
|
235
|
+
return "" if contour.empty?
|
|
236
|
+
|
|
237
|
+
parts = []
|
|
238
|
+
i = 0
|
|
239
|
+
|
|
240
|
+
# Move to first point
|
|
241
|
+
first = contour[i]
|
|
242
|
+
parts << "M #{first[:x]} #{first[:y]}"
|
|
243
|
+
i += 1
|
|
244
|
+
|
|
245
|
+
# Process remaining points
|
|
246
|
+
while i < contour.length
|
|
247
|
+
point = contour[i]
|
|
248
|
+
|
|
249
|
+
if point[:on_curve]
|
|
250
|
+
# Line to on-curve point
|
|
251
|
+
parts << "L #{point[:x]} #{point[:y]}"
|
|
252
|
+
i += 1
|
|
253
|
+
else
|
|
254
|
+
# Off-curve point - need to handle quadratic curves
|
|
255
|
+
# In TrueType, off-curve points are control points for quadratic Bézier curves
|
|
256
|
+
# If we have consecutive off-curve points, there's an implied on-curve point
|
|
257
|
+
# between them at their midpoint
|
|
258
|
+
|
|
259
|
+
control = point
|
|
260
|
+
i += 1
|
|
261
|
+
|
|
262
|
+
if i < contour.length && !contour[i][:on_curve]
|
|
263
|
+
# Two consecutive off-curve points
|
|
264
|
+
# Implied on-curve point at midpoint
|
|
265
|
+
next_control = contour[i]
|
|
266
|
+
implied_x = (control[:x] + next_control[:x]) / 2.0
|
|
267
|
+
implied_y = (control[:y] + next_control[:y]) / 2.0
|
|
268
|
+
parts << "Q #{control[:x]} #{control[:y]} #{implied_x} #{implied_y}"
|
|
269
|
+
elsif i < contour.length
|
|
270
|
+
# Next point is on-curve - end of quadratic curve
|
|
271
|
+
end_point = contour[i]
|
|
272
|
+
parts << "Q #{control[:x]} #{control[:y]} #{end_point[:x]} #{end_point[:y]}"
|
|
273
|
+
i += 1
|
|
274
|
+
else
|
|
275
|
+
# Off-curve point is last - curves back to first point
|
|
276
|
+
parts << "Q #{control[:x]} #{control[:y]} #{first[:x]} #{first[:y]}"
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Close path
|
|
282
|
+
parts << "Z"
|
|
283
|
+
|
|
284
|
+
parts.join(" ")
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Build drawing commands for a contour
|
|
288
|
+
#
|
|
289
|
+
# @param contour [Array<Hash>] Array of point hashes
|
|
290
|
+
# @return [Array<Array>] Array of command arrays
|
|
291
|
+
def build_contour_commands(contour)
|
|
292
|
+
return [] if contour.empty?
|
|
293
|
+
|
|
294
|
+
commands = []
|
|
295
|
+
i = 0
|
|
296
|
+
|
|
297
|
+
# Move to first point
|
|
298
|
+
first = contour[i]
|
|
299
|
+
commands << [:move_to, first[:x], first[:y]]
|
|
300
|
+
i += 1
|
|
301
|
+
|
|
302
|
+
# Process remaining points
|
|
303
|
+
while i < contour.length
|
|
304
|
+
point = contour[i]
|
|
305
|
+
|
|
306
|
+
if point[:on_curve]
|
|
307
|
+
# Line to on-curve point
|
|
308
|
+
commands << [:line_to, point[:x], point[:y]]
|
|
309
|
+
i += 1
|
|
310
|
+
else
|
|
311
|
+
# Off-curve point - quadratic curve control point
|
|
312
|
+
control = point
|
|
313
|
+
i += 1
|
|
314
|
+
|
|
315
|
+
if i < contour.length && !contour[i][:on_curve]
|
|
316
|
+
# Two consecutive off-curve points
|
|
317
|
+
next_control = contour[i]
|
|
318
|
+
implied_x = (control[:x] + next_control[:x]) / 2.0
|
|
319
|
+
implied_y = (control[:y] + next_control[:y]) / 2.0
|
|
320
|
+
commands << [:curve_to, control[:x], control[:y], implied_x,
|
|
321
|
+
implied_y]
|
|
322
|
+
elsif i < contour.length
|
|
323
|
+
# Next point is on-curve
|
|
324
|
+
end_point = contour[i]
|
|
325
|
+
commands << [:curve_to, control[:x], control[:y], end_point[:x],
|
|
326
|
+
end_point[:y]]
|
|
327
|
+
i += 1
|
|
328
|
+
else
|
|
329
|
+
# Curves back to first point
|
|
330
|
+
commands << [:curve_to, control[:x], control[:y], first[:x],
|
|
331
|
+
first[:y]]
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Close path
|
|
337
|
+
commands << [:close_path]
|
|
338
|
+
|
|
339
|
+
commands
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|