ucode 0.1.0 → 0.1.1

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 (174) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/Gemfile.lock +2 -2
  4. data/TODO.full/00-README.md +116 -0
  5. data/TODO.full/01-panglyph-vision.md +112 -0
  6. data/TODO.full/02-panglyph-repo-bootstrap.md +184 -0
  7. data/TODO.full/03-panglyph-font-builder.md +201 -0
  8. data/TODO.full/04-panglyph-publish-pipeline.md +126 -0
  9. data/TODO.full/05-ucode-0-1-1-release.md +139 -0
  10. data/TODO.full/06-fontisan-remove-audit.md +142 -0
  11. data/TODO.full/07-fontisan-remove-ucd.md +125 -0
  12. data/TODO.full/08-archive-private-bin-build.md +143 -0
  13. data/TODO.full/09-archive-public-structure.md +164 -0
  14. data/TODO.full/10-fontist-org-woff-glyphs.md +131 -0
  15. data/TODO.full/11-fontist-org-audit-coverage.md +140 -0
  16. data/TODO.full/12-implementation-order.md +216 -0
  17. data/TODO.full/13-fontisan-font-writer-api.md +189 -0
  18. data/TODO.full/14-fontisan-table-writers.md +66 -0
  19. data/TODO.full/15-panglyph-builder-real.md +82 -0
  20. data/TODO.full/16-archive-public-sync-workflows.md +167 -0
  21. data/TODO.full/17-fontist-org-font-picker.md +73 -0
  22. data/TODO.full/18-comprehensive-spec-coverage.md +64 -0
  23. data/TODO.full/19-ucode-0-1-2-patch.md +32 -0
  24. data/TODO.full/20-fontisan-0-2-23-release.md +52 -0
  25. data/TODO.new/00-README.md +30 -0
  26. data/TODO.new/23-universal-glyph-set-source-map.md +312 -0
  27. data/TODO.new/24-universal-glyph-set-build.md +189 -0
  28. data/TODO.new/25-font-audit-against-universal-set.md +195 -0
  29. data/TODO.new/26-missing-glyph-reporter.md +189 -0
  30. data/TODO.new/27-fontist-org-consumer-integration.md +200 -0
  31. data/TODO.new/28-implementation-order-update.md +187 -0
  32. data/TODO.new/29-universal-set-curation-uc17.md +312 -0
  33. data/TODO.new/30-tier1-font-acquisition.md +241 -0
  34. data/TODO.new/31-universal-set-production-build.md +205 -0
  35. data/TODO.new/32-uc17-coverage-matrix.md +165 -0
  36. data/TODO.new/33-specialist-font-acquisition-refresh.md +138 -0
  37. data/TODO.new/34-pillar2-content-stream-correlator.md +147 -0
  38. data/TODO.new/35-universal-set-production-run.md +160 -0
  39. data/TODO.new/36-per-font-coverage-audit.md +145 -0
  40. data/TODO.new/37-coverage-highlight-reporter.md +125 -0
  41. data/TODO.new/38-fontist-org-glyph-consumer.md +141 -0
  42. data/TODO.new/39-implementation-order-update-32-38.md +258 -0
  43. data/TODO.new/40-archive-private-uses-ucode-audit.md +124 -0
  44. data/TODO.new/41-ucode-unicode-archive-bridge.md +160 -0
  45. data/config/specialist_fonts.yml +102 -0
  46. data/config/unicode17_tier1_fonts.yml +42 -0
  47. data/config/unicode17_universal_glyph_set.yml +293 -0
  48. data/lib/ucode/audit/block_aggregator.rb +57 -29
  49. data/lib/ucode/audit/browser/face_page.rb +128 -0
  50. data/lib/ucode/audit/browser/glyph_panel.rb +124 -0
  51. data/lib/ucode/audit/browser/library_page.rb +74 -0
  52. data/lib/ucode/audit/browser/missing_glyph_page.rb +87 -0
  53. data/lib/ucode/audit/browser/template.rb +47 -0
  54. data/lib/ucode/audit/browser/templates/face.css +200 -0
  55. data/lib/ucode/audit/browser/templates/face.html.erb +41 -0
  56. data/lib/ucode/audit/browser/templates/face.js +298 -0
  57. data/lib/ucode/audit/browser/templates/library.css +119 -0
  58. data/lib/ucode/audit/browser/templates/library.html.erb +42 -0
  59. data/lib/ucode/audit/browser/templates/library.js +99 -0
  60. data/lib/ucode/audit/browser/templates/missing_glyph_page.css +119 -0
  61. data/lib/ucode/audit/browser/templates/missing_glyph_page.html.erb +58 -0
  62. data/lib/ucode/audit/browser/templates/missing_glyph_page.js +2 -0
  63. data/lib/ucode/audit/browser.rb +32 -0
  64. data/lib/ucode/audit/context.rb +27 -1
  65. data/lib/ucode/audit/coverage_reference.rb +103 -0
  66. data/lib/ucode/audit/differ.rb +121 -0
  67. data/lib/ucode/audit/emitter/block_emitter.rb +52 -0
  68. data/lib/ucode/audit/emitter/codepoint_emitter.rb +87 -0
  69. data/lib/ucode/audit/emitter/collection_emitter.rb +80 -0
  70. data/lib/ucode/audit/emitter/face_directory.rb +212 -0
  71. data/lib/ucode/audit/emitter/glyph_emitter.rb +48 -0
  72. data/lib/ucode/audit/emitter/index_emitter.rb +149 -0
  73. data/lib/ucode/audit/emitter/library_emitter.rb +96 -0
  74. data/lib/ucode/audit/emitter/paths.rb +312 -0
  75. data/lib/ucode/audit/emitter/plane_emitter.rb +29 -0
  76. data/lib/ucode/audit/emitter/script_emitter.rb +29 -0
  77. data/lib/ucode/audit/emitter.rb +29 -0
  78. data/lib/ucode/audit/extractors/aggregations.rb +31 -2
  79. data/lib/ucode/audit/face_auditor.rb +86 -0
  80. data/lib/ucode/audit/formatters/audit_diff_text.rb +112 -0
  81. data/lib/ucode/audit/formatters/audit_text.rb +411 -0
  82. data/lib/ucode/audit/formatters/color.rb +48 -0
  83. data/lib/ucode/audit/formatters/library_summary_text.rb +98 -0
  84. data/lib/ucode/audit/formatters/text_formatter.rb +83 -0
  85. data/lib/ucode/audit/formatters.rb +23 -0
  86. data/lib/ucode/audit/library_aggregator.rb +86 -0
  87. data/lib/ucode/audit/library_auditor.rb +105 -0
  88. data/lib/ucode/audit/release/emitter.rb +152 -0
  89. data/lib/ucode/audit/release/face_card.rb +93 -0
  90. data/lib/ucode/audit/release/formula_audits.rb +50 -0
  91. data/lib/ucode/audit/release/library_index_builder.rb +78 -0
  92. data/lib/ucode/audit/release/manifest_builder.rb +127 -0
  93. data/lib/ucode/audit/release.rb +42 -0
  94. data/lib/ucode/audit/ucd_only_reference.rb +81 -0
  95. data/lib/ucode/audit/universal_set_reference.rb +136 -0
  96. data/lib/ucode/audit.rb +31 -0
  97. data/lib/ucode/cli.rb +339 -33
  98. data/lib/ucode/commands/audit/browser_command.rb +82 -0
  99. data/lib/ucode/commands/audit/collection_command.rb +103 -0
  100. data/lib/ucode/commands/audit/compare_command.rb +188 -0
  101. data/lib/ucode/commands/audit/font_command.rb +140 -0
  102. data/lib/ucode/commands/audit/library_command.rb +87 -0
  103. data/lib/ucode/commands/audit/reference_builder.rb +64 -0
  104. data/lib/ucode/commands/audit.rb +20 -0
  105. data/lib/ucode/commands/block_feed.rb +73 -0
  106. data/lib/ucode/commands/canonical_build.rb +138 -0
  107. data/lib/ucode/commands/fetch.rb +37 -1
  108. data/lib/ucode/commands/release.rb +115 -0
  109. data/lib/ucode/commands/universal_set.rb +211 -0
  110. data/lib/ucode/commands.rb +5 -0
  111. data/lib/ucode/coordinator/indices.rb +11 -0
  112. data/lib/ucode/coordinator.rb +138 -5
  113. data/lib/ucode/error.rb +30 -2
  114. data/lib/ucode/fetch/font_fetcher/result.rb +39 -0
  115. data/lib/ucode/fetch/font_fetcher.rb +16 -0
  116. data/lib/ucode/fetch/specialist_font_fetcher.rb +280 -0
  117. data/lib/ucode/fetch.rb +7 -3
  118. data/lib/ucode/glyphs/real_fonts/cmap_cache.rb +74 -0
  119. data/lib/ucode/glyphs/real_fonts.rb +1 -0
  120. data/lib/ucode/glyphs/resolver.rb +62 -0
  121. data/lib/ucode/glyphs/source.rb +48 -0
  122. data/lib/ucode/glyphs/source_builder.rb +61 -0
  123. data/lib/ucode/glyphs/source_config/coverage_assertion.rb +79 -0
  124. data/lib/ucode/glyphs/source_config/gap_report.rb +54 -0
  125. data/lib/ucode/glyphs/source_config.rb +104 -0
  126. data/lib/ucode/glyphs/sources/pillar1_embedded_tounicode.rb +63 -0
  127. data/lib/ucode/glyphs/sources/pillar3_last_resort.rb +51 -0
  128. data/lib/ucode/glyphs/sources/tier1_real_font.rb +104 -0
  129. data/lib/ucode/glyphs/sources.rb +20 -0
  130. data/lib/ucode/glyphs/universal_set/builder.rb +161 -0
  131. data/lib/ucode/glyphs/universal_set/coverage_report.rb +139 -0
  132. data/lib/ucode/glyphs/universal_set/idempotency.rb +86 -0
  133. data/lib/ucode/glyphs/universal_set/manifest_accumulator.rb +195 -0
  134. data/lib/ucode/glyphs/universal_set/manifest_writer.rb +61 -0
  135. data/lib/ucode/glyphs/universal_set/pre_build_check.rb +197 -0
  136. data/lib/ucode/glyphs/universal_set/validator.rb +204 -0
  137. data/lib/ucode/glyphs/universal_set.rb +45 -0
  138. data/lib/ucode/glyphs.rb +6 -0
  139. data/lib/ucode/models/audit/baseline.rb +6 -0
  140. data/lib/ucode/models/audit/block_summary.rb +7 -0
  141. data/lib/ucode/models/audit/codepoint_provenance.rb +39 -0
  142. data/lib/ucode/models/audit/release_face.rb +42 -0
  143. data/lib/ucode/models/audit/release_formula.rb +33 -0
  144. data/lib/ucode/models/audit/release_manifest.rb +43 -0
  145. data/lib/ucode/models/audit/release_universal_set.rb +37 -0
  146. data/lib/ucode/models/audit.rb +9 -0
  147. data/lib/ucode/models/block.rb +2 -0
  148. data/lib/ucode/models/build_report.rb +109 -0
  149. data/lib/ucode/models/codepoint/glyph.rb +42 -0
  150. data/lib/ucode/models/codepoint.rb +3 -0
  151. data/lib/ucode/models/glyph_source.rb +86 -0
  152. data/lib/ucode/models/glyph_source_map.rb +138 -0
  153. data/lib/ucode/models/specialist_font.rb +70 -0
  154. data/lib/ucode/models/specialist_font_manifest.rb +48 -0
  155. data/lib/ucode/models/unihan_entry.rb +81 -9
  156. data/lib/ucode/models/unihan_field.rb +21 -0
  157. data/lib/ucode/models/universal_set_entry.rb +47 -0
  158. data/lib/ucode/models/universal_set_manifest.rb +78 -0
  159. data/lib/ucode/models/validation_report.rb +99 -0
  160. data/lib/ucode/models.rb +9 -0
  161. data/lib/ucode/parsers/named_sequences.rb +5 -5
  162. data/lib/ucode/parsers/unihan.rb +50 -19
  163. data/lib/ucode/repo/aggregate_writer.rb +34 -2
  164. data/lib/ucode/repo/block_feed_emitter.rb +153 -0
  165. data/lib/ucode/repo/build_report_accumulator.rb +138 -0
  166. data/lib/ucode/repo/build_report_writer.rb +46 -0
  167. data/lib/ucode/repo/build_validator.rb +229 -0
  168. data/lib/ucode/repo/codepoint_writer.rb +50 -1
  169. data/lib/ucode/repo/paths.rb +8 -0
  170. data/lib/ucode/repo.rb +4 -0
  171. data/lib/ucode/version.rb +1 -1
  172. data/schema/block-feed.output.schema.yml +134 -0
  173. metadata +143 -2
  174. data/ucode.gemspec +0 -56
