fontisan 0.2.8 → 0.2.10

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.
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sfnt_table"
4
+ require_relative "hmtx"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # OOP representation of the 'hmtx' (Horizontal Metrics) table
9
+ #
10
+ # The hmtx table contains horizontal metrics for each glyph in the font,
11
+ # providing advance width and left sidebearing values needed for proper
12
+ # glyph positioning and text layout.
13
+ #
14
+ # This class extends SfntTable to provide hmtx-specific convenience
15
+ # methods for accessing glyph metrics. The hmtx table requires context
16
+ # from the hhea and maxp tables to function properly.
17
+ #
18
+ # @example Accessing horizontal metrics
19
+ # hmtx = font.sfnt_table("hmtx")
20
+ # hhea = font.table("hhea")
21
+ # maxp = font.table("maxp")
22
+ #
23
+ # hmtx.parse_with_context(hhea.number_of_h_metrics, maxp.num_glyphs)
24
+ # metric = hmtx.metric_for(42) # Get glyph metrics
25
+ # metric[:advance_width] # => 1000
26
+ # metric[:lsb] # => 50
27
+ class HmtxTable < SfntTable
28
+ # Cache for context tables
29
+ attr_reader :hhea_table, :maxp_table
30
+
31
+ # Parse the hmtx table with required context
32
+ #
33
+ # The hmtx table cannot be used without context from hhea and maxp tables.
34
+ #
35
+ # @param number_of_h_metrics [Integer] Number of LongHorMetric records (from hhea)
36
+ # @param num_glyphs [Integer] Total number of glyphs (from maxp)
37
+ # @return [self] Returns self for chaining
38
+ # @raise [ArgumentError] if context is invalid
39
+ def parse_with_context(number_of_h_metrics, num_glyphs)
40
+ unless number_of_h_metrics && num_glyphs
41
+ raise ArgumentError,
42
+ "hmtx table requires number_of_h_metrics and num_glyphs"
43
+ end
44
+
45
+ @number_of_h_metrics = number_of_h_metrics
46
+ @num_glyphs = num_glyphs
47
+
48
+ # Ensure parsed data is loaded
49
+ parse
50
+
51
+ # Parse with context
52
+ parsed.parse_with_context(number_of_h_metrics, num_glyphs)
53
+
54
+ self
55
+ end
56
+
57
+ # Check if table has been parsed with context
58
+ #
59
+ # @return [Boolean] true if context is available
60
+ def has_context?
61
+ !@number_of_h_metrics.nil? && !@num_glyphs.nil?
62
+ end
63
+
64
+ # Get horizontal metrics for a glyph
65
+ #
66
+ # @param glyph_id [Integer] Glyph ID (0-based)
67
+ # @return [Hash, nil] Hash with :advance_width and :lsb keys, or nil
68
+ # @raise [ArgumentError] if context not set
69
+ def metric_for(glyph_id)
70
+ ensure_context!
71
+ return nil unless parsed
72
+
73
+ parsed.metric_for(glyph_id)
74
+ end
75
+
76
+ # Get advance width for a glyph
77
+ #
78
+ # @param glyph_id [Integer] Glyph ID
79
+ # @return [Integer, nil] Advance width in FUnits, or nil
80
+ def advance_width_for(glyph_id)
81
+ metric = metric_for(glyph_id)
82
+ metric&.dig(:advance_width)
83
+ end
84
+
85
+ # Get left sidebearing for a glyph
86
+ #
87
+ # @param glyph_id [integer] Glyph ID
88
+ # @return [Integer, nil] Left sidebearing in FUnits, or nil
89
+ def lsb_for(glyph_id)
90
+ metric = metric_for(glyph_id)
91
+ metric&.dig(:lsb)
92
+ end
93
+
94
+ # Get number of horizontal metrics
95
+ #
96
+ # @return [Integer, nil] Number of hMetrics, or nil if not parsed
97
+ def number_of_h_metrics
98
+ return @number_of_h_metrics if @number_of_h_metrics
99
+ return nil unless parsed
100
+
101
+ parsed.number_of_h_metrics
102
+ end
103
+
104
+ # Get total number of glyphs
105
+ #
106
+ # @return [Integer, nil] Total glyph count, or nil if not parsed
107
+ def num_glyphs
108
+ return @num_glyphs if @num_glyphs
109
+ return nil unless parsed
110
+
111
+ parsed.num_glyphs
112
+ end
113
+
114
+ # Check if a glyph has its own metrics
115
+ #
116
+ # Glyphs with ID < numberOfHMetrics have their own advance width
117
+ #
118
+ # @param glyph_id [Integer] Glyph ID
119
+ # @return [Boolean] true if glyph has unique metrics
120
+ def has_unique_metrics?(glyph_id)
121
+ num = number_of_h_metrics
122
+ return false if num.nil?
123
+
124
+ glyph_id < num
125
+ end
126
+
127
+ # Get all advance widths
128
+ #
129
+ # @return [Array<Integer>] Array of advance widths
130
+ def all_advance_widths
131
+ return [] unless has_context?
132
+
133
+ (0...num_glyphs).map { |gid| advance_width_for(gid) || 0 }
134
+ end
135
+
136
+ # Get all left sidebearings
137
+ #
138
+ # @return [Array<Integer>] Array of LSB values
139
+ def all_lsbs
140
+ return [] unless has_context?
141
+
142
+ (0...num_glyphs).map { |gid| lsb_for(gid) || 0 }
143
+ end
144
+
145
+ # Get metrics statistics
146
+ #
147
+ # @return [Hash] Statistics about horizontal metrics
148
+ def statistics
149
+ return {} unless has_context?
150
+
151
+ widths = all_advance_widths
152
+ lsbs = all_lsbs
153
+
154
+ {
155
+ num_glyphs: num_glyphs,
156
+ number_of_h_metrics: number_of_h_metrics,
157
+ min_advance_width: widths.min,
158
+ max_advance_width: widths.max,
159
+ avg_advance_width: widths.sum.fdiv(widths.size).round(2),
160
+ min_lsb: lsbs.min,
161
+ max_lsb: lsbs.max,
162
+ }
163
+ end
164
+
165
+ private
166
+
167
+ # Ensure context tables are set
168
+ #
169
+ # @raise [ArgumentError] if context not set
170
+ def ensure_context!
171
+ unless has_context?
172
+ raise ArgumentError,
173
+ "hmtx table requires context. " \
174
+ "Call parse_with_context(number_of_h_metrics, num_glyphs) first"
175
+ end
176
+ end
177
+
178
+ protected
179
+
180
+ # Validate the parsed hmtx table
181
+ #
182
+ # @return [Boolean] true if valid
183
+ # @raise [InvalidFontError] if hmtx table is invalid
184
+ def validate_parsed_table?
185
+ # Hmtx table validation requires context, so we can't validate here
186
+ # Validation is deferred until context is provided
187
+ true
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sfnt_table"
4
+ require_relative "loca"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # OOP representation of the 'loca' (Index to Location) table
9
+ #
10
+ # The loca table provides offsets to glyph data in the glyf table.
11
+ # Each glyph has an entry indicating where its data begins in the glyf table.
12
+ #
13
+ # This class extends SfntTable to provide loca-specific convenience
14
+ # methods for accessing glyph locations. The loca table requires context
15
+ # from the head and maxp tables to function properly.
16
+ #
17
+ # @example Accessing glyph locations
18
+ # loca = font.sfnt_table("loca")
19
+ # head = font.table("head")
20
+ # maxp = font.table("maxp")
21
+ #
22
+ # loca.parse_with_context(head.index_to_loc_format, maxp.num_glyphs)
23
+ # offset = loca.offset_for(42) # Get offset for glyph 42
24
+ # size = loca.size_of(42) # Get size of glyph 42
25
+ # loca.empty?(32) # Check if glyph 32 is empty
26
+ class LocaTable < SfntTable
27
+ # Short format constant
28
+ FORMAT_SHORT = 0
29
+
30
+ # Long format constant
31
+ FORMAT_LONG = 1
32
+
33
+ # Cache for context
34
+ attr_reader :index_to_loc_format, :num_glyphs
35
+
36
+ # Parse the loca table with required context
37
+ #
38
+ # The loca table cannot be used without context from head and maxp tables.
39
+ #
40
+ # @param index_to_loc_format [Integer] Format (0 = short, 1 = long) from head
41
+ # @param num_glyphs [Integer] Total number of glyphs from maxp
42
+ # @return [self] Returns self for chaining
43
+ # @raise [ArgumentError] if context is invalid
44
+ def parse_with_context(index_to_loc_format, num_glyphs)
45
+ unless index_to_loc_format && num_glyphs
46
+ raise ArgumentError,
47
+ "loca table requires index_to_loc_format and num_glyphs"
48
+ end
49
+
50
+ @index_to_loc_format = index_to_loc_format
51
+ @num_glyphs = num_glyphs
52
+
53
+ # Ensure parsed data is loaded
54
+ parse
55
+
56
+ # Parse with context
57
+ parsed.parse_with_context(index_to_loc_format, num_glyphs)
58
+
59
+ self
60
+ end
61
+
62
+ # Check if table has been parsed with context
63
+ #
64
+ # @return [Boolean] true if context is available
65
+ def has_context?
66
+ !@index_to_loc_format.nil? && !@num_glyphs.nil?
67
+ end
68
+
69
+ # Get the offset for a glyph ID in the glyf table
70
+ #
71
+ # @param glyph_id [Integer] Glyph ID (0-based)
72
+ # @return [Integer, nil] Byte offset in glyf table, or nil if invalid
73
+ # @raise [ArgumentError] if context not set
74
+ def offset_for(glyph_id)
75
+ ensure_context!
76
+ return nil unless parsed
77
+
78
+ parsed.offset_for(glyph_id)
79
+ end
80
+
81
+ # Calculate the size of glyph data for a glyph ID
82
+ #
83
+ # @param glyph_id [Integer] Glyph ID (0-based)
84
+ # @return [Integer, nil] Size in bytes, or nil if invalid
85
+ # @raise [ArgumentError] if context not set
86
+ def size_of(glyph_id)
87
+ ensure_context!
88
+ return nil unless parsed
89
+
90
+ parsed.size_of(glyph_id)
91
+ end
92
+
93
+ # Check if a glyph has no outline data
94
+ #
95
+ # @param glyph_id [Integer] Glyph ID (0-based)
96
+ # @return [Boolean, nil] True if empty, false if has data, nil if invalid
97
+ # @raise [ArgumentError] if context not set
98
+ def empty?(glyph_id)
99
+ ensure_context!
100
+ return nil unless parsed
101
+
102
+ parsed.empty?(glyph_id)
103
+ end
104
+
105
+ # Check if using short format (format 0)
106
+ #
107
+ # @return [Boolean] true if short format
108
+ def short_format?
109
+ return false unless parsed?
110
+
111
+ @index_to_loc_format == FORMAT_SHORT
112
+ end
113
+
114
+ # Check if using long format (format 1)
115
+ #
116
+ # @return [Boolean] true if long format
117
+ def long_format?
118
+ return false unless parsed?
119
+
120
+ @index_to_loc_format == FORMAT_LONG
121
+ end
122
+
123
+ # Get format name
124
+ #
125
+ # @return [String] "short" or "long"
126
+ def format_name
127
+ short_format? ? "short" : "long"
128
+ end
129
+
130
+ # Get all offsets
131
+ #
132
+ # @return [Array<Integer>] Array of glyph offsets
133
+ def all_offsets
134
+ return [] unless parsed?
135
+
136
+ parsed.offsets || []
137
+ end
138
+
139
+ # Get all glyph sizes
140
+ #
141
+ # @return [Array<Integer>] Array of glyph sizes
142
+ def all_sizes
143
+ return [] unless has_context?
144
+
145
+ (0...num_glyphs).map { |gid| size_of(gid) || 0 }
146
+ end
147
+
148
+ # Get empty glyph IDs
149
+ #
150
+ # @return [Array<Integer>] Array of empty glyph IDs
151
+ def empty_glyph_ids
152
+ return [] unless has_context?
153
+
154
+ (0...num_glyphs).select { |gid| empty?(gid) }
155
+ end
156
+
157
+ # Get non-empty glyph IDs
158
+ #
159
+ # @return [Array<Integer>] Array of glyph IDs with data
160
+ def non_empty_glyph_ids
161
+ return [] unless has_context?
162
+
163
+ (0...num_glyphs).reject { |gid| empty?(gid) }
164
+ end
165
+
166
+ # Get statistics about glyph locations
167
+ #
168
+ # @return [Hash] Statistics about loca table
169
+ def statistics
170
+ return {} unless has_context?
171
+
172
+ sizes = all_sizes
173
+ offsets = all_offsets
174
+
175
+ {
176
+ num_glyphs: num_glyphs,
177
+ format: format_name,
178
+ empty_glyph_count: empty_glyph_ids.length,
179
+ non_empty_glyph_count: non_empty_glyph_ids.length,
180
+ min_size: sizes.compact.min,
181
+ max_size: sizes.compact.max,
182
+ total_data_size: sizes.sum,
183
+ glyf_table_size: offsets.last || 0,
184
+ }
185
+ end
186
+
187
+ private
188
+
189
+ # Ensure context tables are set
190
+ #
191
+ # @raise [ArgumentError] if context not set
192
+ def ensure_context!
193
+ unless has_context?
194
+ raise ArgumentError,
195
+ "loca table requires context. " \
196
+ "Call parse_with_context(index_to_loc_format, num_glyphs) first"
197
+ end
198
+ end
199
+
200
+ protected
201
+
202
+ # Validate the parsed loca table
203
+ #
204
+ # @return [Boolean] true if valid
205
+ # @raise [InvalidFontError] if loca table is invalid
206
+ def validate_parsed_table?
207
+ # Loca table validation requires context, so we can't validate here
208
+ true
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sfnt_table"
4
+ require_relative "maxp"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # OOP representation of the 'maxp' (Maximum Profile) table
9
+ #
10
+ # The maxp table contains memory and complexity limits for the font,
11
+ # providing the number of glyphs and various maximum values needed
12
+ # for font rendering and processing.
13
+ #
14
+ # This class extends SfntTable to provide maxp-specific validation and
15
+ # convenience methods for accessing font complexity metrics.
16
+ #
17
+ # @example Accessing maxp table data
18
+ # maxp = font.sfnt_table("maxp")
19
+ # maxp.num_glyphs # => 512
20
+ # maxp.version # => 1.0 or 0.5
21
+ # maxp.truetype? # => true or false
22
+ class MaxpTable < SfntTable
23
+ # Version 0.5 constant (CFF fonts)
24
+ VERSION_0_5 = 0x00005000
25
+
26
+ # Version 1.0 constant (TrueType fonts)
27
+ VERSION_1_0 = 0x00010000
28
+
29
+ # Get maxp table version
30
+ #
31
+ # @return [Float, nil] Version number (0.5 or 1.0), or nil if not parsed
32
+ def version
33
+ return nil unless parsed
34
+
35
+ parsed.version
36
+ end
37
+
38
+ # Get number of glyphs
39
+ #
40
+ # @return [Integer, nil] Total number of glyphs, or nil if not parsed
41
+ def num_glyphs
42
+ parsed&.num_glyphs
43
+ end
44
+
45
+ # Check if this is a TrueType font (version 1.0)
46
+ #
47
+ # @return [Boolean] true if version 1.0
48
+ def truetype?
49
+ return false unless parsed
50
+
51
+ parsed.truetype?
52
+ end
53
+
54
+ # Check if this is a CFF font (version 0.5)
55
+ #
56
+ # @return [Boolean] true if version 0.5
57
+ def cff?
58
+ return false unless parsed
59
+
60
+ parsed.cff?
61
+ end
62
+
63
+ # Get maximum points in a non-composite glyph (version 1.0)
64
+ #
65
+ # @return [Integer, nil] Maximum points, or nil if not available
66
+ def max_points
67
+ return nil unless parsed&.version_1_0?
68
+
69
+ parsed.max_points
70
+ end
71
+
72
+ # Get maximum contours in a non-composite glyph (version 1.0)
73
+ #
74
+ # @return [Integer, nil] Maximum contours, or nil if not available
75
+ def max_contours
76
+ return nil unless parsed&.version_1_0?
77
+
78
+ parsed.max_contours
79
+ end
80
+
81
+ # Get maximum points in a composite glyph (version 1.0)
82
+ #
83
+ # @return [Integer, nil] Maximum composite points, or nil if not available
84
+ def max_composite_points
85
+ return nil unless parsed&.version_1_0?
86
+
87
+ parsed.max_composite_points
88
+ end
89
+
90
+ # Get maximum contours in a composite glyph (version 1.0)
91
+ #
92
+ # @return [Integer, nil] Maximum composite contours, or nil if not available
93
+ def max_composite_contours
94
+ return nil unless parsed&.version_1_0?
95
+
96
+ parsed.max_composite_contours
97
+ end
98
+
99
+ # Get maximum zones (version 1.0)
100
+ #
101
+ # @return [Integer, nil] Maximum zones (1 or 2), or nil if not available
102
+ def max_zones
103
+ return nil unless parsed&.version_1_0?
104
+
105
+ parsed.max_zones
106
+ end
107
+
108
+ # Get maximum twilight zone points (version 1.0)
109
+ #
110
+ # @return [Integer, nil] Maximum twilight points, or nil if not available
111
+ def max_twilight_points
112
+ return nil unless parsed&.version_1_0?
113
+
114
+ parsed.max_twilight_points
115
+ end
116
+
117
+ # Get maximum storage area locations (version 1.0)
118
+ #
119
+ # @return [Integer, nil] Maximum storage, or nil if not available
120
+ def max_storage
121
+ return nil unless parsed&.version_1_0?
122
+
123
+ parsed.max_storage
124
+ end
125
+
126
+ # Get maximum function definitions (version 1.0)
127
+ #
128
+ # @return [Integer, nil] Maximum function defs, or nil if not available
129
+ def max_function_defs
130
+ return nil unless parsed&.version_1_0?
131
+
132
+ parsed.max_function_defs
133
+ end
134
+
135
+ # Get maximum instruction definitions (version 1.0)
136
+ #
137
+ # @return [Integer, nil] Maximum instruction defs, or nil if not available
138
+ def max_instruction_defs
139
+ return nil unless parsed&.version_1_0?
140
+
141
+ parsed.max_instruction_defs
142
+ end
143
+
144
+ # Get maximum stack depth (version 1.0)
145
+ #
146
+ # @return [Integer, nil] Maximum stack elements, or nil if not available
147
+ def max_stack_elements
148
+ return nil unless parsed&.version_1_0?
149
+
150
+ parsed.max_stack_elements
151
+ end
152
+
153
+ # Get maximum byte count for glyph instructions (version 1.0)
154
+ #
155
+ # @return [Integer, nil] Maximum instruction size, or nil if not available
156
+ def max_size_of_instructions
157
+ return nil unless parsed&.version_1_0?
158
+
159
+ parsed.max_size_of_instructions
160
+ end
161
+
162
+ # Get maximum component elements in composite glyph (version 1.0)
163
+ #
164
+ # @return [Integer, nil] Maximum component elements, or nil if not available
165
+ def max_component_elements
166
+ return nil unless parsed&.version_1_0?
167
+
168
+ parsed.max_component_elements
169
+ end
170
+
171
+ # Get maximum levels of recursion in composite glyphs (version 1.0)
172
+ #
173
+ # @return [Integer, nil] Maximum component depth, or nil if not available
174
+ def max_component_depth
175
+ return nil unless parsed&.version_1_0?
176
+
177
+ parsed.max_component_depth
178
+ end
179
+
180
+ # Check if font uses composite glyphs
181
+ #
182
+ # @return [Boolean] true if max_component_elements > 0
183
+ def has_composite_glyphs?
184
+ max_comp = max_component_elements
185
+ !max_comp.nil? && max_comp.positive?
186
+ end
187
+
188
+ # Check if font uses twilight zone
189
+ #
190
+ # @return [Boolean] true if max_zones == 2
191
+ def has_twilight_zone?
192
+ max_zones == 2
193
+ end
194
+
195
+ # Get complexity statistics
196
+ #
197
+ # @return [Hash] Statistics about font complexity
198
+ def statistics
199
+ stats = {
200
+ num_glyphs: num_glyphs,
201
+ version: version,
202
+ truetype: truetype?,
203
+ cff: cff?,
204
+ }
205
+
206
+ if truetype?
207
+ stats[:max_points] = max_points
208
+ stats[:max_contours] = max_contours
209
+ stats[:max_composite_points] = max_composite_points
210
+ stats[:max_composite_contours] = max_composite_contours
211
+ stats[:max_component_elements] = max_component_elements
212
+ stats[:max_component_depth] = max_component_depth
213
+ stats[:has_composite_glyphs] = has_composite_glyphs?
214
+ stats[:has_twilight_zone] = has_twilight_zone?
215
+ end
216
+
217
+ stats
218
+ end
219
+
220
+ protected
221
+
222
+ # Validate the parsed maxp table
223
+ #
224
+ # @return [Boolean] true if valid
225
+ # @raise [InvalidFontError] if maxp table is invalid
226
+ def validate_parsed_table?
227
+ return true unless parsed
228
+
229
+ # Validate version
230
+ unless parsed.valid_version?
231
+ raise InvalidFontError,
232
+ "Invalid maxp table version: #{parsed.version} " \
233
+ "(must be 0.5 or 1.0)"
234
+ end
235
+
236
+ # Validate number of glyphs
237
+ unless parsed.valid_num_glyphs?
238
+ raise InvalidFontError,
239
+ "Invalid maxp num_glyphs: #{parsed.num_glyphs} (must be >= 1)"
240
+ end
241
+
242
+ # Validate max zones
243
+ unless parsed.valid_max_zones?
244
+ raise InvalidFontError,
245
+ "Invalid maxp max_zones: #{parsed.max_zones} (must be 1 or 2)"
246
+ end
247
+
248
+ # Validate metrics are reasonable
249
+ unless parsed.reasonable_metrics?
250
+ raise InvalidFontError,
251
+ "Invalid maxp metrics: values exceed reasonable limits"
252
+ end
253
+
254
+ true
255
+ end
256
+ end
257
+ end
258
+ end