fontisan 0.4.33 → 0.4.35

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,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Fontisan
6
+ module Models
7
+ # Variable-font axis descriptor for the audit report.
8
+ class AuditAxis < Lutaml::Model::Serializable
9
+ attribute :tag, :string
10
+ attribute :min_value, :float
11
+ attribute :default_value, :float
12
+ attribute :max_value, :float
13
+ attribute :name_id, :integer
14
+
15
+ json do
16
+ map "tag", to: :tag
17
+ map "min_value", to: :min_value
18
+ map "default_value", to: :default_value
19
+ map "max_value", to: :max_value
20
+ map "name_id", to: :name_id
21
+ end
22
+
23
+ yaml do
24
+ map "tag", to: :tag
25
+ map "min_value", to: :min_value
26
+ map "default_value", to: :default_value
27
+ map "max_value", to: :max_value
28
+ map "name_id", to: :name_id
29
+ end
30
+ end
31
+
32
+ # Structured font audit report covering identity, style, coverage,
33
+ # and OpenType layout facts. Designed for archival use — each report
34
+ # is self-describing with provenance (generated_at, fontisan_version,
35
+ # source_sha256).
36
+ #
37
+ # The report is a value object: it captures what the font FILE
38
+ # declares, not what any external index says about it.
39
+ class AuditReport < Lutaml::Model::Serializable
40
+ # Provenance
41
+ attribute :generated_at, :string
42
+ attribute :fontisan_version, :string
43
+ attribute :source_file, :string
44
+ attribute :source_sha256, :string
45
+ attribute :source_format, :string
46
+ attribute :font_index, :integer
47
+ attribute :num_fonts_in_source, :integer
48
+
49
+ # Identity
50
+ attribute :family_name, :string
51
+ attribute :subfamily_name, :string
52
+ attribute :full_name, :string
53
+ attribute :postscript_name, :string
54
+ attribute :version, :string
55
+ attribute :font_revision, :float
56
+
57
+ # Style
58
+ attribute :weight_class, :integer
59
+ attribute :width_class, :integer
60
+ attribute :italic, Lutaml::Model::Type::Boolean
61
+ attribute :bold, Lutaml::Model::Type::Boolean
62
+ attribute :panose, :string
63
+ attribute :is_variable, Lutaml::Model::Type::Boolean
64
+ attribute :axes, AuditAxis, collection: true
65
+
66
+ # Coverage
67
+ attribute :total_codepoints, :integer
68
+ attribute :total_glyphs, :integer
69
+ attribute :cmap_subtables, :integer, collection: true
70
+ attribute :codepoints, :string, collection: true
71
+
72
+ # OpenType layout
73
+ attribute :opentype_scripts, :string, collection: true
74
+ attribute :features, :string, collection: true
75
+
76
+ # Validation (populated only with --validate)
77
+ attribute :validation_issues, Models::ValidationReport::Issue, collection: true
78
+
79
+ json do
80
+ map "generated_at", to: :generated_at
81
+ map "fontisan_version", to: :fontisan_version
82
+ map "source_file", to: :source_file
83
+ map "source_sha256", to: :source_sha256
84
+ map "source_format", to: :source_format
85
+ map "font_index", to: :font_index
86
+ map "num_fonts_in_source", to: :num_fonts_in_source
87
+
88
+ map "family_name", to: :family_name
89
+ map "subfamily_name", to: :subfamily_name
90
+ map "full_name", to: :full_name
91
+ map "postscript_name", to: :postscript_name
92
+ map "version", to: :version
93
+ map "font_revision", to: :font_revision
94
+
95
+ map "weight_class", to: :weight_class
96
+ map "width_class", to: :width_class
97
+ map "italic", to: :italic
98
+ map "bold", to: :bold
99
+ map "panose", to: :panose
100
+ map "is_variable", to: :is_variable
101
+ map "axes", to: :axes
102
+
103
+ map "total_codepoints", to: :total_codepoints
104
+ map "total_glyphs", to: :total_glyphs
105
+ map "cmap_subtables", to: :cmap_subtables
106
+ map "codepoints", to: :codepoints
107
+
108
+ map "opentype_scripts", to: :opentype_scripts
109
+ map "features", to: :features
110
+ map "validation_issues", to: :validation_issues
111
+ end
112
+
113
+ yaml do
114
+ map "generated_at", to: :generated_at
115
+ map "fontisan_version", to: :fontisan_version
116
+ map "source_file", to: :source_file
117
+ map "source_sha256", to: :source_sha256
118
+ map "source_format", to: :source_format
119
+ map "font_index", to: :font_index
120
+ map "num_fonts_in_source", to: :num_fonts_in_source
121
+
122
+ map "family_name", to: :family_name
123
+ map "subfamily_name", to: :subfamily_name
124
+ map "full_name", to: :full_name
125
+ map "postscript_name", to: :postscript_name
126
+ map "version", to: :version
127
+ map "font_revision", to: :font_revision
128
+
129
+ map "weight_class", to: :weight_class
130
+ map "width_class", to: :width_class
131
+ map "italic", to: :italic
132
+ map "bold", to: :bold
133
+ map "panose", to: :panose
134
+ map "is_variable", to: :is_variable
135
+ map "axes", to: :axes
136
+
137
+ map "total_codepoints", to: :total_codepoints
138
+ map "total_glyphs", to: :total_glyphs
139
+ map "cmap_subtables", to: :cmap_subtables
140
+ map "codepoints", to: :codepoints
141
+
142
+ map "opentype_scripts", to: :opentype_scripts
143
+ map "features", to: :features
144
+ map "validation_issues", to: :validation_issues
145
+ end
146
+ end
147
+ end
148
+ end
@@ -6,6 +6,8 @@ module Fontisan
6
6
  module Models
