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.
Files changed (185) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +529 -65
  3. data/Gemfile +1 -0
  4. data/LICENSE +5 -1
  5. data/README.adoc +1301 -275
  6. data/Rakefile +27 -2
  7. data/benchmark/variation_quick_bench.rb +47 -0
  8. data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
  9. data/fontisan.gemspec +4 -1
  10. data/lib/fontisan/binary/base_record.rb +22 -1
  11. data/lib/fontisan/cli.rb +309 -0
  12. data/lib/fontisan/collection/builder.rb +260 -0
  13. data/lib/fontisan/collection/offset_calculator.rb +227 -0
  14. data/lib/fontisan/collection/table_analyzer.rb +204 -0
  15. data/lib/fontisan/collection/table_deduplicator.rb +241 -0
  16. data/lib/fontisan/collection/writer.rb +306 -0
  17. data/lib/fontisan/commands/base_command.rb +8 -1
  18. data/lib/fontisan/commands/convert_command.rb +291 -0
  19. data/lib/fontisan/commands/export_command.rb +161 -0
  20. data/lib/fontisan/commands/info_command.rb +40 -6
  21. data/lib/fontisan/commands/instance_command.rb +295 -0
  22. data/lib/fontisan/commands/ls_command.rb +113 -0
  23. data/lib/fontisan/commands/pack_command.rb +241 -0
  24. data/lib/fontisan/commands/subset_command.rb +245 -0
  25. data/lib/fontisan/commands/unpack_command.rb +338 -0
  26. data/lib/fontisan/commands/validate_command.rb +178 -0
  27. data/lib/fontisan/commands/variable_command.rb +30 -1
  28. data/lib/fontisan/config/collection_settings.yml +56 -0
  29. data/lib/fontisan/config/conversion_matrix.yml +212 -0
  30. data/lib/fontisan/config/export_settings.yml +66 -0
  31. data/lib/fontisan/config/subset_profiles.yml +100 -0
  32. data/lib/fontisan/config/svg_settings.yml +60 -0
  33. data/lib/fontisan/config/validation_rules.yml +149 -0
  34. data/lib/fontisan/config/variable_settings.yml +99 -0
  35. data/lib/fontisan/config/woff2_settings.yml +77 -0
  36. data/lib/fontisan/constants.rb +69 -0
  37. data/lib/fontisan/converters/conversion_strategy.rb +96 -0
  38. data/lib/fontisan/converters/format_converter.rb +259 -0
  39. data/lib/fontisan/converters/outline_converter.rb +936 -0
  40. data/lib/fontisan/converters/svg_generator.rb +244 -0
  41. data/lib/fontisan/converters/table_copier.rb +117 -0
  42. data/lib/fontisan/converters/woff2_encoder.rb +416 -0
  43. data/lib/fontisan/converters/woff_writer.rb +391 -0
  44. data/lib/fontisan/error.rb +203 -0
  45. data/lib/fontisan/export/exporter.rb +262 -0
  46. data/lib/fontisan/export/table_serializer.rb +255 -0
  47. data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
  48. data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
  49. data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
  50. data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
  51. data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
  52. data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
  53. data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
  54. data/lib/fontisan/export/ttx_generator.rb +527 -0
  55. data/lib/fontisan/export/ttx_parser.rb +300 -0
  56. data/lib/fontisan/font_loader.rb +121 -12
  57. data/lib/fontisan/font_writer.rb +301 -0
  58. data/lib/fontisan/formatters/text_formatter.rb +102 -0
  59. data/lib/fontisan/glyph_accessor.rb +503 -0
  60. data/lib/fontisan/hints/hint_converter.rb +177 -0
  61. data/lib/fontisan/hints/postscript_hint_applier.rb +185 -0
  62. data/lib/fontisan/hints/postscript_hint_extractor.rb +254 -0
  63. data/lib/fontisan/hints/truetype_hint_applier.rb +71 -0
  64. data/lib/fontisan/hints/truetype_hint_extractor.rb +162 -0
  65. data/lib/fontisan/loading_modes.rb +113 -0
  66. data/lib/fontisan/metrics_calculator.rb +277 -0
  67. data/lib/fontisan/models/collection_font_summary.rb +52 -0
  68. data/lib/fontisan/models/collection_info.rb +76 -0
  69. data/lib/fontisan/models/collection_list_info.rb +37 -0
  70. data/lib/fontisan/models/font_export.rb +158 -0
  71. data/lib/fontisan/models/font_summary.rb +48 -0
  72. data/lib/fontisan/models/glyph_outline.rb +343 -0
  73. data/lib/fontisan/models/hint.rb +233 -0
  74. data/lib/fontisan/models/outline.rb +664 -0
  75. data/lib/fontisan/models/table_sharing_info.rb +40 -0
  76. data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
  77. data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
  78. data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
  79. data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
  80. data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
  81. data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
  82. data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
  83. data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
  84. data/lib/fontisan/models/ttx/ttfont.rb +49 -0
  85. data/lib/fontisan/models/validation_report.rb +203 -0
  86. data/lib/fontisan/open_type_collection.rb +156 -2
  87. data/lib/fontisan/open_type_font.rb +296 -10
  88. data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
  89. data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
  90. data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
  91. data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
  92. data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
  93. data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
  94. data/lib/fontisan/outline_extractor.rb +423 -0
  95. data/lib/fontisan/subset/builder.rb +268 -0
  96. data/lib/fontisan/subset/glyph_mapping.rb +215 -0
  97. data/lib/fontisan/subset/options.rb +142 -0
  98. data/lib/fontisan/subset/profile.rb +152 -0
  99. data/lib/fontisan/subset/table_subsetter.rb +461 -0
  100. data/lib/fontisan/svg/font_face_generator.rb +278 -0
  101. data/lib/fontisan/svg/font_generator.rb +264 -0
  102. data/lib/fontisan/svg/glyph_generator.rb +168 -0
  103. data/lib/fontisan/svg/view_box_calculator.rb +137 -0
  104. data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
  105. data/lib/fontisan/tables/cff/charset.rb +282 -0
  106. data/lib/fontisan/tables/cff/charstring.rb +905 -0
  107. data/lib/fontisan/tables/cff/charstring_builder.rb +322 -0
  108. data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
  109. data/lib/fontisan/tables/cff/dict.rb +351 -0
  110. data/lib/fontisan/tables/cff/dict_builder.rb +242 -0
  111. data/lib/fontisan/tables/cff/encoding.rb +274 -0
  112. data/lib/fontisan/tables/cff/header.rb +102 -0
  113. data/lib/fontisan/tables/cff/index.rb +237 -0
  114. data/lib/fontisan/tables/cff/index_builder.rb +170 -0
  115. data/lib/fontisan/tables/cff/private_dict.rb +284 -0
  116. data/lib/fontisan/tables/cff/top_dict.rb +236 -0
  117. data/lib/fontisan/tables/cff.rb +487 -0
  118. data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
  119. data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
  120. data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
  121. data/lib/fontisan/tables/cff2.rb +341 -0
  122. data/lib/fontisan/tables/cvar.rb +242 -0
  123. data/lib/fontisan/tables/fvar.rb +2 -2
  124. data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
  125. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
  126. data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
  127. data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
  128. data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
  129. data/lib/fontisan/tables/glyf.rb +235 -0
  130. data/lib/fontisan/tables/gvar.rb +270 -0
  131. data/lib/fontisan/tables/hhea.rb +124 -0
  132. data/lib/fontisan/tables/hmtx.rb +287 -0
  133. data/lib/fontisan/tables/hvar.rb +191 -0
  134. data/lib/fontisan/tables/loca.rb +322 -0
  135. data/lib/fontisan/tables/maxp.rb +192 -0
  136. data/lib/fontisan/tables/mvar.rb +185 -0
  137. data/lib/fontisan/tables/name.rb +99 -30
  138. data/lib/fontisan/tables/variation_common.rb +346 -0
  139. data/lib/fontisan/tables/vvar.rb +234 -0
  140. data/lib/fontisan/true_type_collection.rb +156 -2
  141. data/lib/fontisan/true_type_font.rb +297 -11
  142. data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
  143. data/lib/fontisan/utilities/checksum_calculator.rb +18 -0
  144. data/lib/fontisan/utils/thread_pool.rb +134 -0
  145. data/lib/fontisan/validation/checksum_validator.rb +170 -0
  146. data/lib/fontisan/validation/consistency_validator.rb +197 -0
  147. data/lib/fontisan/validation/structure_validator.rb +198 -0
  148. data/lib/fontisan/validation/table_validator.rb +158 -0
  149. data/lib/fontisan/validation/validator.rb +152 -0
  150. data/lib/fontisan/variable/axis_normalizer.rb +215 -0
  151. data/lib/fontisan/variable/delta_applicator.rb +313 -0
  152. data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
  153. data/lib/fontisan/variable/instancer.rb +344 -0
  154. data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
  155. data/lib/fontisan/variable/region_matcher.rb +208 -0
  156. data/lib/fontisan/variable/static_font_builder.rb +213 -0
  157. data/lib/fontisan/variable/table_updater.rb +219 -0
  158. data/lib/fontisan/variation/blend_applier.rb +199 -0
  159. data/lib/fontisan/variation/cache.rb +298 -0
  160. data/lib/fontisan/variation/cache_key_builder.rb +162 -0
  161. data/lib/fontisan/variation/converter.rb +268 -0
  162. data/lib/fontisan/variation/data_extractor.rb +86 -0
  163. data/lib/fontisan/variation/delta_applier.rb +266 -0
  164. data/lib/fontisan/variation/delta_parser.rb +228 -0
  165. data/lib/fontisan/variation/inspector.rb +275 -0
  166. data/lib/fontisan/variation/instance_generator.rb +273 -0
  167. data/lib/fontisan/variation/interpolator.rb +231 -0
  168. data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
  169. data/lib/fontisan/variation/optimizer.rb +418 -0
  170. data/lib/fontisan/variation/parallel_generator.rb +150 -0
  171. data/lib/fontisan/variation/region_matcher.rb +221 -0
  172. data/lib/fontisan/variation/subsetter.rb +463 -0
  173. data/lib/fontisan/variation/table_accessor.rb +105 -0
  174. data/lib/fontisan/variation/validator.rb +345 -0
  175. data/lib/fontisan/variation/variation_context.rb +211 -0
  176. data/lib/fontisan/version.rb +1 -1
  177. data/lib/fontisan/woff2/directory.rb +257 -0
  178. data/lib/fontisan/woff2/header.rb +101 -0
  179. data/lib/fontisan/woff2/table_transformer.rb +163 -0
  180. data/lib/fontisan/woff2_font.rb +712 -0
  181. data/lib/fontisan/woff_font.rb +483 -0
  182. data/lib/fontisan.rb +120 -0
  183. data/scripts/compare_stack_aware.rb +187 -0
  184. data/scripts/measure_optimization.rb +141 -0
  185. metadata +205 -4
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Svg
5
+ # Calculates SVG viewBox and handles coordinate transformations
6
+ #
7
+ # [`ViewBoxCalculator`](lib/fontisan/svg/view_box_calculator.rb) manages
8
+ # the coordinate system transformation between font space and SVG space.
9
+ # Font coordinates use a Y-up system (ascender is positive), while SVG
10
+ # uses Y-down (origin at top-left).
11
+ #
12
+ # Responsibilities:
13
+ # - Calculate appropriate viewBox for glyphs
14
+ # - Transform Y-coordinates (flip Y-axis)
15
+ # - Scale coordinates based on units-per-em
16
+ # - Provide consistent coordinate mapping
17
+ #
18
+ # This is a pure utility class with no state or side effects.
19
+ #
20
+ # @example Transform a Y coordinate
21
+ # calculator = ViewBoxCalculator.new(units_per_em: 1000, ascent: 800, descent: -200)
22
+ # svg_y = calculator.transform_y(700) # Font Y to SVG Y
23
+ #
24
+ # @example Calculate viewBox for a glyph
25
+ # viewbox = calculator.calculate_viewbox(x_min: 100, y_min: 0, x_max: 600, y_max: 700)
26
+ # # => "100 100 500 700"
27
+ class ViewBoxCalculator
28
+ # @return [Integer] Units per em from font
29
+ attr_reader :units_per_em
30
+
31
+ # @return [Integer] Font ascent
32
+ attr_reader :ascent
33
+
34
+ # @return [Integer] Font descent (typically negative)
35
+ attr_reader :descent
36
+
37
+ # Initialize calculator with font metrics
38
+ #
39
+ # @param units_per_em [Integer] Units per em from font head table
40
+ # @param ascent [Integer] Font ascent from hhea table
41
+ # @param descent [Integer] Font descent from hhea table (typically negative)
42
+ # @raise [ArgumentError] If parameters are invalid
43
+ def initialize(units_per_em:, ascent:, descent:)
44
+ validate_parameters!(units_per_em, ascent, descent)
45
+
46
+ @units_per_em = units_per_em
47
+ @ascent = ascent
48
+ @descent = descent
49
+ end
50
+
51
+ # Transform Y coordinate from font space to SVG space
52
+ #
53
+ # Font space: Y-up (ascender positive, descender negative)
54
+ # SVG space: Y-down (origin at top)
55
+ #
56
+ # Transformation: svg_y = ascent - font_y
57
+ #
58
+ # @param font_y [Numeric] Y coordinate in font space
59
+ # @return [Numeric] Y coordinate in SVG space
60
+ def transform_y(font_y)
61
+ ascent - font_y
62
+ end
63
+
64
+ # Transform point from font space to SVG space
65
+ #
66
+ # @param font_x [Numeric] X coordinate in font space
67
+ # @param font_y [Numeric] Y coordinate in font space
68
+ # @return [Array<Numeric>] [svg_x, svg_y]
69
+ def transform_point(font_x, font_y)
70
+ [font_x, transform_y(font_y)]
71
+ end
72
+
73
+ # Calculate viewBox string for SVG
74
+ #
75
+ # @param x_min [Numeric] Minimum X coordinate
76
+ # @param y_min [Numeric] Minimum Y coordinate
77
+ # @param x_max [Numeric] Maximum X coordinate
78
+ # @param y_max [Numeric] Maximum Y coordinate
79
+ # @return [String] ViewBox string "x y width height"
80
+ def calculate_viewbox(x_min:, y_min:, x_max:, y_max:)
81
+ # Transform bounding box to SVG space
82
+ svg_y_min = transform_y(y_max) # Y is flipped
83
+ svg_y_max = transform_y(y_min)
84
+
85
+ width = x_max - x_min
86
+ height = svg_y_max - svg_y_min
87
+
88
+ "#{x_min} #{svg_y_min} #{width} #{height}"
89
+ end
90
+
91
+ # Calculate font-level viewBox
92
+ #
93
+ # Uses font metrics to create a viewBox covering the entire font space
94
+ #
95
+ # @return [String] ViewBox string for entire font
96
+ def font_viewbox
97
+ # Typical font viewBox covers descent to ascent
98
+ # Width is units_per_em
99
+ height = ascent - descent
100
+ "0 0 #{units_per_em} #{height}"
101
+ end
102
+
103
+ # Get scale factor for coordinate precision
104
+ #
105
+ # @param target_units [Integer] Target units per em (default 1000)
106
+ # @return [Float] Scale factor
107
+ def scale_factor(target_units: 1000)
108
+ target_units.to_f / units_per_em
109
+ end
110
+
111
+ private
112
+
113
+ # Validate initialization parameters
114
+ #
115
+ # @param units_per_em [Integer] Units per em
116
+ # @param ascent [Integer] Font ascent
117
+ # @param descent [Integer] Font descent
118
+ # @raise [ArgumentError] If validation fails
119
+ def validate_parameters!(units_per_em, ascent, descent)
120
+ unless units_per_em.is_a?(Integer) && units_per_em.positive?
121
+ raise ArgumentError,
122
+ "units_per_em must be a positive Integer, got: #{units_per_em.inspect}"
123
+ end
124
+
125
+ unless ascent.is_a?(Integer)
126
+ raise ArgumentError,
127
+ "ascent must be an Integer, got: #{ascent.inspect}"
128
+ end
129
+
130
+ unless descent.is_a?(Integer)
131
+ raise ArgumentError,
132
+ "descent must be an Integer, got: #{descent.inspect}"
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Tables
5
+ class Cff
6
+ # Wrapper class for CFF glyph data
7
+ #
8
+ # [`CFFGlyph`](lib/fontisan/tables/cff/cff_glyph.rb) provides a unified
9
+ # interface for CFF glyphs that matches the API of TrueType glyphs
10
+ # ([`SimpleGlyph`](lib/fontisan/tables/glyf/simple_glyph.rb) and
11
+ # [`CompoundGlyph`](lib/fontisan/tables/glyf/compound_glyph.rb)).
12
+ #
13
+ # This allows [`GlyphAccessor`](lib/fontisan/glyph_accessor.rb) to work
14
+ # transparently with both TrueType (glyf) and OpenType/CFF fonts.
15
+ #
16
+ # CFF Glyph Characteristics:
17
+ # - Always "simple" (no composite structure like TrueType compound glyphs)
18
+ # - Outline data stored as Type 2 CharString programs
19
+ # - Width information embedded in CharString
20
+ # - Glyph names from Charset
21
+ #
22
+ # @example Accessing a CFF glyph
23
+ # cff = font.table("CFF ")
24
+ # charstring = cff.charstring_for_glyph(42)
25
+ # glyph = CFFGlyph.new(42, charstring, cff.charset, cff.encoding)
26
+ #
27
+ # puts glyph.name # => "A"
28
+ # puts glyph.width # => 500
29
+ # puts glyph.bounding_box # => [10, 0, 490, 700]
30
+ # puts glyph.simple? # => true
31
+ # puts glyph.compound? # => false
32
+ #
33
+ # Reference: [`docs/ttfunk-feature-analysis.md:541-575`](docs/ttfunk-feature-analysis.md:541)
34
+ class CFFGlyph
35
+ # @return [Integer] Glyph ID (GID)
36
+ attr_reader :glyph_id
37
+
38
+ # @return [CharString] Interpreted CharString with path data
39
+ attr_reader :charstring
40
+
41
+ # Initialize a CFF glyph wrapper
42
+ #
43
+ # @param glyph_id [Integer] Glyph ID (0-based, 0 is .notdef)
44
+ # @param charstring [CharString] Interpreted CharString object
45
+ # @param charset [Charset] Charset for name lookup
46
+ # @param encoding [Encoding, nil] Encoding (optional, for character code
47
+ # mapping)
48
+ def initialize(glyph_id, charstring, charset, encoding = nil)
49
+ @glyph_id = glyph_id
50
+ @charstring = charstring
51
+ @charset = charset
52
+ @encoding = encoding
53
+ end
54
+
55
+ # Check if this is a simple glyph
56
+ #
57
+ # CFF glyphs are conceptually "simple" - they don't have the composite
58
+ # structure that TrueType compound glyphs have. While CFF CharStrings
59
+ # can call subroutines, these are code reuse mechanisms, not glyph
60
+ # composition.
61
+ #
62
+ # @return [Boolean] Always true for CFF glyphs
63
+ def simple?
64
+ true
65
+ end
66
+
67
+ # Check if this is a compound glyph
68
+ #
69
+ # CFF glyphs don't have components like TrueType compound glyphs.
70
+ #
71
+ # @return [Boolean] Always false for CFF glyphs
72
+ def compound?
73
+ false
74
+ end
75
+
76
+ # Check if this glyph has no outline data
77
+ #
78
+ # A glyph is empty if its CharString path is empty (e.g., space
79
+ # character)
80
+ #
81
+ # @return [Boolean] True if glyph has no path data
82
+ def empty?
83
+ return true unless @charstring
84
+
85
+ @charstring.path.empty?
86
+ end
87
+
88
+ # Get the bounding box for this glyph
89
+ #
90
+ # Returns the glyph's bounding box in font units as calculated from
91
+ # the CharString path.
92
+ #
93
+ # @return [Array<Float>, nil] [xMin, yMin, xMax, yMax] or nil if empty
94
+ def bounding_box
95
+ return nil unless @charstring
96
+
97
+ @charstring.bounding_box
98
+ end
99
+
100
+ # Get the advance width for this glyph
101
+ #
102
+ # Returns the glyph's advance width from the CharString.
103
+ #
104
+ # @return [Integer, nil] Advance width in font units, or nil if not
105
+ # available
106
+ def width
107
+ return nil unless @charstring
108
+
109
+ @charstring.width
110
+ end
111
+
112
+ # Get the PostScript glyph name
113
+ #
114
+ # Looks up the glyph name from the Charset using the glyph ID.
115
+ #
116
+ # @return [String] Glyph name (e.g., "A", "Aacute", ".notdef")
117
+ def name
118
+ return ".notdef" unless @charset
119
+
120
+ @charset.glyph_name(@glyph_id) || ".notdef"
121
+ end
122
+
123
+ # Convert the glyph outline to drawing commands
124
+ #
125
+ # Returns an array of drawing commands that can be used to render
126
+ # the glyph outline.
127
+ #
128
+ # @return [Array<Array>] Array of command arrays:
129
+ # - [:move_to, x, y]
130
+ # - [:line_to, x, y]
131
+ # - [:curve_to, x1, y1, x2, y2, x, y]
132
+ #
133
+ # @example Rendering a glyph
134
+ # glyph.to_commands.each do |cmd|
135
+ # case cmd[0]
136
+ # when :move_to
137
+ # canvas.move_to(cmd[1], cmd[2])
138
+ # when :line_to
139
+ # canvas.line_to(cmd[1], cmd[2])
140
+ # when :curve_to
141
+ # canvas.curve_to(cmd[1], cmd[2], cmd[3], cmd[4], cmd[5], cmd[6])
142
+ # end
143
+ # end
144
+ def to_commands
145
+ return [] unless @charstring
146
+
147
+ @charstring.to_commands
148
+ end
149
+
150
+ # Get the raw path data
151
+ #
152
+ # Returns the raw path array from the CharString for advanced use cases.
153
+ #
154
+ # @return [Array<Hash>] Array of path command hashes with keys:
155
+ # - :type (:move_to, :line_to, :curve_to)
156
+ # - :x, :y (coordinates)
157
+ # - :x1, :y1, :x2, :y2 (control points for curves)
158
+ def path
159
+ return [] unless @charstring
160
+
161
+ @charstring.path
162
+ end
163
+
164
+ # String representation for debugging
165
+ #
166
+ # @return [String] Human-readable representation
167
+ def to_s
168
+ "#<#{self.class.name} gid=#{@glyph_id} name=#{name.inspect} " \
169
+ "width=#{width} bbox=#{bounding_box.inspect}>"
170
+ end
171
+
172
+ alias inspect to_s
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,282 @@
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 Charset structure
10
+ #
11
+ # Charset maps glyph IDs (GIDs) to glyph names via String IDs (SIDs).
12
+ # GID 0 is always `.notdef` and is not included in the Charset data.
13
+ #
14
+ # Three formats:
15
+ # - Format 0: Array of SIDs, one per glyph (except .notdef)
16
+ # - Format 1: Ranges with 8-bit nLeft counts
17
+ # - Format 2: Ranges with 16-bit nLeft counts
18
+ #
19
+ # Predefined charsets:
20
+ # - 0: ISOAdobe charset (SIDs 0-228)
21
+ # - 1: Expert charset
22
+ # - 2: Expert Subset charset
23
+ #
24
+ # Reference: CFF specification section 13 "Charsets"
25
+ # https://adobe-type-tools.github.io/font-tech-notes/pdfs/5176.CFF.pdf
26
+ #
27
+ # @example Reading a Charset
28
+ # charset = Fontisan::Tables::Cff::Charset.new(
29
+ # data, num_glyphs, cff_table
30
+ # )
31
+ # puts charset.glyph_name(5) # => "A"
32
+ # puts charset.glyph_id("A") # => 5
33
+ class Charset
34
+ # Format identifiers
35
+ FORMATS = {
36
+ 0 => :array,
37
+ 1 => :range_8,
38
+ 2 => :range_16,
39
+ }.freeze
40
+
41
+ # Predefined charset identifiers
42
+ PREDEFINED = {
43
+ 0 => :iso_adobe,
44
+ 1 => :expert,
45
+ 2 => :expert_subset,
46
+ }.freeze
47
+
48
+ # @return [Integer] Charset format (0, 1, or 2)
49
+ attr_reader :format_type
50
+
51
+ # @return [Array<String>] Glyph names indexed by GID
52
+ attr_reader :glyph_names
53
+
54
+ # Initialize a Charset
55
+ #
56
+ # @param data [String, Integer] Binary data or predefined charset ID
57
+ # @param num_glyphs [Integer] Number of glyphs in the font
58
+ # @param cff_table [Cff] Parent CFF table for string lookup
59
+ def initialize(data, num_glyphs, cff_table)
60
+ @num_glyphs = num_glyphs
61
+ @cff_table = cff_table
62
+ @glyph_names = [".notdef"] # GID 0 is always .notdef
63
+ @glyph_name_to_id = { ".notdef" => 0 }
64
+
65
+ if data.is_a?(Integer) && PREDEFINED.key?(data)
66
+ load_predefined_charset(data)
67
+ else
68
+ @data = data
69
+ parse!
70
+ end
71
+ end
72
+
73
+ # Get glyph name for a GID
74
+ #
75
+ # @param gid [Integer] Glyph ID
76
+ # @return [String, nil] Glyph name or nil if invalid GID
77
+ def glyph_name(gid)
78
+ return nil if gid.negative? || gid >= @glyph_names.size
79
+
80
+ @glyph_names[gid]
81
+ end
82
+
83
+ # Get GID for a glyph name
84
+ #
85
+ # @param name [String] Glyph name
86
+ # @return [Integer, nil] Glyph ID or nil if not found
87
+ def glyph_id(name)
88
+ @glyph_name_to_id[name]
89
+ end
90
+
91
+ # Get the format symbol
92
+ #
93
+ # @return [Symbol] Format identifier (:array, :range_8, :range_16, or
94
+ # :predefined)
95
+ def format
96
+ @format_type ? FORMATS[@format_type] : :predefined
97
+ end
98
+
99
+ private
100
+
101
+ # Parse the Charset from binary data
102
+ def parse!
103
+ io = StringIO.new(@data)
104
+ @format_type = read_uint8(io)
105
+
106
+ case @format_type
107
+ when 0
108
+ parse_format_0(io)
109
+ when 1
110
+ parse_format_1(io)
111
+ when 2
112
+ parse_format_2(io)
113
+ else
114
+ raise CorruptedTableError,
115
+ "Invalid Charset format: #{@format_type}"
116
+ end
117
+
118
+ build_name_to_id_map
119
+ rescue StandardError => e
120
+ raise CorruptedTableError,
121
+ "Failed to parse Charset: #{e.message}"
122
+ end
123
+
124
+ # Parse Format 0: Array of SIDs
125
+ #
126
+ # Format 0 directly lists SIDs for each glyph (except .notdef at GID 0)
127
+ #
128
+ # @param io [StringIO] Input stream positioned after format byte
129
+ def parse_format_0(io)
130
+ # Read one SID per glyph (num_glyphs - 1, excluding .notdef)
131
+ (@num_glyphs - 1).times do
132
+ sid = read_uint16(io)
133
+ glyph_name = sid_to_glyph_name(sid)
134
+ @glyph_names << glyph_name
135
+ end
136
+ end
137
+
138
+ # Parse Format 1: Ranges with 8-bit counts
139
+ #
140
+ # Format 1 uses ranges: first SID, nLeft (number of consecutive SIDs)
141
+ #
142
+ # @param io [StringIO] Input stream positioned after format byte
143
+ def parse_format_1(io)
144
+ glyph_count = 1 # Start at 1 (we already have .notdef at 0)
145
+
146
+ while glyph_count < @num_glyphs
147
+ first_sid = read_uint16(io)
148
+ n_left = read_uint8(io)
149
+
150
+ # Add glyphs for this range
151
+ (n_left + 1).times do |i|
152
+ sid = first_sid + i
153
+ glyph_name = sid_to_glyph_name(sid)
154
+ @glyph_names << glyph_name
155
+ glyph_count += 1
156
+ break if glyph_count >= @num_glyphs
157
+ end
158
+ end
159
+ end
160
+
161
+ # Parse Format 2: Ranges with 16-bit counts
162
+ #
163
+ # Format 2 is like Format 1 but with 16-bit nLeft values
164
+ #
165
+ # @param io [StringIO] Input stream positioned after format byte
166
+ def parse_format_2(io)
167
+ glyph_count = 1 # Start at 1 (we already have .notdef at 0)
168
+
169
+ while glyph_count < @num_glyphs
170
+ first_sid = read_uint16(io)
171
+ n_left = read_uint16(io)
172
+
173
+ # Add glyphs for this range
174
+ (n_left + 1).times do |i|
175
+ sid = first_sid + i
176
+ glyph_name = sid_to_glyph_name(sid)
177
+ @glyph_names << glyph_name
178
+ glyph_count += 1
179
+ break if glyph_count >= @num_glyphs
180
+ end
181
+ end
182
+ end
183
+
184
+ # Load a predefined charset
185
+ #
186
+ # @param charset_id [Integer] Predefined charset ID (0, 1, or 2)
187
+ def load_predefined_charset(charset_id)
188
+ @format_type = nil # Predefined charsets don't have a format
189
+
190
+ case charset_id
191
+ when 0
192
+ load_iso_adobe_charset
193
+ when 1
194
+ load_expert_charset
195
+ when 2
196
+ load_expert_subset_charset
197
+ end
198
+
199
+ build_name_to_id_map
200
+ end
201
+
202
+ # Load ISOAdobe charset (SIDs 0-228)
203
+ #
204
+ # This is the standard charset containing common Latin glyphs
205
+ def load_iso_adobe_charset
206
+ # ISOAdobe charset contains SIDs 0-228
207
+ # For a full implementation, we would need all 229 glyphs
208
+ # Here we generate them from SIDs
209
+ (@num_glyphs - 1).times do |i|
210
+ sid = i + 1 # Skip 0 (.notdef)
211
+ break if sid > 228
212
+
213
+ @glyph_names << sid_to_glyph_name(sid)
214
+ end
215
+ end
216
+
217
+ # Load Expert charset
218
+ #
219
+ # This is a special charset for expert fonts with additional glyphs
220
+ def load_expert_charset
221
+ # Expert charset contains specific SIDs for expert glyphs
222
+ # This is a placeholder - a full implementation would include the
223
+ # complete expert charset SID list from the CFF specification
224
+ (@num_glyphs - 1).times do |i|
225
+ @glyph_names << sid_to_glyph_name(i + 1)
226
+ end
227
+ end
228
+
229
+ # Load Expert Subset charset
230
+ #
231
+ # This is a subset of the Expert charset
232
+ def load_expert_subset_charset
233
+ # Expert Subset contains a subset of expert glyphs
234
+ # This is a placeholder - a full implementation would include the
235
+ # complete expert subset charset SID list from the CFF specification
236
+ (@num_glyphs - 1).times do |i|
237
+ @glyph_names << sid_to_glyph_name(i + 1)
238
+ end
239
+ end
240
+
241
+ # Convert SID to glyph name
242
+ #
243
+ # @param sid [Integer] String ID
244
+ # @return [String] Glyph name
245
+ def sid_to_glyph_name(sid)
246
+ @cff_table.string_for_sid(sid) || ".notdef"
247
+ end
248
+
249
+ # Build the name-to-ID lookup map
250
+ def build_name_to_id_map
251
+ @glyph_names.each_with_index do |name, gid|
252
+ @glyph_name_to_id[name] = gid
253
+ end
254
+ end
255
+
256
+ # Read an unsigned 8-bit integer
257
+ #
258
+ # @param io [StringIO] Input stream
259
+ # @return [Integer] The value
260
+ def read_uint8(io)
261
+ byte = io.read(1)
262
+ raise CorruptedTableError, "Unexpected end of Charset data" if
263
+ byte.nil?
264
+
265
+ byte.unpack1("C")
266
+ end
267
+
268
+ # Read an unsigned 16-bit integer (big-endian)
269
+ #
270
+ # @param io [StringIO] Input stream
271
+ # @return [Integer] The value
272
+ def read_uint16(io)
273
+ bytes = io.read(2)
274
+ raise CorruptedTableError, "Unexpected end of Charset data" if
275
+ bytes.nil? || bytes.bytesize < 2
276
+
277
+ bytes.unpack1("n") # Big-endian unsigned 16-bit
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end