fontisan 0.4.32 → 0.4.34

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,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "time"
5
+
6
+ module Fontisan
7
+ module Commands
8
+ # Produces a structured {Models::AuditReport} for a single font or
9
+ # an array of reports for every face in a collection.
10
+ #
11
+ # The audit report is MECE with ucode: fontisan owns the font-identity
12
+ # axis (name table, style metadata, OpenType layout); ucode owns the
13
+ # Unicode-coverage axis (UCD parsing, block/script aggregation). The
14
+ # audit command emits the raw codepoint list so a consumer can run
15
+ # ucode separately without re-reading the font.
16
+ #
17
+ # Delegates data extraction to existing sub-commands and table readers
18
+ # — no table re-parsing happens here. This keeps the audit command a
19
+ # thin orchestration layer (DRY, single source of truth).
20
+ #
21
+ # @example Single font
22
+ # cmd = AuditCommand.new("Inter.ttf", include_codepoints: true)
23
+ # cmd.run # => Models::AuditReport
24
+ #
25
+ # @example Collection
26
+ # cmd = AuditCommand.new("Inter.ttc")
27
+ # cmd.run # => Array<Models::AuditReport>
28
+ class AuditCommand < BaseCommand
29
+ # @return [Models::AuditReport, Array<Models::AuditReport>]
30
+ def run
31
+ if FontLoader.collection?(@font_path)
32
+ audit_collection
33
+ else
34
+ audit_single_font(font_index: nil, num_fonts: 1)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ # ------------------------------------------------------------------
41
+ # Collection dispatch
42
+ # ------------------------------------------------------------------
43
+
44
+ def audit_collection
45
+ collection = FontLoader.load_collection(@font_path)
46
+ num = collection.num_fonts
47
+ Array.new(num) do |idx|
48
+ face = load_face(idx)
49
+ build_report(face, font_index: idx, num_fonts: num)
50
+ end
51
+ end
52
+
53
+ def audit_single_font(font_index:, num_fonts:)
54
+ build_report(font, font_index: font_index, num_fonts: num_fonts)
55
+ end
56
+
57
+ # Reload a single face from a collection by index, with full tables
58
+ # so the audit is complete.
59
+ def load_face(idx)
60
+ FontLoader.load(@font_path, font_index: idx, mode: LoadingModes::FULL)
61
+ end
62
+
63
+ # ------------------------------------------------------------------
64
+ # Report assembly
65
+ # ------------------------------------------------------------------
66
+
67
+ # @param face [SfntFont] loaded font
68
+ # @param font_index [Integer, nil]
69
+ # @param num_fonts [Integer]
70
+ # @return [Models::AuditReport]
71
+ def build_report(face, font_index:, num_fonts:)
72
+ Models::AuditReport.new.tap do |r|
73
+ populate_provenance(r, font_index:, num_fonts:)
74
+ populate_identity(r, face)
75
+ populate_style(r, face)
76
+ populate_coverage(r, face)
77
+ populate_layout(r, face)
78
+ end
79
+ end
80
+
81
+ # ------------------------------------------------------------------
82
+ # Provenance
83
+ # ------------------------------------------------------------------
84
+
85
+ def populate_provenance(report, font_index:, num_fonts:)
86
+ report.generated_at = Time.now.utc.iso8601
87
+ report.fontisan_version = VERSION
88
+ report.source_file = File.basename(@font_path)
89
+ report.source_sha256 = Digest::SHA256.file(@font_path).hexdigest
90
+ report.source_format = detect_source_format
91
+ report.font_index = font_index
92
+ report.num_fonts_in_source = num_fonts
93
+ end
94
+
95
+ def detect_source_format
96
+ fmt = FontLoader.detect_format(@font_path)
97
+ fmt ? fmt.to_s : "unknown"
98
+ end
99
+
100
+ # ------------------------------------------------------------------
101
+ # Identity (name table)
102
+ # ------------------------------------------------------------------
103
+
104
+ def populate_identity(report, face)
105
+ return unless face.has_table?(Constants::NAME_TAG)
106
+
107
+ name_table = face.table(Constants::NAME_TAG)
108
+ report.family_name = name_table.english_name(Tables::Name::FAMILY)
109
+ report.subfamily_name = name_table.english_name(Tables::Name::SUBFAMILY)
110
+ report.full_name = name_table.english_name(Tables::Name::FULL_NAME)
111
+ report.postscript_name =
112
+ name_table.english_name(Tables::Name::POSTSCRIPT_NAME)
113
+ report.version = name_table.english_name(Tables::Name::VERSION)
114
+
115
+ return unless face.has_table?(Constants::HEAD_TAG)
116
+
117
+ report.font_revision = face.table(Constants::HEAD_TAG).font_revision
118
+ end
119
+
120
+ # ------------------------------------------------------------------
121
+ # Style (OS/2, head, fvar)
122
+ # ------------------------------------------------------------------
123
+
124
+ def populate_style(report, face)
125
+ report.is_variable = false
126
+ report.italic = false
127
+ report.bold = false
128
+ populate_os2_style(report, face) if face.has_table?(Constants::OS2_TAG)
129
+ populate_head_style(report, face) if face.has_table?(Constants::HEAD_TAG)
130
+ populate_fvar_style(report, face) if face.has_table?(Constants::FVAR_TAG)
131
+ end
132
+
133
+ def populate_os2_style(report, face)
134
+ os2 = face.table(Constants::OS2_TAG)
135
+ report.weight_class = os2.us_weight_class
136
+ report.width_class = os2.us_width_class
137
+ report.italic = os2.fs_selection && (os2.fs_selection & 0x01).positive?
138
+ report.bold = os2.fs_selection && (os2.fs_selection & 0x20).positive?
139
+ report.panose = format_panose(os2.panose)
140
+ end
141
+
142
+ def populate_head_style(report, face)
143
+ head = face.table(Constants::HEAD_TAG)
144
+ mac_style = head.mac_style || 0
145
+ # head macStyle overrides OS/2 fsSelection only when OS/2 didn't set it
146
+ report.italic = true if (mac_style & 0x02).positive?
147
+ report.bold = true if (mac_style & 0x01).positive?
148
+ end
149
+
150
+ def populate_fvar_style(report, face)
151
+ report.is_variable = true
152
+ fvar = face.table(Constants::FVAR_TAG)
153
+ report.axes = fvar.axes.map do |axis|
154
+ Models::AuditAxis.new(
155
+ tag: axis.axis_tag,
156
+ min_value: axis.min_value,
157
+ default_value: axis.default_value,
158
+ max_value: axis.max_value,
159
+ name_id: axis.axis_name_id,
160
+ )
161
+ end
162
+ end
163
+
164
+ def format_panose(panose)
165
+ return nil unless panose
166
+
167
+ panose.to_a.join(" ")
168
+ end
169
+
170
+ # ------------------------------------------------------------------
171
+ # Coverage (cmap + glyph count)
172
+ # ------------------------------------------------------------------
173
+
174
+ def populate_coverage(report, face)
175
+ if face.has_table?(Constants::CMAP_TAG)
176
+ cmap = face.table(Constants::CMAP_TAG)
177
+ mappings = cmap.unicode_mappings || {}
178
+ report.total_codepoints = mappings.size
179
+ report.cmap_subtables = cmap.subtable_formats
180
+ report.codepoints = codepoint_list(mappings.keys) if include_codepoints?
181
+ end
182
+
183
+ report.total_glyphs = total_glyphs(face)
184
+ end
185
+
186
+ def codepoint_list(codepoints)
187
+ codepoints.sort.map { |cp| format_codepoint(cp) }
188
+ end
189
+
190
+ def format_codepoint(cp)
191
+ format("U+%04X", cp)
192
+ end
193
+
194
+ def total_glyphs(face)
195
+ return 0 unless face.has_table?(Constants::MAXP_TAG)
196
+
197
+ face.table(Constants::MAXP_TAG)&.num_glyphs || 0
198
+ rescue StandardError
199
+ 0
200
+ end
201
+
202
+ def include_codepoints?
203
+ @options.fetch(:include_codepoints, true)
204
+ end
205
+
206
+ # ------------------------------------------------------------------
207
+ # OpenType layout (GSUB/GPOS)
208
+ # ------------------------------------------------------------------
209
+
210
+ def populate_layout(report, face)
211
+ report.opentype_scripts = collect_scripts(face)
212
+ report.features = collect_features(face)
213
+ end
214
+
215
+ def collect_scripts(face)
216
+ scripts = Set.new
217
+ %w[GSUB GPOS].each do |tag|
218
+ next unless face.has_table?(tag)
219
+
220
+ table = face.table(tag)
221
+ scripts.merge(table.scripts) if table
222
+ end
223
+ scripts.sort
224
+ end
225
+
226
+ def collect_features(face)
227
+ features = Set.new
228
+ %w[GSUB GPOS].each do |tag|
229
+ next unless face.has_table?(tag)
230
+
231
+ table = face.table(tag)
232
+ features.merge(collect_features_from_table(table)) if table
233
+ end
234
+ features.sort
235
+ end
236
+
237
+ def collect_features_from_table(layout_table)
238
+ scripts = begin
239
+ layout_table.scripts
240
+ rescue StandardError
241
+ []
242
+ end
243
+ scripts.flat_map { |script| features_for_script(layout_table, script) }
244
+ rescue StandardError
245
+ []
246
+ end
247
+
248
+ def features_for_script(layout_table, script)
249
+
250
+ layout_table.features(script_tag: script)
251
+ rescue StandardError
252
+ []
253
+
254
+ end
255
+ end
256
+ end
257
+ end
@@ -5,6 +5,7 @@
5
5
  module Fontisan
