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,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sfnt_table"
4
+ require_relative "glyf"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # OOP representation of the 'glyf' (Glyph Data) table
9
+ #
10
+ # The glyf table contains TrueType glyph outline data. Each glyph is
11
+ # described by either a simple glyph (with contours and points) or a
12
+ # compound glyph (composed of other glyphs with transformations).
13
+ #
14
+ # This class extends SfntTable to provide glyf-specific convenience
15
+ # methods for accessing glyph data. The glyf table requires context
16
+ # from the loca and head tables to function properly.
17
+ #
18
+ # @example Accessing glyphs
19
+ # glyf = font.sfnt_table("glyf")
20
+ # loca = font.table("loca")
21
+ # head = font.table("head")
22
+ #
23
+ # glyf.parse_with_context(loca, head)
24
+ # glyph = glyf.glyph_for(42) # Get glyph by ID
25
+ # glyph.simple? # => true or false
26
+ # glyph.bounding_box # => [xMin, yMin, xMax, yMax]
27
+ class GlyfTable < SfntTable
28
+ # Cache for context tables
29
+ attr_reader :loca_table, :head_table
30
+
31
+ # Parse the glyf table with required context
32
+ #
33
+ # The glyf table cannot be used without context from loca and head tables.
34
+ # This method must be called before accessing glyph data.
35
+ #
36
+ # @param loca [Tables::Loca] Parsed loca table
37
+ # @param head [Tables::Head] Parsed head table
38
+ # @return [self] Returns self for chaining
39
+ # @raise [ArgumentError] if context is invalid
40
+ def parse_with_context(loca, head)
41
+ unless loca && head
42
+ raise ArgumentError,
43
+ "glyf table requires both loca and head tables as context"
44
+ end
45
+
46
+ @loca_table = loca
47
+ @head_table = head
48
+
49
+ # Ensure parsed data is loaded
50
+ parse
51
+
52
+ self
53
+ end
54
+
55
+ # Check if table has been parsed with context
56
+ #
57
+ # @return [Boolean] true if context is available
58
+ def has_context?
59
+ !@loca_table.nil? && !@head_table.nil?
60
+ end
61
+
62
+ # Get a glyph by ID
63
+ #
64
+ # @param glyph_id [Integer] Glyph ID (0-based, 0 is .notdef)
65
+ # @return [SimpleGlyph, CompoundGlyph, nil] Parsed glyph or nil if empty
66
+ # @raise [ArgumentError] if context not set
67
+ def glyph_for(glyph_id)
68
+ ensure_context!
69
+ return nil unless parsed
70
+
71
+ parsed.glyph_for(glyph_id, @loca_table, @head_table)
72
+ end
73
+
74
+ # Check if a glyph is simple (has contours)
75
+ #
76
+ # @param glyph_id [Integer] Glyph ID
77
+ # @return [Boolean] true if glyph is simple
78
+ def simple_glyph?(glyph_id)
79
+ glyph = glyph_for(glyph_id)
80
+ return false if glyph.nil?
81
+
82
+ glyph.respond_to?(:num_contours) && glyph.num_contours >= 0
83
+ end
84
+
85
+ # Check if a glyph is compound
86
+ #
87
+ # @param glyph_id [Integer] Glyph ID
88
+ # @return [Boolean] true if glyph is compound
89
+ def compound_glyph?(glyph_id)
90
+ glyph = glyph_for(glyph_id)
91
+ return false if glyph.nil?
92
+
93
+ glyph.respond_to?(:num_contours) && glyph.num_contours == -1
94
+ end
95
+
96
+ # Check if a glyph is empty
97
+ #
98
+ # @param glyph_id [Integer] Glyph ID
99
+ # @return [Boolean] true if glyph has no data
100
+ def empty_glyph?(glyph_id)
101
+ glyph_for(glyph_id).nil?
102
+ end
103
+
104
+ # Get glyph bounding box
105
+ #
106
+ # @param glyph_id [Integer] Glyph ID
107
+ # @return [Array<Integer>, nil] [xMin, yMin, xMax, yMax] or nil
108
+ def glyph_bounding_box(glyph_id)
109
+ glyph = glyph_for(glyph_id)
110
+ return nil if glyph.nil?
111
+
112
+ [glyph.x_min, glyph.y_min, glyph.x_max, glyph.y_max]
113
+ end
114
+
115
+ # Get number of contours for a glyph
116
+ #
117
+ # @param glyph_id [Integer] Glyph ID
118
+ # @return [Integer, nil] Number of contours, or nil
119
+ def glyph_contour_count(glyph_id)
120
+ glyph = glyph_for(glyph_id)
121
+ return nil if glyph.nil?
122
+
123
+ glyph.num_contours if glyph.respond_to?(:num_contours)
124
+ end
125
+
126
+ # Get number of points for a simple glyph
127
+ #
128
+ # @param glyph_id [Integer] Glyph ID
129
+ # @return [Integer, nil] Number of points, or nil
130
+ def glyph_point_count(glyph_id)
131
+ glyph = glyph_for(glyph_id)
132
+ return nil if glyph.nil? || !glyph.respond_to?(:num_points)
133
+
134
+ glyph.num_points
135
+ end
136
+
137
+ # Get glyph cache size
138
+ #
139
+ # @return [Integer] Number of cached glyphs
140
+ def cache_size
141
+ return 0 unless parsed
142
+
143
+ parsed.cache_size
144
+ end
145
+
146
+ # Clear glyph cache
147
+ #
148
+ # @return [void]
149
+ def clear_cache
150
+ return unless parsed
151
+
152
+ parsed.clear_cache
153
+ end
154
+
155
+ # Validate all glyphs are accessible
156
+ #
157
+ # @param num_glyphs [Integer] Total number of glyphs to check
158
+ # @return [Boolean] true if all glyphs can be accessed
159
+ def all_glyphs_accessible?(num_glyphs)
160
+ ensure_context!
161
+ return false unless parsed
162
+
163
+ parsed.all_glyphs_accessible?(@loca_table, @head_table, num_glyphs)
164
+ end
165
+
166
+ # Validate no glyphs are clipped
167
+ #
168
+ # @param num_glyphs [Integer] Total number of glyphs to check
169
+ # @return [Boolean] true if no glyphs exceed font bounds
170
+ def no_clipped_glyphs?(num_glyphs)
171
+ ensure_context!
172
+ return false unless parsed
173
+
174
+ parsed.no_clipped_glyphs?(@loca_table, @head_table, num_glyphs)
175
+ end
176
+
177
+ # Validate all glyphs have valid contour counts
178
+ #
179
+ # @param glyph_id [Integer] Glyph ID to check
180
+ # @return [Boolean] true if contour count is valid
181
+ def valid_contour_count?(glyph_id)
182
+ ensure_context!
183
+ return false unless parsed
184
+
185
+ parsed.valid_contour_count?(glyph_id, @loca_table, @head_table)
186
+ end
187
+
188
+ # Validate glyphs have sound instructions
189
+ #
190
+ # @param num_glyphs [Integer] Total number of glyphs to check
191
+ # @return [Boolean] true if all instructions are valid
192
+ def instructions_sound?(num_glyphs)
193
+ ensure_context!
194
+ return false unless parsed
195
+
196
+ parsed.instructions_sound?(@loca_table, @head_table, num_glyphs)
197
+ end
198
+
199
+ # Get glyph statistics
200
+ #
201
+ # @param num_glyphs [Integer] Total number of glyphs
202
+ # @return [Hash] Statistics about the glyphs
203
+ def statistics(num_glyphs)
204
+ ensure_context!
205
+ return {} unless parsed
206
+
207
+ simple_count = 0
208
+ compound_count = 0
209
+ empty_count = 0
210
+
211
+ (0...num_glyphs).each do |gid|
212
+ if empty_glyph?(gid)
213
+ empty_count += 1
214
+ elsif simple_glyph?(gid)
215
+ simple_count += 1
216
+ elsif compound_glyph?(gid)
217
+ compound_count += 1
218
+ end
219
+ end
220
+
221
+ {
222
+ total_glyphs: num_glyphs,
223
+ simple_glyphs: simple_count,
224
+ compound_glyphs: compound_count,
225
+ empty_glyphs: empty_count,
226
+ cached_glyphs: cache_size,
227
+ }
228
+ end
229
+
230
+ private
231
+
232
+ # Ensure context tables are set
233
+ #
234
+ # @raise [ArgumentError] if context not set
235
+ def ensure_context!
236
+ unless has_context?
237
+ raise ArgumentError,
238
+ "glyf table requires context. Call parse_with_context(loca, head) first"
239
+ end
240
+ end
241
+
242
+ protected
243
+
244
+ # Validate the parsed glyf table
245
+ #
246
+ # @return [Boolean] true if valid
247
+ # @raise [InvalidFontError] if glyf table is invalid
248
+ def validate_parsed_table?
249
+ # Glyf table validation requires context, so we can't validate here
250
+ # Validation is deferred until context is provided
251
+ true
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sfnt_table"
4
+ require_relative "head"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # OOP representation of the 'head' (Font Header) table
9
+ #
10
+ # The head table contains global information about the font, including
11
+ # metadata about the font file, bounding box, and indexing information.
12
+ #
13
+ # This class extends SfntTable to provide head-specific validation and
14
+ # convenience methods for accessing common head table fields.
15
+ #
16
+ # @example Accessing head table data
17
+ # head = font.table("head") # Returns SfntTable instance
18
+ # head.magic_number_valid? # => true
19
+ # head.units_per_em # => 2048
20
+ # head.bounding_box # => {x_min: -123, y_min: -456, ...}
21
+ class HeadTable < SfntTable
22
+ # Check if magic number is valid
23
+ #
24
+ # @return [Boolean] true if magic number is 0x5F0F3CF5
25
+ def magic_number_valid?
26
+ parsed && parsed.magic_number == Tables::Head::MAGIC_NUMBER
27
+ end
28
+
29
+ # Get units per em
30
+ #
31
+ # @return [Integer, nil] Units per em value, or nil if not parsed
32
+ def units_per_em
33
+ parsed&.units_per_em
34
+ end
35
+
36
+ # Get font bounding box
37
+ #
38
+ # @return [Hash, nil] Bounding box hash, or nil if not parsed
39
+ def bounding_box
40
+ return nil unless parsed
41
+
42
+ {
43
+ x_min: parsed.x_min,
44
+ y_min: parsed.y_min,
45
+ x_max: parsed.x_max,
46
+ y_max: parsed.y_max,
47
+ }
48
+ end
49
+
50
+ # Get index to loc format
51
+ #
52
+ # @return [Integer, nil] IndexToLocFormat value, or nil if not parsed
53
+ def index_to_loc_format
54
+ parsed&.index_to_loc_format
55
+ end
56
+
57
+ # Check if using long loca format
58
+ #
59
+ # @return [Boolean] true if using 32-bit loca offsets
60
+ def long_loca_format?
61
+ index_to_loc_format == 1
62
+ end
63
+
64
+ # Get font version
65
+ #
66
+ # @return [Float, nil] Font version as Fixed-point number
67
+ def version
68
+ parsed&.version
69
+ end
70
+
71
+ # Get font revision
72
+ #
73
+ # @return [Float, nil] Font revision as Fixed-point number
74
+ def font_revision
75
+ parsed&.font_revision
76
+ end
77
+
78
+ protected
79
+
80
+ # Validate the parsed head table
81
+ #
82
+ # @return [Boolean] true if valid
83
+ # @raise [InvalidFontError] if magic number is invalid
84
+ def validate_parsed_table?
85
+ return true unless parsed
86
+
87
+ unless magic_number_valid?
88
+ raise InvalidFontError,
89
+ "Invalid head table magic number: expected 0x#{Tables::Head::MAGIC_NUMBER.to_s(16).upcase}, " \
90
+ "got 0x#{parsed.magic_number.to_s(16).upcase}"
91
+ end
92
+
93
+ # Validate units_per_em is power of 2
94
+ upem = parsed.units_per_em
95
+ unless upem&.positive? && (upem & (upem - 1)).zero?
96
+ raise InvalidFontError,
97
+ "Invalid head table units_per_em: #{upem} (must be power of 2)"
98
+ end
99
+
100
+ # Validate index_to_loc_format
101
+ ilf = parsed.index_to_loc_format
102
+ unless [0, 1].include?(ilf)
103
+ raise InvalidFontError,
104
+ "Invalid head table indexToLocFormat: #{ilf} (must be 0 or 1)"
105
+ end
106
+
107
+ true
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sfnt_table"
4
+ require_relative "hhea"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # OOP representation of the 'hhea' (Horizontal Header) table
9
+ #
10
+ # The hhea table contains horizontal layout metrics for the entire font,
11
+ # defining font-wide horizontal metrics such as ascent, descent, line gap,
12
+ # and the number of horizontal metrics in the hmtx table.
13
+ #
14
+ # This class extends SfntTable to provide hhea-specific validation and
15
+ # convenience methods for accessing common hhea table fields.
16
+ #
17
+ # @example Accessing hhea table data
18
+ # hhea = font.sfnt_table("hhea")
19
+ # hhea.ascent # => 2048
20
+ # hhea.descent # => -512
21
+ # hhea.line_gap # => 0
22
+ # hhea.line_height # => 2560
23
+ class HheaTable < SfntTable
24
+ # Fixed value 0x00010000 for version 1.0
25
+ VERSION_1_0 = 0x00010000
26
+
27
+ # Get hhea table version
28
+ #
29
+ # @return [Float, nil] Version number (typically 1.0), or nil if not parsed
30
+ def version
31
+ return nil unless parsed
32
+
33
+ parsed.version
34
+ end
35
+
36
+ # Get typographic ascent
37
+ #
38
+ # Distance from baseline to highest ascender (positive value)
39
+ #
40
+ # @return [Integer, nil] Ascent in FUnits, or nil if not parsed
41
+ def ascent
42
+ parsed&.ascent
43
+ end
44
+
45
+ # Get typographic descent
46
+ #
47
+ # Distance from baseline to lowest descender (negative value)
48
+ #
49
+ # @return [Integer, nil] Descent in FUnits, or nil if not parsed
50
+ def descent
51
+ parsed&.descent
52
+ end
53
+
54
+ # Get typographic line gap
55
+ #
56
+ # Additional space between lines (non-negative value)
57
+ #
58
+ # @return [Integer, nil] Line gap in FUnits, or nil if not parsed
59
+ def line_gap
60
+ parsed&.line_gap
61
+ end
62
+
63
+ # Get total line height
64
+ #
65
+ # Calculated as ascent - descent + line_gap
66
+ #
67
+ # @return [Integer, nil] Line height in FUnits, or nil if not parsed
68
+ def line_height
69
+ return nil unless parsed
70
+
71
+ ascent - descent + line_gap
72
+ end
73
+
74
+ # Get maximum advance width
75
+ #
76
+ # Maximum advance width value in hmtx table
77
+ #
78
+ # @return [Integer, nil] Maximum advance width, or nil if not parsed
79
+ def advance_width_max
80
+ parsed&.advance_width_max
81
+ end
82
+
83
+ # Get minimum left sidebearing
84
+ #
85
+ # @return [Integer, nil] Minimum lsb value, or nil if not parsed
86
+ def min_left_side_bearing
87
+ parsed&.min_left_side_bearing
88
+ end
89
+
90
+ # Get minimum right sidebearing
91
+ #
92
+ # @return [Integer, nil] Minimum rsb value, or nil if not parsed
93
+ def min_right_side_bearing
94
+ parsed&.min_right_side_bearing
95
+ end
96
+
97
+ # Get maximum x extent
98
+ #
99
+ # Maximum of lsb + (xMax - xMin) for all glyphs
100
+ #
101
+ # @return [Integer, nil] Maximum extent, or nil if not parsed
102
+ def x_max_extent
103
+ parsed&.x_max_extent
104
+ end
105
+
106
+ # Get caret slope rise
107
+ #
108
+ # Used to calculate slope of cursor (rise/run)
109
+ # For vertical text: rise = 1, run = 0
110
+ #
111
+ # @return [Integer, nil] Caret slope rise, or nil if not parsed
112
+ def caret_slope_rise
113
+ parsed&.caret_slope_rise
114
+ end
115
+
116
+ # Get caret slope run
117
+ #
118
+ # Used to calculate slope of cursor (rise/run)
119
+ # For vertical text: run = 0
120
+ #
121
+ # @return [Integer, nil] Caret slope run, or nil if not parsed
122
+ def caret_slope_run
123
+ parsed&.caret_slope_run
124
+ end
125
+
126
+ # Get caret offset
127
+ #
128
+ # Amount by which slanted highlight should be shifted
129
+ #
130
+ # @return [Integer, nil] Caret offset, or nil if not parsed
131
+ def caret_offset
132
+ parsed&.caret_offset
133
+ end
134
+
135
+ # Get metric data format
136
+ #
137
+ # Format of metric data (0 for current format)
138
+ #
139
+ # @return [Integer, nil] Metric data format, or nil if not parsed
140
+ def metric_data_format
141
+ parsed&.metric_data_format
142
+ end
143
+
144
+ # Get number of hmetrics
145
+ #
146
+ # Number of hMetric entries in hmtx table
147
+ #
148
+ # @return [Integer, nil] Number of metrics (must be >= 1), or nil if not parsed
149
+ def number_of_h_metrics
150
+ parsed&.number_of_h_metrics
151
+ end
152
+
153
+ # Check if caret is vertical
154
+ #
155
+ # @return [Boolean] true if caret is vertical (rise != 0, run == 0)
156
+ def vertical_caret?
157
+ return false unless parsed
158
+
159
+ caret_slope_rise != 0 && caret_slope_run.zero?
160
+ end
161
+
162
+ # Check if caret is italic
163
+ #
164
+ # @return [Boolean] true if caret is slanted (both rise and run non-zero)
165
+ def italic_caret?
166
+ return false unless parsed
167
+
168
+ caret_slope_rise != 0 && caret_slope_run != 0
169
+ end
170
+
171
+ # Check if caret is horizontal
172
+ #
173
+ # @return [Boolean] true if caret is horizontal (rise == 0, run != 0)
174
+ def horizontal_caret?
175
+ return false unless parsed
176
+
177
+ caret_slope_rise.zero? && caret_slope_run != 0
178
+ end
179
+
180
+ # Get caret angle in degrees
181
+ #
182
+ # @return [Float, nil] Caret angle in degrees, or nil if not parsed
183
+ def caret_angle
184
+ return nil unless parsed
185
+
186
+ return 0.0 if caret_slope_run.zero?
187
+
188
+ Math.atan2(caret_slope_rise, caret_slope_run) * (180.0 / Math::PI)
189
+ end
190
+
191
+ protected
192
+
193
+ # Validate the parsed hhea table
194
+ #
195
+ # @return [Boolean] true if valid
196
+ # @raise [InvalidFontError] if hhea table is invalid
197
+ def validate_parsed_table?
198
+ return true unless parsed
199
+
200
+ # Validate version
201
+ unless parsed.valid_version?
202
+ raise InvalidFontError,
203
+ "Invalid hhea table version: expected 0x00010000 (1.0), " \
204
+ "got 0x#{parsed.version_raw.to_s(16).upcase}"
205
+ end
206
+
207
+ # Validate metric data format
208
+ unless parsed.valid_metric_data_format?
209
+ raise InvalidFontError,
210
+ "Invalid hhea metric data format: #{parsed.metric_data_format} (must be 0)"
211
+ end
212
+
213
+ # Validate number of h metrics
214
+ unless parsed.valid_number_of_h_metrics?
215
+ raise InvalidFontError,
216
+ "Invalid hhea number_of_h_metrics: #{parsed.number_of_h_metrics} (must be >= 1)"
217
+ end
218
+
219
+ # Validate ascent/descent
220
+ unless parsed.valid_ascent_descent?
221
+ raise InvalidFontError,
222
+ "Invalid hhea ascent/descent: ascent=#{parsed.ascent}, " \
223
+ "descent=#{parsed.descent} (ascent must be positive, descent must be negative)"
224
+ end
225
+
226
+ # Validate line gap
227
+ unless parsed.valid_line_gap?
228
+ raise InvalidFontError,
229
+ "Invalid hhea line_gap: #{parsed.line_gap} (must be >= 0)"
230
+ end
231
+
232
+ # Validate advance width max
233
+ unless parsed.valid_advance_width_max?
234
+ raise InvalidFontError,
235
+ "Invalid hhea advance_width_max: #{parsed.advance_width_max} (must be > 0)"
236
+ end
237
+
238
+ # Validate caret slope
239
+ unless parsed.valid_caret_slope?
240
+ raise InvalidFontError,
241
+ "Invalid hhea caret slope: rise=#{parsed.caret_slope_rise}, " \
242
+ "run=#{parsed.caret_slope_run} (at least one must be non-zero)"
243
+ end
244
+
245
+ # Validate x max extent
246
+ unless parsed.valid_x_max_extent?
247
+ raise InvalidFontError,
248
+ "Invalid hhea x_max_extent: #{parsed.x_max_extent} (must be > 0)"
249
+ end
250
+
251
+ true
252
+ end
253
+ end
254
+ end
255
+ end