7
7
  autoload :AllScriptsFeaturesInfo,
8
8
  "fontisan/models/all_scripts_features_info"
9
+ autoload :AuditAxis, "fontisan/models/audit_report"
10
+ autoload :AuditReport, "fontisan/models/audit_report"
9
11
  autoload :AxisInfo, "fontisan/models/variable_font_info"
10
12
  autoload :BitmapGlyph, "fontisan/models/bitmap_glyph"
11
13
  autoload :BitmapStrike, "fontisan/models/bitmap_strike"
@@ -42,6 +42,39 @@ module Fontisan
42
42
  def initialize(private_dict = nil)
43
43
  @private_dict = private_dict || PrivateDict.new
44
44
  @charstrings = {}
45
+ @encoding_override = nil
46
+ end
47
+
48
+ # Build a CharStrings from a pre-built glyph-name → charstring-bytecode
49
+ # hash. Useful for constructing test fixtures without parsing a full
50
+ # Type 1 font. Optionally accepts +encoding:+ to override the default
51
+ # Adobe Standard Encoding (char code → glyph name).
52
+ #
53
+ # @param charstrings_hash [Hash{String=>String}]
54
+ # @param encoding [Hash{Integer=>String}, nil]
55
+ # @param private_dict [PrivateDict, nil]
56
+ # @return [CharStrings]
57
+ def self.from_hash(charstrings_hash, encoding: nil, private_dict: nil)
58
+ cs = new(private_dict)
59
+ charstrings_hash.each { |name, data| cs.register(name, data) }
60
+ cs.set_encoding(encoding) if encoding
61
+ cs
62
+ end
63
+
64
+ # Register (or replace) a glyph's charstring data.
65
+ #
66
+ # @param glyph_name [String]
67
+ # @param charstring_data [String] binary charstring bytecode
68
+ def register(glyph_name, charstring_data)
69
+ @charstrings[glyph_name] = charstring_data
70
+ end
71
+
72
+ # Override the default Adobe Standard Encoding with a custom
73
+ # +char_code → glyph_name+ map.
74
+ #
75
+ # @param encoding [Hash{Integer=>String}]
76
+ def set_encoding(encoding)
77
+ @encoding_override = encoding
45
78
  end
46
79
 
47
80
  # Parse CharStrings dictionary from decrypted Type 1 font data
@@ -72,7 +105,7 @@ module Fontisan
72
105
  #
73
106
  # @return [Hash] Character code to glyph name mapping
74
107
  def encoding
75
- @encoding ||= build_standard_encoding
108
+ @encoding_override || (@encoding ||= build_standard_encoding)
76
109
  end
77
110
 
78
111
  # Iterate over all charstrings
@@ -2,6 +2,10 @@
2
2
 
3
3
  module Fontisan
4
4
  module Type1
5
+ # Error raised when a seac reference cannot be resolved (the referenced
6
+ # glyph is missing, or a cycle is detected in nested seac).
7
+ class SeacReferenceError < Fontisan::Error; end
8
+
5
9
  # Expands Type 1 seac composite glyphs into base + accent outlines