6
6
  module Commands
7
7
  autoload :BaseCommand, "fontisan/commands/base_command"
8
+ autoload :AuditCommand, "fontisan/commands/audit_command"
8
9
  autoload :ConvertCommand, "fontisan/commands/convert_command"
9
10
  autoload :DumpTableCommand, "fontisan/commands/dump_table_command"
10
11
  autoload :ExportCommand, "fontisan/commands/export_command"
@@ -0,0 +1,143 @@
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
+ json do
77
+ map "generated_at", to: :generated_at
78
+ map "fontisan_version", to: :fontisan_version
79
+ map "source_file", to: :source_file
80
+ map "source_sha256", to: :source_sha256
81
+ map "source_format", to: :source_format
82
+ map "font_index", to: :font_index
83
+ map "num_fonts_in_source", to: :num_fonts_in_source
84
+
85
+ map "family_name", to: :family_name
86
+ map "subfamily_name", to: :subfamily_name
87
+ map "full_name", to: :full_name
88
+ map "postscript_name", to: :postscript_name
89
+ map "version", to: :version
90
+ map "font_revision", to: :font_revision
91
+
92
+ map "weight_class", to: :weight_class
93
+ map "width_class", to: :width_class
94
+ map "italic", to: :italic
95
+ map "bold", to: :bold
96
+ map "panose", to: :panose
97
+ map "is_variable", to: :is_variable
98
+ map "axes", to: :axes
99
+
100
+ map "total_codepoints", to: :total_codepoints
101
+ map "total_glyphs", to: :total_glyphs
102
+ map "cmap_subtables", to: :cmap_subtables
103
+ map "codepoints", to: :codepoints
104
+
105
+ map "opentype_scripts", to: :opentype_scripts
106
+ map "features", to: :features
107
+ end
108
+
109
+ yaml do
110
+ map "generated_at", to: :generated_at
111
+ map "fontisan_version", to: :fontisan_version
112
+ map "source_file", to: :source_file
113
+ map "source_sha256", to: :source_sha256
114
+ map "source_format", to: :source_format
115
+ map "font_index", to: :font_index
116
+ map "num_fonts_in_source", to: :num_fonts_in_source
117
+
118
+ map "family_name", to: :family_name
119
+ map "subfamily_name", to: :subfamily_name
120
+ map "full_name", to: :full_name
121
+ map "postscript_name", to: :postscript_name
122
+ map "version", to: :version
123
+ map "font_revision", to: :font_revision
124
+
125
+ map "weight_class", to: :weight_class
126
+ map "width_class", to: :width_class
127
+ map "italic", to: :italic
128
+ map "bold", to: :bold
129
+ map "panose", to: :panose
130
+ map "is_variable", to: :is_variable
131
+ map "axes", to: :axes
132
+
133
+ map "total_codepoints", to: :total_codepoints
134
+ map "total_glyphs", to: :total_glyphs
135
+ map "cmap_subtables", to: :cmap_subtables
136
+ map "codepoints", to: :codepoints
137
+
138
+ map "opentype_scripts", to: :opentype_scripts
139
+ map "features", to: :features
140
+ end
141
+ end
142
+ end
143
+ 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"