@@ -0,0 +1,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ucode
4
+ module Audit
5
+ module Formatters
6
+ # Human-readable, sectioned view of an {Models::Audit::AuditReport}.
7
+ #
8
+ # The text formatter is the default output for `ucode audit`. Every
9
+ # section is nil-safe so the same renderer covers full OpenType /
10
+ # TrueType faces, Type 1 fonts (no OS/2, no metrics, no layout),
11
+ # and partial reports where the UCD baseline could not be resolved.
12
+ #
13
+ # ucode deltas vs fontisan's AuditTextRenderer:
14
+ #
15
+ # - Reads `report.baseline.unicode_version` instead of `ucd_version`.
16
+ # - Renders `report.scripts` (ScriptSummary[]) as a coverage table.
17
+ # - Renders `report.blocks` (BlockSummary[]) with explicit status
18
+ # and coverage_percent columns.
19
+ # - Adds `plane_summaries` and `discrepancies` sections (no
20
+ # fontisan equivalent).
21
+ # - Drops CLDR language coverage (out of scope).
22
+ # - Honors `ENV["NO_COLOR"]` via {Color}.
23
+ class AuditText
24
+ SEPARATOR = "=" * 80
25
+ LIST_LIMIT = 10
26
+
27
+ # Map OS/2 width class → human label.
28
+ WIDTH_NAMES = {
29
+ 1 => "Ultra-condensed", 2 => "Extra-condensed", 3 => "Condensed",
30
+ 4 => "Semi-condensed", 5 => "Medium (normal)", 6 => "Semi-expanded",
31
+ 7 => "Expanded", 8 => "Extra-expanded", 9 => "Ultra-expanded"
32
+ }.freeze
33
+
34
+ # @param report [Models::Audit::AuditReport]
35
+ def initialize(report)
36
+ @report = report
37
+ @lines = []
38
+ @helper = TextFormatter.new
39
+ end
40
+
41
+ # @return [String]
42
+ def render
43
+ render_header
44
+ render_identity
45
+ render_style
46
+ render_metrics
47
+ render_coverage
48
+ render_planes
49
+ render_blocks
50
+ render_scripts
51
+ render_licensing
52
+ render_hinting
53
+ render_color
54
+ render_variation
55
+ render_opentype_layout
56
+ render_discrepancies
57
+ render_warnings
58
+ @lines.join("\n")
59
+ end
60
+
61
+ private
62
+
63
+ def render_header
64
+ @lines << Color.bold(@report.postscript_name || @report.family_name || "(unknown)")
65
+ @lines << Color.dim(SEPARATOR)
66
+ @lines << " generated_at: #{@report.generated_at}"
67
+ @lines << " ucode: #{@report.ucode_version}"
68
+ @lines << " source_sha256: #{@report.source_sha256}"
69
+ @lines << " source_file: #{@report.source_file}"
70
+ @lines << " source_format: #{@report.source_format || '(unknown)'}"
71
+ @lines << " layout: #{layout_descriptor}"
72
+ end
73
+
74
+ def layout_descriptor
75
+ if @report.num_fonts_in_source.nil? || @report.num_fonts_in_source <= 1
76
+ "single face (1/1)"
77
+ else
78
+ format("collection face (%<idx>d/%<total>d)",
79
+ idx: (@report.font_index || 0) + 1,
80
+ total: @report.num_fonts_in_source)
81
+ end
82
+ end
83
+
84
+ def render_identity
85
+ section("IDENTITY")
86
+ @lines << @helper.row("Family", @report.family_name)
87
+ @lines << @helper.row("Subfamily", @report.subfamily_name)
88
+ @lines << @helper.row("Full name", @report.full_name)
89
+ @lines << @helper.row("PostScript", @report.postscript_name)
90
+ @lines << @helper.row("Version", @report.version)
91
+ @lines << @helper.row("Revision", @report.font_revision)
92
+ @lines.compact!
93
+ end
94
+
95
+ def render_style
96
+ section("STYLE")
97
+ @lines << @helper.row("Weight class", weight_descriptor)
98
+ @lines << @helper.row("Width class", width_descriptor)
99
+ @lines << @helper.row("Bold", yes_no(@report.bold))
100
+ @lines << @helper.row("Italic", yes_no(@report.italic))
101
+ @lines << @helper.row("PANOSE", @report.panose)
102
+ @lines.compact!
103
+ end
104
+
105
+ def render_metrics
106
+ return unless @report.metrics
107
+
108
+ m = @report.metrics
109
+ section("METRICS")
110
+ @lines << @helper.row("unitsPerEm", m.units_per_em)
111
+ if m.hhea_ascent
112
+ @lines << @helper.row("hhea",
113
+ "ascent: #{m.hhea_ascent} / descent: #{m.hhea_descent} / line gap: #{m.hhea_line_gap}")
114
+ end
115
+ if m.typo_ascender
116
+ @lines << @helper.row("OS/2 typo",
117
+ "ascent: #{m.typo_ascender} / descent: #{m.typo_descender} / line gap: #{m.typo_line_gap}")
118
+ end
119
+ if m.win_ascent
120
+ @lines << @helper.row("OS/2 win",
121
+ "ascent: #{m.win_ascent} / descent: #{m.win_descent}")
122
+ end
123
+ @lines << @helper.row("x-height", m.x_height)
124
+ @lines << @helper.row("cap height", m.cap_height)
125
+ if m.bbox_x_min || m.bbox_x_max
126
+ @lines << @helper.row("bbox", "(#{m.bbox_x_min}, #{m.bbox_y_min}) → (#{m.bbox_x_max}, #{m.bbox_y_max})")
127
+ end
128
+ @lines << @helper.row("metrics consistent?", yes_no(m.metrics_consistent?))
129
+ @lines.compact!
130
+ end
131
+
132
+ def render_coverage
133
+ section("COVERAGE")
134
+ @lines << @helper.row("Codepoints", @report.total_codepoints)
135
+ @lines << @helper.row("Glyphs", @report.total_glyphs)
136
+ unless Array(@report.cmap_subtables).empty?
137
+ @lines << @helper.row("cmap subtables", Array(@report.cmap_subtables).join(", "))
138
+ end
139
+ @lines << @helper.row("Ranges (top #{LIST_LIMIT})",
140
+ @helper.truncate_ranges(@report.codepoint_ranges))
141
+ @lines << @helper.row("Baseline", baseline_descriptor)
142
+ @lines.compact!
143
+ end
144
+
145
+ def baseline_descriptor
146
+ v = @report.baseline&.unicode_version
147
+ v ? "Unicode #{v} (#{@report.baseline.source})" : "(unresolved)"
148
+ end
149
+
150
+ def render_planes
151
+ planes = Array(@report.plane_summaries)
152
+ return if planes.empty?
153
+
154
+ section("PLANE ROLLUP")
155
+ planes.first(LIST_LIMIT).each do |p|
156
+ @lines << format(" Plane %<plane>-2d %<covered>d / %<assigned>d (%<pct>s%%)",
157
+ plane: p.plane, covered: p.covered_total,
158
+ assigned: p.assigned_total,
159
+ pct: format_percent(p.coverage_percent))
160
+ end
161
+ if planes.size > LIST_LIMIT
162
+ @lines << " … (+#{planes.size - LIST_LIMIT} more planes)"
163
+ end
164
+ end
165
+
166
+ def render_blocks
167
+ blocks = Array(@report.blocks)
168
+ return if blocks.empty?
169
+
170
+ section(blocks_header(blocks))
171
+ blocks.sort_by { |b| -(b.coverage_percent || 0) }.first(LIST_LIMIT).each do |block|
172
+ @lines << format_block_row(block)
173
+ end
174
+ return unless blocks.size > LIST_LIMIT
175
+
176
+ @lines << " … (+#{blocks.size - LIST_LIMIT} more blocks; see report JSON for the full list)"
177
+ end
178
+
179
+ def blocks_header(blocks)
180
+ complete = blocks.count { |b| b.status == Models::Audit::BlockSummary::STATUS_COMPLETE }
181
+ partial = blocks.count { |b| b.status == Models::Audit::BlockSummary::STATUS_PARTIAL }
182
+ "UNICODE BLOCKS (#{blocks.size} touched: #{complete} complete, #{partial} partial, top #{LIST_LIMIT} by fill)"
183
+ end
184
+
185
+ def format_block_row(block)
186
+ format(" %<name>-40s %<range>s %<covered>d/%<total>d (%<pct>s%%, %<status>s)",
187
+ name: "#{block.name}:",
188
+ range: block.range,
189
+ covered: block.covered_count,
190
+ total: block.total_assigned,
191
+ pct: format_percent(block.coverage_percent),
192
+ status: block.status)
193
+ end
194
+
195
+ def render_scripts
196
+ scripts = Array(@report.scripts)
197
+ return if scripts.empty?
198
+
199
+ section("UNICODE SCRIPTS (#{scripts.size} touched, top #{LIST_LIMIT} by coverage)")
200
+ scripts.sort_by { |s| -(s.coverage_percent || 0) }.first(LIST_LIMIT).each do |script|
201
+ label = "#{script.script_code} (#{script.script_name}):"
202
+ @lines << format(" %<name>-25s %<covered>d/%<total>d (%<pct>s%%, %<status>s)",
203
+ name: label,
204
+ covered: script.covered_total,
205
+ total: script.assigned_total,
206
+ pct: format_percent(script.coverage_percent),
207
+ status: script.status)
208
+ end
209
+ end
210
+
211
+ def render_licensing
212
+ return unless @report.licensing
213
+
214
+ l = @report.licensing
215
+ section("LICENSING")
216
+ @lines << @helper.row("Copyright", l.copyright)
217
+ @lines << @helper.row("Trademark", l.trademark)
218
+ @lines << @helper.row("Manufacturer", l.manufacturer)
219
+ @lines << @helper.row("Designer", l.designer)
220
+ @lines << @helper.row("License", l.license_description)
221
+ @lines << @helper.row("License URL", l.license_url)
222
+ @lines << @helper.row("Vendor URL", l.vendor_url)
223
+ @lines << @helper.row("Designer URL", l.designer_url)
224
+ @lines << @helper.row("Vendor ID", l.vendor_id)
225
+ @lines << @helper.row("Embedding", l.embedding_type)
226
+ @lines.compact!
227
+ end
228
+
229
+ def render_hinting
230
+ return unless @report.hinting
231
+
232
+ h = @report.hinting
233
+ section("HINTING")
234
+ @lines << @helper.row("Format", h.hinting_format || (h.is_unhinted ? "unhinted" : "unknown"))
235
+ @lines << @helper.row("fpgm", instruction_line(h.has_fpgm, h.fpgm_instruction_count))
236
+ @lines << @helper.row("prep", instruction_line(h.has_prep, h.prep_instruction_count))
237
+ @lines << @helper.row("cvt", cvt_line(h))
238
+ @lines << @helper.row("gasp", gasp_line(h))
239
+ @lines << @helper.row("CFF hints", h.cff_hint_count)
240
+ @lines.compact!
241
+ end
242
+
243
+ def render_color
244
+ c = @report.color_capabilities
245
+ return unless c
246
+ return if Array(c.color_formats).empty?
247
+
248
+ section("COLOR")
249
+ @lines << @helper.row("Color formats", Array(c.color_formats).join(", "))
250
+ append_color_rows(c)
251
+ @lines.compact!
252
+ end
253
+
254
+ def append_color_rows(c)
255
+ color_rows(c).each { |row| @lines << row }
256
+ end
257
+
258
+ def color_rows(c)
259
+ [].tap do |rows|
260
+ rows << colr_row(c) if c.has_colr
261
+ rows << cpal_row(c) if c.has_cpal
262
+ rows.concat(count_rows(c))
263
+ end
264
+ end
265
+
266
+ # Returns rows for color formats that surface a strike/document count.
267
+ # Each entry is gated by both presence flag and non-nil count.
268
+ def count_rows(c)
269
+ [].tap do |rows|
270
+ rows << @helper.row("SVG documents", c.svg_document_count) if c.has_svg && c.svg_document_count
271
+ rows << @helper.row("CBDT strikes", c.cbdt_strike_count) if c.has_cbdt && c.cbdt_strike_count
272
+ rows << @helper.row("sbix strikes", c.sbix_strike_count) if c.has_sbix && c.sbix_strike_count
273
+ end
274
+ end
275
+
276
+ def colr_row(c)
277
+ @helper.row("COLR",
278
+ "v#{c.colr_version}, #{c.colr_base_glyph_count} base glyphs, #{c.colr_layer_count} layers")
279
+ end
280
+
281
+ def cpal_row(c)
282
+ @helper.row("CPAL", "palettes: #{c.cpal_palette_count}, colors: #{c.cpal_color_count}")
283
+ end
284
+
285
+ def render_variation
286
+ v = @report.variation
287
+ section("VARIABLE FONT")
288
+ if v.nil? || Array(v.axes).empty?
289
+ @lines << " (not variable)"
290
+ return
291
+ end
292
+
293
+ v.axes.each do |axis|
294
+ @lines << @helper.row(axis.tag,
295
+ format("%<min>s .. %<max>s default %<default>s",
296
+ min: axis.min_value, max: axis.max_value,
297
+ default: axis.default_value))
298
+ end
299
+ return if Array(v.named_instances).empty?
300
+
301
+ @lines << " Named instances:"
302
+ v.named_instances.first(LIST_LIMIT).each do |inst|
303
+ @lines << " #{inst.postscript_name || inst.subfamily_name}: #{inst.coordinates}"
304
+ end
305
+ end
306
+
307
+ def render_opentype_layout
308
+ return unless @report.opentype_layout
309
+
310
+ l = @report.opentype_layout
311
+ section("OPENTYPE LAYOUT")
312
+ @lines << @helper.row("GSUB", yes_no(l.has_gsub))
313
+ @lines << @helper.row("GPOS", yes_no(l.has_gpos))
314
+ @lines << @helper.row("Scripts (#{Array(l.scripts).size})",
315
+ @helper.truncate_list(l.scripts))
316
+ @lines << @helper.row("Features (#{Array(l.features).size})",
317
+ @helper.truncate_list(l.features))
318
+ @lines.compact!
319
+ end
320
+
321
+ def render_discrepancies
322
+ discrepancies = Array(@report.discrepancies)
323
+ section("DISCREPANCIES (#{discrepancies.size})")
324
+ if discrepancies.empty?
325
+ @lines << " (none)"
326
+ return
327
+ end
328
+
329
+ discrepancies.first(LIST_LIMIT).each do |d|
330
+ @lines << " [#{d.kind}] #{d.detail}"
331
+ end
332
+ if discrepancies.size > LIST_LIMIT
333
+ @lines << " … (+#{discrepancies.size - LIST_LIMIT} more; see report JSON for the full list)"
334
+ end
335
+ end
336
+
337
+ def render_warnings
338
+ section("WARNINGS")
339
+ @lines << if @report.warning
340
+ " #{@report.warning}"
341
+ else
342
+ " (none)"
343
+ end
344
+ end
345
+
346
+ # ---- formatting helpers --------------------------------------------
347
+
348
+ def section(title)
349
+ @lines << ""
350
+ @lines << Color.bold(title)
351
+ end
352
+
353
+ def yes_no(bool)
354
+ bool ? "yes" : "no"
355
+ end
356
+
357
+ def format_percent(pct)
358
+ pct.nil? ? "?" : format("%<v>.2f", v: pct)
359
+ end
360
+
361
+ def weight_descriptor
362
+ return nil unless @report.weight_class
363
+
364
+ name = weight_name(@report.weight_class)
365
+ "#{@report.weight_class}#{" (#{name})" if name}"
366
+ end
367
+
368
+ def width_descriptor
369
+ return nil unless @report.width_class
370
+
371
+ name = WIDTH_NAMES[@report.width_class]
372
+ "#{@report.width_class}#{" (#{name})" if name}"
373
+ end
374
+
375
+ def weight_name(value)
376
+ case value
377
+ when 100 then "Thin"
378
+ when 200 then "Extra-light"
379
+ when 300 then "Light"
380
+ when 400 then "Regular"
381
+ when 500 then "Medium"
382
+ when 600 then "Semi-bold"
383
+ when 700 then "Bold"
384
+ when 800 then "Extra-bold"
385
+ when 900 then "Black"
386
+ end
387
+ end
388
+
389
+ def instruction_line(has, count)
390
+ return "no" unless has
391
+
392
+ count ? "#{count} instructions" : "present"
393
+ end
394
+
395
+ def cvt_line(hinting)
396
+ return "no" unless hinting.has_cvt
397
+
398
+ hinting.cvt_entry_count ? "#{hinting.cvt_entry_count} entries" : "present"
399
+ end
400
+
401
+ def gasp_line(hinting)
402
+ ranges = Array(hinting.gasp_ranges)
403
+ return "no" if ranges.empty?
404
+
405
+ ppems = ranges.map(&:max_ppem).compact
406
+ "#{ranges.size} ranges (#{ppems.join('/')} ppem)"
407
+ end
408
+ end
409
+ end
410
+ end
411
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ucode
4
+ module Audit
5
+ module Formatters
6
+ # Minimal ANSI helper. Formatters route every color emphasis
7
+ # through this module so the NO_COLOR env var is honored uniformly.
8
+ #
9
+ # Follows the no-color.org convention: when ENV["NO_COLOR"] is
10
+ # set to any non-empty value, all color methods return the input
11
+ # string unchanged.
12
+ module Color
13
+ RESET = "\e[0m"
14
+ BOLD = "\e[1m"
15
+ DIM = "\e[2m"
16
+ RED = "\e[31m"
17
+ GREEN = "\e[32m"
18
+ CYAN = "\e[36m"
19
+
20
+ module_function
21
+
22
+ def enabled?
23
+ ENV["NO_COLOR"].nil? || ENV["NO_COLOR"].empty?
24
+ end
25
+
26
+ def bold(text)
27
+ enabled? ? "#{BOLD}#{text}#{RESET}" : text
28
+ end
29
+
30
+ def dim(text)
31
+ enabled? ? "#{DIM}#{text}#{RESET}" : text
32
+ end
33
+
34
+ def cyan(text)
35
+ enabled? ? "#{CYAN}#{text}#{RESET}" : text
36
+ end
37
+
38
+ def green(text)
39
+ enabled? ? "#{GREEN}#{text}#{RESET}" : text
40
+ end
41
+
42
+ def red(text)
43
+ enabled? ? "#{RED}#{text}#{RESET}" : text
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ucode
4
+ module Audit
5
+ module Formatters
6
+ # Human-readable overview of a {Models::Audit::LibrarySummary}.
7
+ #
8
+ # Lists the per-face rollup counts, aggregate metrics, script
9
+ # coverage matrix, duplicate groups, and license distribution.
10
+ # The full per-face AuditReports are attached to the model; this
11
+ # view only shows the cross-face summaries (use YAML/JSON output
12
+ # for the full per-face data).
13
+ class LibrarySummaryText
14
+ SEPARATOR = "=" * 80
15
+ LIST_LIMIT = 15
16
+
17
+ # @param summary [Models::Audit::LibrarySummary]
18
+ def initialize(summary)
19
+ @summary = summary
20
+ @lines = []
21
+ @helper = TextFormatter.new
22
+ end
23
+
24
+ # @return [String]
25
+ def render
26
+ render_header
27
+ render_aggregates
28
+ render_script_coverage
29
+ render_duplicates
30
+ render_license_distribution
31
+ @lines.join("\n")
32
+ end
33
+
34
+ private
35
+
36
+ def render_header
37
+ @lines << Color.bold("LIBRARY SUMMARY")
38
+ @lines << Color.dim(SEPARATOR)
39
+ @lines << " root: #{@summary.root_path}"
40
+ @lines << " files: #{@summary.total_files} faces: #{@summary.total_faces}"
41
+ exts = Array(@summary.scanned_extensions)
42
+ @lines << " formats: #{exts.empty? ? '(none)' : exts.join(', ')}"
43
+ end
44
+
45
+ def render_aggregates
46
+ m = @summary.aggregate_metrics || {}
47
+ section("AGGREGATES")
48
+ @lines << " codepoints: #{m[:total_codepoints] || 0}"
49
+ @lines << " glyphs: #{m[:total_glyphs] || 0}"
50
+ @lines << " total size: #{@helper.format_bytes(m[:total_size_bytes] || 0)}"
51
+ end
52
+
53
+ def render_script_coverage
54
+ rows = Array(@summary.script_coverage)
55
+ return if rows.empty?
56
+
57
+ section("SCRIPT COVERAGE (top #{LIST_LIMIT})")
58
+ rows.first(LIST_LIMIT).each do |row|
59
+ @lines << " #{row.script}: #{row.face_count} face#{'s' unless row.face_count == 1}"
60
+ end
61
+ if rows.size > LIST_LIMIT
62
+ @lines << " … (+#{rows.size - LIST_LIMIT} more scripts)"
63
+ end
64
+ end
65
+
66
+ def render_duplicates
67
+ groups = Array(@summary.duplicate_groups)
68
+ return if groups.empty?
69
+
70
+ section("DUPLICATES (#{groups.size} group#{'s' unless groups.size == 1})")
71
+ groups.first(LIST_LIMIT).each do |group|
72
+ sha = group.source_sha256.to_s
73
+ @lines << " sha #{sha[0, 12]}:"
74
+ group.files.each { |path| @lines << " #{path}" }
75
+ end
76
+ if groups.size > LIST_LIMIT
77
+ @lines << " … (+#{groups.size - LIST_LIMIT} more duplicate groups)"
78
+ end
79
+ end
80
+
81
+ def render_license_distribution
82
+ dist = @summary.license_distribution || {}
83
+ return if dist.empty?
84
+
85
+ section("LICENSE DISTRIBUTION")
86
+ dist.sort_by { |_url, count| -count }.each do |url, count|
87
+ @lines << " #{count} #{url}"
88
+ end
89
+ end
90
+
91
+ def section(title)
92
+ @lines << ""
93
+ @lines << Color.bold(title)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ucode
4
+ module Audit
5
+ module Formatters
6
+ # Shared utilities for the text-rendering formatters. Owns the
7
+ # column helpers, list truncation, byte formatting, and ANSI
8
+ # color hook that the audit/diff/library renderers all use.
9
+ #
10
+ # Formatters instantiate this class and delegate common formatting
11
+ # chores to it; the renderer classes own the section shape and
12
+ # model-walking logic.
13
+ class TextFormatter
14
+ LIST_LIMIT = 10
15
+ LABEL_WIDTH = 18
16
+
17
+ # Format a list of arbitrary items as a single-line truncated
18
+ # comma-separated string. Returns "(none)" for empty input.
19
+ #
20
+ # @param items [Enumerable]
21
+ # @return [String]
22
+ def truncate_list(items, limit: LIST_LIMIT)
23
+ list = Array(items)
24
+ return "(none)" if list.empty?
25
+
26
+ shown = list.first(limit).join(", ")
27
+ if list.size > limit
28
+ "#{shown}, … (+#{list.size - limit} more)"
29
+ else
30
+ shown
31
+ end
32
+ end
33
+
34
+ # Format a codepoint range list as `U+XXXX-U+XXXX, …`, truncated.
35
+ #
36
+ # @param ranges [Enumerable<Models::Audit::CodepointRange>]
37
+ # @return [String]
38
+ def truncate_ranges(ranges, limit: LIST_LIMIT)
39
+ list = Array(ranges)
40
+ return "(none)" if list.empty?
41
+
42
+ shown = list.first(limit).join(", ")
43
+ if list.size > limit
44
+ "#{shown}, … (+#{list.size - limit} more)"
45
+ else
46
+ shown
47
+ end
48
+ end
49
+
50
+ # Format an integer byte count as `B` / `KB` / `MB`.
51
+ #
52
+ # @param bytes [Integer, nil]
53
+ # @return [String]
54
+ def format_bytes(bytes)
55
+ return "0 B" if bytes.nil? || bytes.zero?
56
+
57
+ if bytes < 1024
58
+ "#{bytes} B"
59
+ elsif bytes < 1024 * 1024
60
+ format("%<v>.2f KB", v: bytes / 1024.0)
61
+ else
62
+ format("%<v>.2f MB", v: bytes / (1024.0 * 1024))
63
+ end
64
+ end
65
+
66
+ # Right-pad a label to a column width, then append the value.
67
+ # Returns nil if value is nil or empty-string (signal to skip).
68
+ #
69
+ # @param label [String, Symbol]
70
+ # @param value [Object]
71
+ # @return [String, nil]
72
+ def row(label, value, width: LABEL_WIDTH)
73
+ return if value.nil?
74
+ return if value.is_a?(String) && value.empty?
75
+
76
+ label_s = label.to_s
77
+ padding = " " * [(width - label_s.length - 1), 1].max
78
+ " #{label_s}:#{padding}#{value}"
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Autoload hub for the Ucode::Audit::Formatters namespace.
4
+ #
5
+ # Presentation-only: every class here takes a model instance
6
+ # ({Models::Audit::AuditReport}, {Models::Audit::AuditDiff}, or
7
+ # {Models::Audit::LibrarySummary}) and returns a human-readable string.
8
+ # No font parsing, no I/O.
9
+ #
10
+ # MECE with the model layer: formatters READ from models; they never
11
+ # mutate them or carry audit logic. Adding a new output format (e.g.
12
+ # Markdown) = one new file here + one autoload line.
13
+ module Ucode
14
+ module Audit
15
+ module Formatters
16
+ autoload :Color, "ucode/audit/formatters/color"
17
+ autoload :TextFormatter, "ucode/audit/formatters/text_formatter"
18
+ autoload :AuditText, "ucode/audit/formatters/audit_text"
19
+ autoload :AuditDiffText, "ucode/audit/formatters/audit_diff_text"
20
+ autoload :LibrarySummaryText, "ucode/audit/formatters/library_summary_text"
21
+ end
22
+ end
23
+ end