6
10
  #
7
11
  # [`SeacExpander`](lib/fontisan/type1/seac_expander.rb) handles the
@@ -9,6 +13,10 @@ module Fontisan
9
13
  # The seac operator combines two glyphs (a base character and an accent)
10
14
  # with a positioning offset, which must be decomposed for CFF conversion.
11
15
  #
16
+ # Supports nested seac: if a base or accent glyph is itself a seac
17
+ # composite, it is recursively expanded. Cycles are detected and raise
18
+ # {SeacReferenceError}.
19
+ #
12
20
  # The seac operator format is:
13
21
  # ```
14
22
  # seac asb adx ady bchar achar
@@ -28,6 +36,8 @@ module Fontisan
28
36
  #
29
37
  # @see https://www.adobe.com/devnet/font/pdfs/Type1.pdf
30
38
  class SeacExpander
39
+ MAX_DEPTH = 16
40
+
31
41
  # @return [CharStrings] Type 1 CharStrings dictionary
32
42
  attr_reader :charstrings
33
43
 
@@ -43,67 +53,15 @@ module Fontisan
43
53
  @private_dict = private_dict
44
54
  end
45
55
 
46
- # Decompose a seac composite glyph into base + accent outlines
47
- #
48
- # This method:
49
- # 1. Parses the seac operator to extract components
50
- # 2. Gets CharStrings for base and accent glyphs
51
- # 3. Parses both CharStrings into outline commands
52
- # 4. Transforms the accent by (adx, ady) offset
53
- # 5. Merges base and accent outlines into a single path
54
- # 6. Returns the decomposed CharString data
56
+ # Decompose a seac composite glyph into base + accent outlines.
57
+ # Handles nested seac recursively.
55
58
  #
56
59
  # @param glyph_name [String] Name of the composite glyph to decompose
57
60
  # @return [String, nil] Decomposed CharString bytecode, or nil if not a seac composite
58
- # @raise [Fontisan::Error] If base or accent glyphs are not found
59
- #
60
- # @example Decompose "Agrave" glyph
61
- # expander.decompose("Agrave")
61
+ # @raise [SeacReferenceError] If base or accent glyphs are not found,
62
+ # or if a cycle is detected
62
63
  def decompose(glyph_name)
63
- components = @charstrings.components_for(glyph_name)
64
- return nil unless components
65
-
66
- # Use the encoding map to lookup glyph names from character codes
67
- base_glyph_name = @charstrings.encoding[components[:base]]
68
- accent_glyph_name = @charstrings.encoding[components[:accent]]
69
-
70
- if base_glyph_name.nil?
71
- raise Fontisan::Error,
72
- "Base glyph for char code #{components[:base]} not found"
73
- end
74
-
75
- if accent_glyph_name.nil?
76
- raise Fontisan::Error,
77
- "Accent glyph for char code #{components[:accent]} not found"
78
- end
79
-
80
- # Get CharStrings for base and accent
81
- base_charstring = @charstrings[base_glyph_name]
82
- accent_charstring = @charstrings[accent_glyph_name]
83
-
84
- if base_charstring.nil?
85
- raise Fontisan::Error,
86
- "CharString not found for base glyph #{base_glyph_name}"
87
- end
88
-
89
- if accent_charstring.nil?
90
- raise Fontisan::Error,
91
- "CharString not found for accent glyph #{accent_glyph_name}"
92
- end
93
-
94
- # Parse both CharStrings into command sequences
95
- base_commands = parse_charstring_to_commands(base_charstring)
96
- accent_commands = parse_charstring_to_commands(accent_charstring)
97
-
98
- # Transform accent by (adx, ady) offset
99
- accent_commands = transform_commands(accent_commands, components[:adx],
100
- components[:ady])
101
-
102
- # Merge base and accent commands
103
- merged_commands = merge_outline_commands(base_commands, accent_commands)
104
-
105
- # Convert merged commands back to CharString bytecode
106
- generate_charstring(merged_commands)
64
+ decompose_with_visited(glyph_name, Set.new)
107
65
  end
108
66
 
109
67
  # Check if a glyph is a seac composite
@@ -123,6 +81,59 @@ module Fontisan
123
81
 
124
82
  private
125
83
 
