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,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "../../binary/base_record"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Tables
|
|
8
|
+
class Cff
|
|
9
|
+
# CFF Encoding structure
|
|
10
|
+
#
|
|
11
|
+
# Encoding maps character codes to glyph IDs (GIDs).
|
|
12
|
+
# GID 0 (.notdef) is not encoded.
|
|
13
|
+
#
|
|
14
|
+
# Three formats:
|
|
15
|
+
# - Format 0: Array of codes (one per glyph)
|
|
16
|
+
# - Format 1: Ranges of consecutive codes
|
|
17
|
+
# - Format 0/1 with supplement: Format 0 or 1 with additional mappings
|
|
18
|
+
#
|
|
19
|
+
# Predefined encodings:
|
|
20
|
+
# - 0: Standard encoding (Adobe standard character set)
|
|
21
|
+
# - 1: Expert encoding (Adobe expert character set)
|
|
22
|
+
#
|
|
23
|
+
# Reference: CFF specification section 14 "Encodings"
|
|
24
|
+
# https://adobe-type-tools.github.io/font-tech-notes/pdfs/5176.CFF.pdf
|
|
25
|
+
#
|
|
26
|
+
# @example Reading an Encoding
|
|
27
|
+
# encoding = Fontisan::Tables::Cff::Encoding.new(data, num_glyphs)
|
|
28
|
+
# puts encoding.glyph_id(65) # => GID for char code 65 ('A')
|
|
29
|
+
# puts encoding.char_code(5) # => char code for GID 5
|
|
30
|
+
class Encoding
|
|
31
|
+
# Predefined encoding identifiers
|
|
32
|
+
PREDEFINED = {
|
|
33
|
+
0 => :standard,
|
|
34
|
+
1 => :expert,
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# Format mask to extract format type
|
|
38
|
+
FORMAT_MASK = 0x7F
|
|
39
|
+
|
|
40
|
+
# @return [Integer] Encoding format (0 or 1)
|
|
41
|
+
attr_reader :format_type
|
|
42
|
+
|
|
43
|
+
# @return [Hash<Integer, Integer>] Map from character code to GID
|
|
44
|
+
attr_reader :code_to_gid
|
|
45
|
+
|
|
46
|
+
# @return [Hash<Integer, Integer>] Map from GID to character code
|
|
47
|
+
attr_reader :gid_to_code
|
|
48
|
+
|
|
49
|
+
# Initialize an Encoding
|
|
50
|
+
#
|
|
51
|
+
# @param data [String, Integer] Binary data or predefined encoding ID
|
|
52
|
+
# @param num_glyphs [Integer] Number of glyphs in the font
|
|
53
|
+
def initialize(data, num_glyphs)
|
|
54
|
+
@num_glyphs = num_glyphs
|
|
55
|
+
@code_to_gid = {}
|
|
56
|
+
@gid_to_code = {}
|
|
57
|
+
|
|
58
|
+
# GID 0 (.notdef) is always at code 0
|
|
59
|
+
@code_to_gid[0] = 0
|
|
60
|
+
@gid_to_code[0] = 0
|
|
61
|
+
|
|
62
|
+
if data.is_a?(Integer) && PREDEFINED.key?(data)
|
|
63
|
+
load_predefined_encoding(data)
|
|
64
|
+
else
|
|
65
|
+
@data = data
|
|
66
|
+
parse!
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Get GID for a character code
|
|
71
|
+
#
|
|
72
|
+
# @param code [Integer] Character code (0-255)
|
|
73
|
+
# @return [Integer, nil] Glyph ID or nil if not mapped
|
|
74
|
+
def glyph_id(code)
|
|
75
|
+
@code_to_gid[code]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Get character code for a GID
|
|
79
|
+
#
|
|
80
|
+
# @param gid [Integer] Glyph ID
|
|
81
|
+
# @return [Integer, nil] Character code or nil if not mapped
|
|
82
|
+
def char_code(gid)
|
|
83
|
+
@gid_to_code[gid]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Get the format symbol
|
|
87
|
+
#
|
|
88
|
+
# @return [Symbol] Format identifier (:array, :range, or :predefined)
|
|
89
|
+
def format
|
|
90
|
+
return :predefined unless @format_type
|
|
91
|
+
|
|
92
|
+
@format_type.zero? ? :array : :range
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Check if encoding has supplement
|
|
96
|
+
#
|
|
97
|
+
# @return [Boolean] True if encoding has supplemental mappings
|
|
98
|
+
def has_supplement?
|
|
99
|
+
@has_supplement || false
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Parse the Encoding from binary data
|
|
105
|
+
def parse!
|
|
106
|
+
io = StringIO.new(@data)
|
|
107
|
+
format_byte = read_uint8(io)
|
|
108
|
+
|
|
109
|
+
# Extract format (lower 7 bits) and supplement flag (bit 7)
|
|
110
|
+
@format_type = format_byte & FORMAT_MASK
|
|
111
|
+
@has_supplement = (format_byte & 0x80) != 0
|
|
112
|
+
|
|
113
|
+
case @format_type
|
|
114
|
+
when 0
|
|
115
|
+
parse_format_0(io)
|
|
116
|
+
when 1
|
|
117
|
+
parse_format_1(io)
|
|
118
|
+
else
|
|
119
|
+
raise CorruptedTableError,
|
|
120
|
+
"Invalid Encoding format: #{@format_type}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Parse supplemental encoding if present
|
|
124
|
+
parse_supplement(io) if @has_supplement
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
raise CorruptedTableError,
|
|
127
|
+
"Failed to parse Encoding: #{e.message}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Parse Format 0: Array of codes
|
|
131
|
+
#
|
|
132
|
+
# Format 0 directly lists character codes for each glyph (except
|
|
133
|
+
# .notdef)
|
|
134
|
+
#
|
|
135
|
+
# @param io [StringIO] Input stream positioned after format byte
|
|
136
|
+
def parse_format_0(io)
|
|
137
|
+
n_codes = read_uint8(io)
|
|
138
|
+
|
|
139
|
+
# Read one code per glyph (GIDs start at 1, skipping .notdef)
|
|
140
|
+
n_codes.times do |i|
|
|
141
|
+
code = read_uint8(io)
|
|
142
|
+
gid = i + 1 # GID 0 is .notdef, so start at 1
|
|
143
|
+
|
|
144
|
+
@code_to_gid[code] = gid
|
|
145
|
+
@gid_to_code[gid] = code
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Parse Format 1: Ranges of codes
|
|
150
|
+
#
|
|
151
|
+
# Format 1 uses ranges: first code, nLeft (number of consecutive codes)
|
|
152
|
+
#
|
|
153
|
+
# @param io [StringIO] Input stream positioned after format byte
|
|
154
|
+
def parse_format_1(io)
|
|
155
|
+
n_ranges = read_uint8(io)
|
|
156
|
+
gid = 1 # Start at GID 1 (skip .notdef at 0)
|
|
157
|
+
|
|
158
|
+
n_ranges.times do
|
|
159
|
+
first_code = read_uint8(io)
|
|
160
|
+
n_left = read_uint8(io)
|
|
161
|
+
|
|
162
|
+
# Map the range of codes
|
|
163
|
+
(n_left + 1).times do |i|
|
|
164
|
+
code = first_code + i
|
|
165
|
+
@code_to_gid[code] = gid
|
|
166
|
+
@gid_to_code[gid] = code
|
|
167
|
+
gid += 1
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Parse supplemental encoding
|
|
173
|
+
#
|
|
174
|
+
# Supplemental encoding provides additional code-to-GID mappings
|
|
175
|
+
#
|
|
176
|
+
# @param io [StringIO] Input stream positioned after main encoding data
|
|
177
|
+
def parse_supplement(io)
|
|
178
|
+
n_sups = read_uint8(io)
|
|
179
|
+
|
|
180
|
+
n_sups.times do
|
|
181
|
+
read_uint8(io)
|
|
182
|
+
read_uint16(io)
|
|
183
|
+
|
|
184
|
+
# Find GID for this SID (requires charset lookup)
|
|
185
|
+
# For now, we'll store the code mapping
|
|
186
|
+
# A full implementation would need charset access to resolve SID to
|
|
187
|
+
# GID
|
|
188
|
+
# This is typically used when the charset has glyphs not in the
|
|
189
|
+
# standard encoding
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Load a predefined encoding
|
|
194
|
+
#
|
|
195
|
+
# @param encoding_id [Integer] Predefined encoding ID (0 or 1)
|
|
196
|
+
def load_predefined_encoding(encoding_id)
|
|
197
|
+
@format_type = nil # Predefined encodings don't have a format
|
|
198
|
+
|
|
199
|
+
case encoding_id
|
|
200
|
+
when 0
|
|
201
|
+
load_standard_encoding
|
|
202
|
+
when 1
|
|
203
|
+
load_expert_encoding
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Load Standard encoding
|
|
208
|
+
#
|
|
209
|
+
# Adobe Standard Encoding is the default encoding for Type 1 fonts
|
|
210
|
+
# It maps common Latin characters to specific codes
|
|
211
|
+
def load_standard_encoding
|
|
212
|
+
# Standard encoding for common characters (codes 0-255)
|
|
213
|
+
# This is a simplified version - a full implementation would include
|
|
214
|
+
# all 256 standard encoding mappings from the CFF specification
|
|
215
|
+
# Appendix B
|
|
216
|
+
|
|
217
|
+
# Common ASCII mappings (basic Latin)
|
|
218
|
+
gid = 1
|
|
219
|
+
(32..126).each do |code|
|
|
220
|
+
@code_to_gid[code] = gid
|
|
221
|
+
@gid_to_code[gid] = code
|
|
222
|
+
gid += 1
|
|
223
|
+
break if gid >= @num_glyphs
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Load Expert encoding
|
|
228
|
+
#
|
|
229
|
+
# Adobe Expert Encoding is used for expert fonts with special
|
|
230
|
+
# characters like small caps, old-style figures, ligatures, etc.
|
|
231
|
+
def load_expert_encoding
|
|
232
|
+
# Expert encoding for special characters
|
|
233
|
+
# This is a simplified version - a full implementation would include
|
|
234
|
+
# all expert encoding mappings from the CFF specification Appendix C
|
|
235
|
+
|
|
236
|
+
# Map some common expert characters
|
|
237
|
+
gid = 1
|
|
238
|
+
expert_codes = [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44,
|
|
239
|
+
45, 46, 47]
|
|
240
|
+
expert_codes.each do |code|
|
|
241
|
+
@code_to_gid[code] = gid if gid < @num_glyphs
|
|
242
|
+
@gid_to_code[gid] = code if gid < @num_glyphs
|
|
243
|
+
gid += 1
|
|
244
|
+
break if gid >= @num_glyphs
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Read an unsigned 8-bit integer
|
|
249
|
+
#
|
|
250
|
+
# @param io [StringIO] Input stream
|
|
251
|
+
# @return [Integer] The value
|
|
252
|
+
def read_uint8(io)
|
|
253
|
+
byte = io.read(1)
|
|
254
|
+
raise CorruptedTableError, "Unexpected end of Encoding data" if
|
|
255
|
+
byte.nil?
|
|
256
|
+
|
|
257
|
+
byte.unpack1("C")
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Read an unsigned 16-bit integer (big-endian)
|
|
261
|
+
#
|
|
262
|
+
# @param io [StringIO] Input stream
|
|
263
|
+
# @return [Integer] The value
|
|
264
|
+
def read_uint16(io)
|
|
265
|
+
bytes = io.read(2)
|
|
266
|
+
raise CorruptedTableError, "Unexpected end of Encoding data" if
|
|
267
|
+
bytes.nil? || bytes.bytesize < 2
|
|
268
|
+
|
|
269
|
+
bytes.unpack1("n") # Big-endian unsigned 16-bit
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../binary/base_record"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Tables
|
|
7
|
+
class Cff
|
|
8
|
+
# CFF Header structure
|
|
9
|
+
#
|
|
10
|
+
# The CFF header appears at the beginning of the CFF table and contains
|
|
11
|
+
# basic version and structural information about the CFF data.
|
|
12
|
+
#
|
|
13
|
+
# Structure (4 bytes minimum):
|
|
14
|
+
# - uint8: major version (always 1 for CFF, 2 for CFF2)
|
|
15
|
+
# - uint8: minor version (always 0)
|
|
16
|
+
# - uint8: hdr_size (header size in bytes, typically 4)
|
|
17
|
+
# - uint8: off_size (offset size used throughout CFF, 1-4 bytes)
|
|
18
|
+
#
|
|
19
|
+
# Reference: CFF specification section 4 "Header"
|
|
20
|
+
# https://adobe-type-tools.github.io/font-tech-notes/pdfs/5176.CFF.pdf
|
|
21
|
+
#
|
|
22
|
+
# @example Reading a CFF header
|
|
23
|
+
# data = File.binread("font.otf", 4, cff_offset)
|
|
24
|
+
# header = Fontisan::Tables::Cff::Header.read(data)
|
|
25
|
+
# puts header.major # => 1
|
|
26
|
+
# puts header.minor # => 0
|
|
27
|
+
# puts header.off_size # => 4
|
|
28
|
+
class Header < Binary::BaseRecord
|
|
29
|
+
# Major version number (1 for CFF, 2 for CFF2)
|
|
30
|
+
uint8 :major
|
|
31
|
+
|
|
32
|
+
# Minor version number (always 0)
|
|
33
|
+
uint8 :minor
|
|
34
|
+
|
|
35
|
+
# Header size in bytes (typically 4, but can be larger for extensions)
|
|
36
|
+
uint8 :hdr_size
|
|
37
|
+
|
|
38
|
+
# Offset size used throughout the CFF table
|
|
39
|
+
# Valid values are 1, 2, 3, or 4 bytes
|
|
40
|
+
#
|
|
41
|
+
# This determines how offsets are encoded in INDEX structures and
|
|
42
|
+
# other parts of the CFF table.
|
|
43
|
+
uint8 :off_size
|
|
44
|
+
|
|
45
|
+
# Check if this is a valid CFF version 1.0 header
|
|
46
|
+
#
|
|
47
|
+
# @return [Boolean] True if major version is 1 and minor is 0
|
|
48
|
+
def cff?
|
|
49
|
+
major == 1 && minor.zero?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Check if this is a CFF2 header (variable CFF fonts)
|
|
53
|
+
#
|
|
54
|
+
# @return [Boolean] True if major version is 2
|
|
55
|
+
def cff2?
|
|
56
|
+
major == 2
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Get the version as a string
|
|
60
|
+
#
|
|
61
|
+
# @return [String] Version in "major.minor" format
|
|
62
|
+
def version
|
|
63
|
+
"#{major}.#{minor}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Validate that the header has correct values
|
|
67
|
+
#
|
|
68
|
+
# @return [Boolean] True if header is valid
|
|
69
|
+
def valid?
|
|
70
|
+
# Major version must be 1 or 2
|
|
71
|
+
return false unless [1, 2].include?(major)
|
|
72
|
+
|
|
73
|
+
# Minor version must be 0
|
|
74
|
+
return false unless minor.zero?
|
|
75
|
+
|
|
76
|
+
# Header size must be at least 4 bytes
|
|
77
|
+
return false unless hdr_size >= 4
|
|
78
|
+
|
|
79
|
+
# Offset size must be between 1 and 4
|
|
80
|
+
return false unless (1..4).cover?(off_size)
|
|
81
|
+
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Validate header and raise error if invalid
|
|
86
|
+
#
|
|
87
|
+
# @raise [Fontisan::CorruptedTableError] If header is invalid
|
|
88
|
+
def validate!
|
|
89
|
+
return if valid?
|
|
90
|
+
|
|
91
|
+
message = "Invalid CFF header: " \
|
|
92
|
+
"version=#{version}, " \
|
|
93
|
+
"hdr_size=#{hdr_size}, " \
|
|
94
|
+
"off_size=#{off_size}"
|
|
95
|
+
error = Fontisan::CorruptedTableError.new(message)
|
|
96
|
+
error.set_backtrace(caller)
|
|
97
|
+
Kernel.raise(error)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "../../binary/base_record"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Tables
|
|
8
|
+
class Cff
|
|
9
|
+
# CFF INDEX structure
|
|
10
|
+
#
|
|
11
|
+
# INDEX is a fundamental data structure used throughout CFF for storing
|
|
12
|
+
# arrays of variable-length data items. It's used for:
|
|
13
|
+
# - Name INDEX (font names)
|
|
14
|
+
# - String INDEX (string data)
|
|
15
|
+
# - Global Subr INDEX (global subroutines)
|
|
16
|
+
# - Local Subr INDEX (local subroutines)
|
|
17
|
+
# - CharStrings INDEX (glyph programs)
|
|
18
|
+
#
|
|
19
|
+
# Structure:
|
|
20
|
+
# - count (Card16): Number of objects stored in INDEX
|
|
21
|
+
# - offSize (OffSize): Size of offset values (1-4 bytes)
|
|
22
|
+
# - offset[count+1] (Offset): Array of offsets to data
|
|
23
|
+
# - data: The actual data bytes
|
|
24
|
+
#
|
|
25
|
+
# Offsets are relative to the byte before the data array. The first
|
|
26
|
+
# offset is always 1, not 0. The last offset points one byte past the
|
|
27
|
+
# end of the data.
|
|
28
|
+
#
|
|
29
|
+
# Reference: CFF specification section 5 "INDEX Data"
|
|
30
|
+
# https://adobe-type-tools.github.io/font-tech-notes/pdfs/5176.CFF.pdf
|
|
31
|
+
#
|
|
32
|
+
# @example Reading an INDEX
|
|
33
|
+
# index = Fontisan::Tables::Cff::Index.new(data)
|
|
34
|
+
# puts index.count # => 3
|
|
35
|
+
# puts index[0] # => first item data
|
|
36
|
+
# index.each { |item| puts item }
|
|
37
|
+
class Index
|
|
38
|
+
# @return [Integer] Number of items in the INDEX
|
|
39
|
+
attr_reader :count
|
|
40
|
+
|
|
41
|
+
# @return [Integer] Size of offset values (1-4 bytes)
|
|
42
|
+
attr_reader :off_size
|
|
43
|
+
|
|
44
|
+
# @return [Array<Integer>] Array of offsets (count + 1 elements)
|
|
45
|
+
attr_reader :offsets
|
|
46
|
+
|
|
47
|
+
# @return [String] Binary string containing all data
|
|
48
|
+
attr_reader :data
|
|
49
|
+
|
|
50
|
+
# Initialize an INDEX from binary data
|
|
51
|
+
#
|
|
52
|
+
# @param io [IO, StringIO, String] Binary data to parse
|
|
53
|
+
# @param start_offset [Integer] Starting byte offset in the data
|
|
54
|
+
def initialize(io, start_offset: 0)
|
|
55
|
+
@io = io.is_a?(String) ? StringIO.new(io) : io
|
|
56
|
+
@start_offset = start_offset
|
|
57
|
+
@io.seek(start_offset) if @io.respond_to?(:seek)
|
|
58
|
+
|
|
59
|
+
parse!
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Get the item at the specified index
|
|
63
|
+
#
|
|
64
|
+
# @param index [Integer] Zero-based index of item to retrieve
|
|
65
|
+
# @return [String, nil] Binary data for the item, or nil if out of bounds
|
|
66
|
+
def [](index)
|
|
67
|
+
return nil if index.negative? || index >= count
|
|
68
|
+
return "" if count.zero?
|
|
69
|
+
|
|
70
|
+
# Offsets are 1-based in the data array
|
|
71
|
+
start_pos = offsets[index] - 1
|
|
72
|
+
end_pos = offsets[index + 1] - 1
|
|
73
|
+
length = end_pos - start_pos
|
|
74
|
+
|
|
75
|
+
data[start_pos, length]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Iterate over each item in the INDEX
|
|
79
|
+
#
|
|
80
|
+
# @yield [String] Binary data for each item
|
|
81
|
+
# @return [Enumerator] If no block given
|
|
82
|
+
def each
|
|
83
|
+
return enum_for(:each) unless block_given?
|
|
84
|
+
|
|
85
|
+
count.times do |i|
|
|
86
|
+
yield self[i]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Get all items as an array
|
|
91
|
+
#
|
|
92
|
+
# @return [Array<String>] Array of binary data strings
|
|
93
|
+
def to_a
|
|
94
|
+
Array.new(count) { |i| self[i] }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Check if the INDEX is empty
|
|
98
|
+
#
|
|
99
|
+
# @return [Boolean] True if count is 0
|
|
100
|
+
def empty?
|
|
101
|
+
count.zero?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Get the size of a specific item
|
|
105
|
+
#
|
|
106
|
+
# @param index [Integer] Zero-based index of item
|
|
107
|
+
# @return [Integer, nil] Size in bytes, or nil if out of bounds
|
|
108
|
+
def item_size(index)
|
|
109
|
+
return nil if index.negative? || index >= count
|
|
110
|
+
return 0 if count.zero?
|
|
111
|
+
|
|
112
|
+
offsets[index + 1] - offsets[index]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Calculate total size of the INDEX in bytes
|
|
116
|
+
#
|
|
117
|
+
# This includes the count, offSize, offset array, and data.
|
|
118
|
+
#
|
|
119
|
+
# @return [Integer] Total size in bytes
|
|
120
|
+
def total_size
|
|
121
|
+
return 2 if count.zero? # Just the count field
|
|
122
|
+
|
|
123
|
+
# count (2) + offSize (1) + offset array + data
|
|
124
|
+
2 + 1 + ((count + 1) * off_size) + data.bytesize
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# Parse the INDEX structure from the IO
|
|
130
|
+
def parse!
|
|
131
|
+
# Read count (Card16)
|
|
132
|
+
@count = read_uint16
|
|
133
|
+
|
|
134
|
+
# Empty INDEX has only count field
|
|
135
|
+
if @count.zero?
|
|
136
|
+
@off_size = 0
|
|
137
|
+
@offsets = []
|
|
138
|
+
@data = "".b
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Read offSize (OffSize)
|
|
143
|
+
@off_size = read_uint8
|
|
144
|
+
|
|
145
|
+
# Validate offSize
|
|
146
|
+
unless (1..4).cover?(@off_size)
|
|
147
|
+
raise CorruptedTableError,
|
|
148
|
+
"Invalid INDEX offSize: #{@off_size} (must be 1-4)"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Read offset array (count + 1 offsets)
|
|
152
|
+
@offsets = Array.new(@count + 1) do
|
|
153
|
+
read_offset(@off_size)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Validate offsets
|
|
157
|
+
validate_offsets!
|
|
158
|
+
|
|
159
|
+
# Read data section
|
|
160
|
+
# Size is (last offset - 1) since offsets are 1-based
|
|
161
|
+
data_size = @offsets.last - 1
|
|
162
|
+
@data = read_bytes(data_size)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Read an unsigned 16-bit integer
|
|
166
|
+
#
|
|
167
|
+
# @return [Integer] The value
|
|
168
|
+
def read_uint16
|
|
169
|
+
bytes = read_bytes(2)
|
|
170
|
+
bytes.unpack1("n") # Big-endian unsigned 16-bit
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Read an unsigned 8-bit integer
|
|
174
|
+
#
|
|
175
|
+
# @return [Integer] The value
|
|
176
|
+
def read_uint8
|
|
177
|
+
read_bytes(1).unpack1("C")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Read an offset value of specified size
|
|
181
|
+
#
|
|
182
|
+
# @param size [Integer] Number of bytes (1-4)
|
|
183
|
+
# @return [Integer] The offset value
|
|
184
|
+
def read_offset(size)
|
|
185
|
+
bytes = read_bytes(size)
|
|
186
|
+
|
|
187
|
+
case size
|
|
188
|
+
when 1
|
|
189
|
+
bytes.unpack1("C")
|
|
190
|
+
when 2
|
|
191
|
+
bytes.unpack1("n")
|
|
192
|
+
when 3
|
|
193
|
+
# 24-bit big-endian
|
|
194
|
+
bytes.unpack("C3").inject(0) { |sum, byte| (sum << 8) | byte }
|
|
195
|
+
when 4
|
|
196
|
+
bytes.unpack1("N")
|
|
197
|
+
else
|
|
198
|
+
raise ArgumentError, "Invalid offset size: #{size}"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Read specified number of bytes from IO
|
|
203
|
+
#
|
|
204
|
+
# @param count [Integer] Number of bytes to read
|
|
205
|
+
# @return [String] Binary string
|
|
206
|
+
def read_bytes(count)
|
|
207
|
+
return "".b if count.zero?
|
|
208
|
+
|
|
209
|
+
bytes = @io.read(count)
|
|
210
|
+
if bytes.nil? || bytes.bytesize < count
|
|
211
|
+
raise CorruptedTableError,
|
|
212
|
+
"Unexpected end of INDEX data"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
bytes
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Validate that offsets are in ascending order and within bounds
|
|
219
|
+
def validate_offsets!
|
|
220
|
+
# First offset must be 1
|
|
221
|
+
unless @offsets.first == 1
|
|
222
|
+
raise CorruptedTableError,
|
|
223
|
+
"Invalid INDEX: first offset must be 1, got #{@offsets.first}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Check ascending order
|
|
227
|
+
@offsets.each_cons(2) do |prev, curr|
|
|
228
|
+
if curr < prev
|
|
229
|
+
raise CorruptedTableError,
|
|
230
|
+
"Invalid INDEX: offsets are not in ascending order"
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|