84
+ # Recursive decomposition with cycle detection via +visited+ set.
85
+ def decompose_with_visited(glyph_name, visited)
86
+ return nil unless composite?(glyph_name)
87
+
88
+ if visited.include?(glyph_name)
89
+ raise SeacReferenceError,
90
+ "Cycle detected in seac references: #{visited.to_a.join(' → ')} → #{glyph_name}"
91
+ end
92
+
93
+ if visited.size >= MAX_DEPTH
94
+ raise SeacReferenceError,
95
+ "seac nesting depth exceeds #{MAX_DEPTH} at #{glyph_name}"
96
+ end
97
+
98
+ visited_next = visited.dup.add(glyph_name)
99
+ components = @charstrings.components_for(glyph_name)
100
+
101
+ base_name = resolve_char_code(components[:base], "base", glyph_name)
102
+ accent_name = resolve_char_code(components[:accent], "accent", glyph_name)
103
+
104
+ base_commands = resolve_glyph_commands(base_name, visited_next)
105
+ accent_commands = resolve_glyph_commands(accent_name, visited_next)
106
+ accent_commands = transform_commands(accent_commands, components[:adx],
107
+ components[:ady])
108
+ merged = merge_outline_commands(base_commands, accent_commands)
109
+ generate_charstring(merged)
110
+ end
111
+
112
+ # Resolve a char code to a glyph name via the font's encoding.
113
+ def resolve_char_code(char_code, role, parent)
114
+ name = @charstrings.encoding[char_code]
115
+ return name if name
116
+
117
+ raise SeacReferenceError,
118
+ "#{role} glyph for char code #{char_code} (in #{parent}) not found in encoding"
119
+ end
120
+
121
+ # Get outline commands for a glyph, recursively expanding nested seac.
122
+ def resolve_glyph_commands(glyph_name, visited)
123
+ charstring = @charstrings[glyph_name]
124
+ if charstring.nil?
125
+ raise SeacReferenceError,
126
+ "CharString not found for glyph #{glyph_name}"
127
+ end
128
+
129
+ if composite?(glyph_name)
130
+ nested = decompose_with_visited(glyph_name, visited)
131
+ parse_charstring_to_commands(nested)
132
+ else
133
+ parse_charstring_to_commands(charstring)
134
+ end
135
+ end
136
+
126
137
  # Parse Type 1 CharString into drawing commands
127
138
  #
128
139
  # Converts Type 1 CharString bytecode into a list of drawing commands
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Minimal Adobe FEA (features.fea) parser. Extracts the two most
7
+ # common feature constructs from UFO sources:
8
+ #
9
+ # feature liga { sub f i by fi; } liga; → LigatureSubst
10
+ # feature kern { pos A V -50; } kern; → PairPos
11
+ #
12
+ # Unsupported constructs are collected and surfaced via
13
+ # {ParsedFeatures#unsupported} so the caller can decide whether
14
+ # to warn or raise. This is the "limited fea subset" approach
15
+ # from TODO #10b — expand as real-world fonts demand.
16
+ #
17
+ # The parser is deliberately simple: it tokenizes on whitespace
18
+ # and semicolons, recognizes `feature <tag> { ... } <tag>;` blocks,
19
+ # and dispatches per-statement to registered rule handlers (OCP —
20
+ # new rule kinds are one handler addition).
21
+ #
22
+ # @example
23
+ # parsed = FeatureCompiler.parse(fea_text)
24
+ # parsed.ligatures_for("liga") # => [{sequence: ["f", "i"], result: "fi"}]
25
+ class FeatureCompiler
26
+ # @param text [String] features.fea content
27
+ # @return [ParsedFeatures]
28
+ def self.parse(text)
29
+ new.parse(text)
30
+ end
31
+
32
+ # @param text [String]
33
+ # @return [ParsedFeatures]
34
+ def parse(text)
35
+ @parsed = ParsedFeatures.new
36
+ tokenize_blocks(text || "")
37
+ @parsed
38
+ end
39
+
40
+ private
41
+
42
+ # Split text into `feature <tag> { body } <tag>;` blocks and
43
+ # pass each block body to the statement dispatcher.
44
+ def tokenize_blocks(text)
45
+ # Strip comments (# to end of line)
46
+ cleaned = text.gsub(%r{#[^\n]*}, "")
47
+ # Match feature blocks: feature TAG { ... } TAG;
48
+ cleaned.scan(/feature\s+([A-Za-z0-9]{4})\s*\{([^}]*)\}\s*\1\s*;/m) do |tag, body|
49
+ parse_body(tag, body)
50
+ end
51
+ end
52
+
53
+ # Parse statements within a feature block.
54
+ def parse_body(tag, body)
55
+ statements = body.split(";").map(&:strip).reject(&:empty?)
56
+ statements.each do |stmt|
57
+ tokens = stmt.split(/\s+/)
58
+ dispatch(tag, tokens, stmt)
59
+ end
60
+ end
61
+
62
+ # Dispatch a statement to the appropriate handler based on the
63
+ # leading keyword. Unknown statements are collected as unsupported.
64
+ def dispatch(tag, tokens, raw)
65
+ case tokens.first
66
+ when "sub" then handle_sub(tag, tokens)
67
+ when "pos" then handle_pos(tag, tokens)
68
+ else
69
+ @parsed.add_unsupported(tag, raw)
70
+ end
71
+ end
72
+
73
+ # `sub A B C by ABC;` → ligature substitution
74
+ def handle_sub(tag, tokens)
75
+ # tokens: ["sub", "A", "B", ..., "by", "ABC"]
76
+ by_idx = tokens.index("by")
77
+ unless by_idx && by_idx > 1 && tokens.size > by_idx + 1
78
+ @parsed.add_unsupported(tag, tokens.join(" "))
79
+ return
80
+ end
81
+
82
+ sequence = tokens[1, by_idx - 1]
83
+ result = tokens[by_idx + 1]
84
+ return if sequence.nil? || sequence.empty? || result.nil?
85
+
86
+ if sequence.size == 1
87
+ # Single substitution: `sub A by A.alt;` — unsupported for now
88
+ @parsed.add_unsupported(tag, tokens.join(" "))
89
+ return
90
+ end
91
+
92
+ @parsed.add_ligature(tag, sequence: sequence, result: result)
93
+ end
94
+
95
+ # `pos A B -50;` → pair positioning
96
+ def handle_pos(tag, tokens)
97
+ # tokens: ["pos", "A", "B", "-50"] or ["pos", "A", "B", "<", "0", "-50", "0", ">"]
98
+ return unless tokens.size >= 4
99
+
100
+ left = tokens[1]
101
+ right = tokens[2]
102
+ value = parse_pos_value(tokens[3])
103
+ return unless value
104
+
105
+ @parsed.add_pair(tag, left:, right:, value:)
106
+ end
107
+
108
+ # Parse a positioning value: either a bare integer or the start
109
+ # of a ValueRecord (we only handle the bare integer case for now).
110
+ def parse_pos_value(token)
111
+ Integer(token)
112
+ rescue ArgumentError
113
+ nil
114
+ end
115
+ end
116
+
117
+ # Result of parsing features.fea. Aggregates ligature and pair
118
+ # rules by feature tag, plus any unsupported statements.
119
+ class ParsedFeatures
120
+ attr_reader :ligatures, :pairs, :unsupported
121
+
122
+ def initialize
123
+ @ligatures = Hash.new { |h, k| h[k] = [] }
124
+ @pairs = Hash.new { |h, k| h[k] = [] }
125
+ @unsupported = Hash.new { |h, k| h[k] = [] }
126
+ end
127
+
128
+ # @param tag [String] feature tag
129
+ # @return [Array<Hash>] ligature rules: {sequence: [names], result: name}
130
+ def ligatures_for(tag)
131
+ @ligatures[tag] || []
132
+ end
133
+
134
+ # @param tag [String] feature tag
135
+ # @return [Array<Hash>] pair rules: {left:, right:, value:}
136
+ def pairs_for(tag)
137
+ @pairs[tag] || []
138
+ end
139
+
140
+ # @return [Boolean] true if no features were parsed
141
+ def empty?
142
+ @ligatures.empty? && @pairs.empty?
143
+ end
144
+
145
+ # ---- mutation (used by FeatureCompiler) ----
146
+
147
+ def add_ligature(tag, sequence:, result:)
148
+ @ligatures[tag] << { sequence: sequence, result: result }
149
+ end
150
+
151
+ def add_pair(tag, left:, right:, value:)
152
+ @pairs[tag] << { left: left, right: right, value: value }
153
+ end
154
+
155
+ def add_unsupported(tag, statement)
156
+ @unsupported[tag] << statement
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end