ucode 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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/TODO.full/00-README.md +116 -0
  4. data/TODO.full/01-panglyph-vision.md +112 -0
  5. data/TODO.full/02-panglyph-repo-bootstrap.md +184 -0
  6. data/TODO.full/03-panglyph-font-builder.md +201 -0
  7. data/TODO.full/04-panglyph-publish-pipeline.md +126 -0
  8. data/TODO.full/05-ucode-0-1-1-release.md +139 -0
  9. data/TODO.full/06-fontisan-remove-audit.md +142 -0
  10. data/TODO.full/07-fontisan-remove-ucd.md +125 -0
  11. data/TODO.full/08-archive-private-bin-build.md +143 -0
  12. data/TODO.full/09-archive-public-structure.md +164 -0
  13. data/TODO.full/10-fontist-org-woff-glyphs.md +131 -0
  14. data/TODO.full/11-fontist-org-audit-coverage.md +140 -0
  15. data/TODO.full/12-implementation-order.md +216 -0
  16. data/TODO.full/13-fontisan-font-writer-api.md +189 -0
  17. data/TODO.full/14-fontisan-table-writers.md +66 -0
  18. data/TODO.full/15-panglyph-builder-real.md +82 -0
  19. data/TODO.full/16-archive-public-sync-workflows.md +167 -0
  20. data/TODO.full/17-fontist-org-font-picker.md +73 -0
  21. data/TODO.full/18-comprehensive-spec-coverage.md +64 -0
  22. data/TODO.full/19-ucode-0-1-2-patch.md +32 -0
  23. data/TODO.full/20-fontisan-0-2-23-release.md +52 -0
  24. data/TODO.new/00-README.md +30 -0
  25. data/TODO.new/23-universal-glyph-set-source-map.md +312 -0
  26. data/TODO.new/24-universal-glyph-set-build.md +189 -0
  27. data/TODO.new/25-font-audit-against-universal-set.md +195 -0
  28. data/TODO.new/26-missing-glyph-reporter.md +189 -0
  29. data/TODO.new/27-fontist-org-consumer-integration.md +200 -0
  30. data/TODO.new/28-implementation-order-update.md +187 -0
  31. data/TODO.new/29-universal-set-curation-uc17.md +312 -0
  32. data/TODO.new/30-tier1-font-acquisition.md +241 -0
  33. data/TODO.new/31-universal-set-production-build.md +205 -0
  34. data/TODO.new/32-uc17-coverage-matrix.md +165 -0
  35. data/TODO.new/33-specialist-font-acquisition-refresh.md +138 -0
  36. data/TODO.new/34-pillar2-content-stream-correlator.md +147 -0
  37. data/TODO.new/35-universal-set-production-run.md +160 -0
  38. data/TODO.new/36-per-font-coverage-audit.md +145 -0
  39. data/TODO.new/37-coverage-highlight-reporter.md +125 -0
  40. data/TODO.new/38-fontist-org-glyph-consumer.md +141 -0
  41. data/TODO.new/39-implementation-order-update-32-38.md +258 -0
  42. data/TODO.new/40-archive-private-uses-ucode-audit.md +124 -0
  43. data/TODO.new/41-ucode-unicode-archive-bridge.md +160 -0
  44. data/config/specialist_fonts.yml +102 -0
  45. data/config/unicode17_tier1_fonts.yml +42 -0
  46. data/config/unicode17_universal_glyph_set.yml +293 -0
  47. data/lib/ucode/audit/block_aggregator.rb +57 -29
  48. data/lib/ucode/audit/browser/face_page.rb +128 -0
  49. data/lib/ucode/audit/browser/glyph_panel.rb +124 -0
  50. data/lib/ucode/audit/browser/library_page.rb +74 -0
  51. data/lib/ucode/audit/browser/missing_glyph_page.rb +87 -0
  52. data/lib/ucode/audit/browser/template.rb +47 -0
  53. data/lib/ucode/audit/browser/templates/face.css +200 -0
  54. data/lib/ucode/audit/browser/templates/face.html.erb +41 -0
  55. data/lib/ucode/audit/browser/templates/face.js +298 -0
  56. data/lib/ucode/audit/browser/templates/library.css +119 -0
  57. data/lib/ucode/audit/browser/templates/library.html.erb +42 -0
  58. data/lib/ucode/audit/browser/templates/library.js +99 -0
  59. data/lib/ucode/audit/browser/templates/missing_glyph_page.css +119 -0
  60. data/lib/ucode/audit/browser/templates/missing_glyph_page.html.erb +58 -0
  61. data/lib/ucode/audit/browser/templates/missing_glyph_page.js +2 -0
  62. data/lib/ucode/audit/browser.rb +32 -0
  63. data/lib/ucode/audit/context.rb +27 -1
  64. data/lib/ucode/audit/coverage_reference.rb +103 -0
  65. data/lib/ucode/audit/differ.rb +121 -0
  66. data/lib/ucode/audit/emitter/block_emitter.rb +52 -0
  67. data/lib/ucode/audit/emitter/codepoint_emitter.rb +87 -0
  68. data/lib/ucode/audit/emitter/collection_emitter.rb +80 -0
  69. data/lib/ucode/audit/emitter/face_directory.rb +212 -0
  70. data/lib/ucode/audit/emitter/glyph_emitter.rb +48 -0
  71. data/lib/ucode/audit/emitter/index_emitter.rb +149 -0
  72. data/lib/ucode/audit/emitter/library_emitter.rb +96 -0
  73. data/lib/ucode/audit/emitter/paths.rb +312 -0
  74. data/lib/ucode/audit/emitter/plane_emitter.rb +29 -0
  75. data/lib/ucode/audit/emitter/script_emitter.rb +29 -0
  76. data/lib/ucode/audit/emitter.rb +29 -0
  77. data/lib/ucode/audit/extractors/aggregations.rb +31 -2
  78. data/lib/ucode/audit/face_auditor.rb +86 -0
  79. data/lib/ucode/audit/formatters/audit_diff_text.rb +112 -0
  80. data/lib/ucode/audit/formatters/audit_text.rb +411 -0
  81. data/lib/ucode/audit/formatters/color.rb +48 -0
  82. data/lib/ucode/audit/formatters/library_summary_text.rb +98 -0
  83. data/lib/ucode/audit/formatters/text_formatter.rb +83 -0
  84. data/lib/ucode/audit/formatters.rb +23 -0
  85. data/lib/ucode/audit/library_aggregator.rb +86 -0
  86. data/lib/ucode/audit/library_auditor.rb +105 -0
  87. data/lib/ucode/audit/reference_factory.rb +66 -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 +32 -0
  97. data/lib/ucode/cli.rb +366 -41
  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.rb +19 -0
  104. data/lib/ucode/commands/block_feed.rb +73 -0
  105. data/lib/ucode/commands/build.rb +4 -0
  106. data/lib/ucode/commands/canonical_build.rb +137 -0
  107. data/lib/ucode/commands/fetch.rb +49 -15
  108. data/lib/ucode/commands/glyphs.rb +25 -67
  109. data/lib/ucode/commands/lookup.rb +11 -11
  110. data/lib/ucode/commands/parse.rb +7 -5
  111. data/lib/ucode/commands/release.rb +114 -0
  112. data/lib/ucode/commands/universal_set.rb +207 -0
  113. data/lib/ucode/commands.rb +5 -0
  114. data/lib/ucode/coordinator/indices.rb +49 -2
  115. data/lib/ucode/coordinator.rb +138 -5
  116. data/lib/ucode/error.rb +30 -2
  117. data/lib/ucode/fetch/font_fetcher/result.rb +39 -0
  118. data/lib/ucode/fetch/font_fetcher.rb +16 -0
  119. data/lib/ucode/fetch/specialist_font_fetcher.rb +280 -0
  120. data/lib/ucode/fetch.rb +7 -3
  121. data/lib/ucode/glyphs/pipeline.rb +106 -0
  122. data/lib/ucode/glyphs/real_fonts/cmap_cache.rb +74 -0
  123. data/lib/ucode/glyphs/real_fonts.rb +1 -0
  124. data/lib/ucode/glyphs/resolver.rb +62 -0
  125. data/lib/ucode/glyphs/source.rb +48 -0
  126. data/lib/ucode/glyphs/source_builder.rb +61 -0
  127. data/lib/ucode/glyphs/source_config/coverage_assertion.rb +79 -0
  128. data/lib/ucode/glyphs/source_config/gap_report.rb +54 -0
  129. data/lib/ucode/glyphs/source_config.rb +104 -0
  130. data/lib/ucode/glyphs/sources/pillar1_embedded_tounicode.rb +63 -0
  131. data/lib/ucode/glyphs/sources/pillar3_last_resort.rb +51 -0
  132. data/lib/ucode/glyphs/sources/tier1_real_font.rb +104 -0
  133. data/lib/ucode/glyphs/sources.rb +20 -0
  134. data/lib/ucode/glyphs/universal_set/builder.rb +161 -0
  135. data/lib/ucode/glyphs/universal_set/coverage_report.rb +139 -0
  136. data/lib/ucode/glyphs/universal_set/idempotency.rb +86 -0
  137. data/lib/ucode/glyphs/universal_set/manifest_accumulator.rb +195 -0
  138. data/lib/ucode/glyphs/universal_set/manifest_writer.rb +61 -0
  139. data/lib/ucode/glyphs/universal_set/pre_build_check.rb +197 -0
  140. data/lib/ucode/glyphs/universal_set/validator.rb +204 -0
  141. data/lib/ucode/glyphs/universal_set.rb +45 -0
  142. data/lib/ucode/glyphs.rb +7 -0
  143. data/lib/ucode/models/audit/baseline.rb +6 -0
  144. data/lib/ucode/models/audit/block_summary.rb +7 -0
  145. data/lib/ucode/models/audit/codepoint_provenance.rb +39 -0
  146. data/lib/ucode/models/audit/release_face.rb +42 -0
  147. data/lib/ucode/models/audit/release_formula.rb +33 -0
  148. data/lib/ucode/models/audit/release_manifest.rb +43 -0
  149. data/lib/ucode/models/audit/release_universal_set.rb +37 -0
  150. data/lib/ucode/models/audit.rb +9 -0
  151. data/lib/ucode/models/block.rb +2 -0
  152. data/lib/ucode/models/build_report.rb +109 -0
  153. data/lib/ucode/models/codepoint/glyph.rb +42 -0
  154. data/lib/ucode/models/codepoint.rb +3 -0
  155. data/lib/ucode/models/glyph_source.rb +86 -0
  156. data/lib/ucode/models/glyph_source_map.rb +138 -0
  157. data/lib/ucode/models/specialist_font.rb +70 -0
  158. data/lib/ucode/models/specialist_font_manifest.rb +48 -0
  159. data/lib/ucode/models/unihan_entry.rb +81 -9
  160. data/lib/ucode/models/unihan_field.rb +21 -0
  161. data/lib/ucode/models/universal_set_entry.rb +47 -0
  162. data/lib/ucode/models/universal_set_manifest.rb +78 -0
  163. data/lib/ucode/models/validation_report.rb +99 -0
  164. data/lib/ucode/models.rb +9 -0
  165. data/lib/ucode/parsers/named_sequences.rb +5 -5
  166. data/lib/ucode/parsers/unihan.rb +50 -19
  167. data/lib/ucode/repo/aggregate_writer.rb +82 -288
  168. data/lib/ucode/repo/block_feed_emitter.rb +153 -0
  169. data/lib/ucode/repo/build_report_accumulator.rb +138 -0
  170. data/lib/ucode/repo/build_report_writer.rb +46 -0
  171. data/lib/ucode/repo/build_validator.rb +229 -0
  172. data/lib/ucode/repo/codepoint_writer.rb +50 -1
  173. data/lib/ucode/repo/paths.rb +8 -0
  174. data/lib/ucode/repo/writers/blocks_writer.rb +73 -0
  175. data/lib/ucode/repo/writers/enums_writer.rb +38 -0
  176. data/lib/ucode/repo/writers/indexes_writer.rb +53 -0
  177. data/lib/ucode/repo/writers/manifest_writer.rb +78 -0
  178. data/lib/ucode/repo/writers/named_sequences_writer.rb +47 -0
  179. data/lib/ucode/repo/writers/planes_writer.rb +82 -0
  180. data/lib/ucode/repo/writers/relationships_writer.rb +71 -0
  181. data/lib/ucode/repo/writers/scripts_writer.rb +54 -0
  182. data/lib/ucode/repo/writers.rb +20 -0
  183. data/lib/ucode/repo.rb +5 -0
  184. data/lib/ucode/version.rb +1 -1
  185. data/schema/block-feed.output.schema.yml +134 -0
  186. metadata +158 -4
  187. data/Gemfile.lock +0 -406
data/lib/ucode/cli.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "thor"
4
4
 
5
5
  require "ucode/commands"
6
+ require "ucode/version_resolver"
6
7
 
7
8
  module Ucode
8
9
  # Top-level CLI entry.
@@ -10,6 +11,12 @@ module Ucode
10
11
  # **Thin Thor**: every method delegates to a `Commands::*Command`
11
12
  # class and only formats the result. The Command classes are pure
12
13
  # and testable in-process — Thor never holds business logic.
14
+ #
15
+ # **Version resolution lives here** — each top-level command resolves
16
+ # the user-supplied intent (nil / :default / :latest / explicit string)
17
+ # exactly once via `VersionResolver.resolve` and threads the resolved
18
+ # string into the dispatched Command. Sub-commands never re-resolve.
19
+ # See Candidate 4 of the 2026-06-29 architecture review.
13
20
  class Cli < Thor
14
21
  package_name "ucode"
15
22
 
@@ -28,13 +35,17 @@ module Ucode
28
35
  desc "ucd [VERSION]", "Download UCD.zip"
29
36
  option :force, type: :boolean, default: false, desc: "Re-download even if cached"
30
37
  def ucd(version = nil)
31
- puts format_result Commands::FetchCommand.new.fetch_ucd(version, force: options[:force])
38
+ puts format_result Commands::FetchCommand.new.fetch_ucd(
39
+ VersionResolver.resolve(version), force: options[:force],
40
+ )
32
41
  end
33
42
 
34
43
  desc "unihan [VERSION]", "Download Unihan.zip"
35
44
  option :force, type: :boolean, default: false
36
45
  def unihan(version = nil)
37
- puts format_result Commands::FetchCommand.new.fetch_unihan(version, force: options[:force])
46
+ puts format_result Commands::FetchCommand.new.fetch_unihan(
47
+ VersionResolver.resolve(version), force: options[:force],
48
+ )
38
49
  end
39
50
 
40
51
  desc "charts [VERSION]", "Download per-block Code Charts PDFs"
@@ -43,7 +54,26 @@ module Ucode
43
54
  def charts(version = nil)
44
55
  cps = options[:block]&.map { |id| block_id_to_first_cp(id) }&.compact
45
56
  puts format_result Commands::FetchCommand.new
46
- .fetch_charts(version, block_first_cps: cps, force: options[:force])
57
+ .fetch_charts(VersionResolver.resolve(version),
58
+ block_first_cps: cps, force: options[:force])
59
+ end
60
+
61
+ desc "fonts", "Download specialist Tier 1 fonts (config/specialist_fonts.yml)"
62
+ option :manifest, type: :string,
63
+ desc: "Override manifest path (default config/specialist_fonts.yml)"
64
+ option :label, type: :string, desc: "Fetch only this font by label"
65
+ option :allow_proprietary, type: :boolean, default: false,
66
+ desc: "Permit non-OFL licensed fonts"
67
+ option :dry_run, type: :boolean, default: false,
68
+ desc: "Plan only; no network or disk writes"
69
+ def fonts
70
+ result = Commands::FetchCommand.new.fetch_fonts(
71
+ manifest_path: options[:manifest],
72
+ only_label: options[:label],
73
+ allow_proprietary: options[:allow_proprietary],
74
+ dry_run: options[:dry_run],
75
+ )
76
+ puts format_fonts_result(result)
47
77
  end
48
78
 
49
79
  private
@@ -58,6 +88,11 @@ module Ucode
58
88
  def format_result(result)
59
89
  JSON.pretty_generate(result)
60
90
  end
91
+
92
+ def format_fonts_result(result)
93
+ clean = result.merge(results: result[:results].map { |r| r.to_h.compact })
94
+ JSON.pretty_generate(clean)
95
+ end
61
96
  end
62
97
 
63
98
  desc "fetch", "Download UCD sources"
@@ -67,7 +102,9 @@ module Ucode
67
102
  desc "parse [VERSION]", "Stream UCD → output/"
68
103
  option :to, type: :string, default: "./output", desc: "Output directory"
69
104
  def parse(version = nil)
70
- result = Commands::ParseCommand.new.call(version, output_root: options[:to])
105
+ result = Commands::ParseCommand.new.call(
106
+ VersionResolver.resolve(version), output_root: options[:to],
107
+ )
71
108
  puts JSON.pretty_generate(result)
72
109
  end
73
110
 
@@ -88,7 +125,7 @@ module Ucode
88
125
  desc: "Opt into the experimental v0.1 pipeline"
89
126
  def glyphs(version = nil)
90
127
  result = Commands::GlyphsCommand.new.call(
91
- version,
128
+ VersionResolver.resolve(version),
92
129
  output_root: options[:to],
93
130
  block_filter: options[:block],
94
131
  force: options[:force],
@@ -127,7 +164,9 @@ module Ucode
127
164
  def block(codepoint)
128
165
  cp = parse_cp(codepoint)
129
166
  with_db_handling do
130
- result = Commands::LookupCommand.new.lookup_block(options[:version], codepoint: cp)
167
+ result = Commands::LookupCommand.new.lookup_block(
168
+ VersionResolver.resolve(options[:version]), codepoint: cp,
169
+ )
131
170
  puts "#{format("U+%04X", cp)} → #{result.block || "(unassigned)"}"
132
171
  end
133
172
  end
@@ -137,7 +176,9 @@ module Ucode
137
176
  def script(codepoint)
138
177
  cp = parse_cp(codepoint)
139
178
  with_db_handling do
140
- result = Commands::LookupCommand.new.lookup_script(options[:version], codepoint: cp)
179
+ result = Commands::LookupCommand.new.lookup_script(
180
+ VersionResolver.resolve(options[:version]), codepoint: cp,
181
+ )
141
182
  puts "#{format("U+%04X", cp)} → #{result.script || "(none)"}"
142
183
  end
143
184
  end
@@ -149,7 +190,8 @@ module Ucode
149
190
  cp = parse_cp(codepoint)
150
191
  with_db_handling do
151
192
  result = Commands::LookupCommand.new
152
- .lookup_char(options[:version], codepoint: cp, output_root: options[:from])
193
+ .lookup_char(VersionResolver.resolve(options[:version]),
194
+ codepoint: cp, output_root: options[:from])
153
195
  puts "#{format("U+%04X", cp)} block=#{result.block_id} glyph=#{result.glyph_path}"
154
196
  end
155
197
  end
@@ -222,50 +264,333 @@ module Ucode
222
264
  puts JSON.pretty_generate(result)
223
265
  end
224
266
 
225
- # ─────────────── font-coverage ───────────────
226
- desc "font-coverage FONT [FONT...]", "Audit Unicode 17 block coverage for one or more fonts"
227
- long_desc <<~LONG
228
- Each FONT argument is either a fontist formula name (resolved via
229
- `Fontist::Font.find` then `install`) or `label=/path/to/font.ttf`
230
- (uses the local file directly). For every font, walks the cmap via
231
- fontisan and emits per-Unicode-17-block coverage to
232
- `<to>/font_coverage/<label>.json`.
267
+ # ─────────────── audit ───────────────
268
+ class Audit < Thor
269
+ desc "font PATH", "Audit a single font (or fontist formula name)"
270
+ option :label, type: :string, default: nil,
271
+ desc: "Output directory name (default: postscript_name)"
272
+ option :unicode_version, type: :string, default: nil
273
+ option :output, type: :string, default: "./output"
274
+ option :verbose, type: :boolean, default: false,
275
+ desc: "Emit per-codepoint detail chunks"
276
+ option :with_glyphs, type: :boolean, default: false,
277
+ desc: "Emit per-codepoint SVG chunks (no-op until TODO 20)"
278
+ option :brief, type: :boolean, default: false,
279
+ desc: "Cheap-extractor-only mode"
280
+ option :browse, type: :boolean, default: false,
281
+ desc: "Also write the self-contained HTML browser"
282
+ option :no_install, type: :boolean, default: false,
283
+ desc: "Don't auto-install missing fonts via fontist"
284
+ option :reference_universal_set, type: :string, default: nil,
285
+ desc: "Path to universal-set manifest (or 'none'); " \
286
+ "default: output/universal_glyph_set/manifest.json " \
287
+ "if present, else UCD-only"
288
+ option :universal_set_root, type: :string, default: nil,
289
+ desc: "Path to universal-set build root (e.g. " \
290
+ "output/universal_glyph_set). Required for " \
291
+ "--with-missing-glyph-pages."
292
+ option :with_missing_glyph_pages, type: :boolean, default: false,
293
+ desc: "Emit per-block missing-glyph galleries " \
294
+ "(requires --browse + --universal-set-root)"
295
+ def font(path)
296
+ reference = Ucode::Audit::ReferenceFactory.build_from_cli(
297
+ flag: options[:reference_universal_set],
298
+ version: options[:unicode_version],
299
+ )
300
+ result = Commands::Audit::FontCommand.new.call(
301
+ path,
302
+ label: options[:label],
303
+ unicode_version: options[:unicode_version],
304
+ verbose: options[:verbose],
305
+ with_glyphs: options[:with_glyphs],
306
+ brief: options[:brief],
307
+ output_root: options[:output],
308
+ browse: options[:browse],
309
+ install: !options[:no_install],
310
+ reference: reference,
311
+ universal_set_root: options[:universal_set_root],
312
+ with_missing_glyph_pages: options[:with_missing_glyph_pages],
313
+ )
314
+ puts JSON.pretty_generate(result_to_h(result))
315
+ end
316
+
317
+ desc "collection PATH", "Audit a TTC/OTC/dfong collection"
318
+ option :font_index, type: :numeric, default: nil,
319
+ desc: "Audit only face N (single-face output)"
320
+ option :label, type: :string, default: nil
321
+ option :unicode_version, type: :string, default: nil
322
+ option :output, type: :string, default: "./output"
323
+ option :verbose, type: :boolean, default: false
324
+ option :with_glyphs, type: :boolean, default: false
325
+ option :brief, type: :boolean, default: false
326
+ option :browse, type: :boolean, default: false
327
+ option :reference_universal_set, type: :string, default: nil,
328
+ desc: "Path to universal-set manifest (or 'none')"
329
+ option :universal_set_root, type: :string, default: nil,
330
+ desc: "Path to universal-set build root"
331
+ option :with_missing_glyph_pages, type: :boolean, default: false,
332
+ desc: "Emit per-block missing-glyph galleries"
333
+ def collection(path)
334
+ reference = Ucode::Audit::ReferenceFactory.build_from_cli(
335
+ flag: options[:reference_universal_set],
336
+ version: options[:unicode_version],
337
+ )
338
+ result = Commands::Audit::CollectionCommand.new.call(
339
+ path,
340
+ font_index: options[:font_index],
341
+ label: options[:label],
342
+ unicode_version: options[:unicode_version],
343
+ verbose: options[:verbose],
344
+ with_glyphs: options[:with_glyphs],
345
+ brief: options[:brief],
346
+ output_root: options[:output],
347
+ browse: options[:browse],
348
+ reference: reference,
349
+ universal_set_root: options[:universal_set_root],
350
+ with_missing_glyph_pages: options[:with_missing_glyph_pages],
351
+ )
352
+ puts JSON.pretty_generate(result_to_h(result))
353
+ end
354
+
355
+ desc "library DIR", "Walk a directory of fonts and audit each"
356
+ option :recursive, type: :boolean, default: false
357
+ option :unicode_version, type: :string, default: nil
358
+ option :output, type: :string, default: "./output"
359
+ option :verbose, type: :boolean, default: false
360
+ option :with_glyphs, type: :boolean, default: false
361
+ option :brief, type: :boolean, default: false
362
+ option :browse, type: :boolean, default: false,
363
+ desc: "Also write the library + face HTML browsers"
364
+ option :reference_universal_set, type: :string, default: nil,
365
+ desc: "Path to universal-set manifest (or 'none')"
366
+ option :universal_set_root, type: :string, default: nil,
367
+ desc: "Path to universal-set build root"
368
+ option :with_missing_glyph_pages, type: :boolean, default: false,
369
+ desc: "Emit per-block missing-glyph galleries"
370
+ def library(dir)
371
+ reference = Ucode::Audit::ReferenceFactory.build_from_cli(
372
+ flag: options[:reference_universal_set],
373
+ version: options[:unicode_version],
374
+ )
375
+ result = Commands::Audit::LibraryCommand.new.call(
376
+ dir,
377
+ recursive: options[:recursive],
378
+ unicode_version: options[:unicode_version],
379
+ verbose: options[:verbose],
380
+ with_glyphs: options[:with_glyphs],
381
+ brief: options[:brief],
382
+ output_root: options[:output],
383
+ browse: options[:browse],
384
+ reference: reference,
385
+ universal_set_root: options[:universal_set_root],
386
+ with_missing_glyph_pages: options[:with_missing_glyph_pages],
387
+ )
388
+ puts JSON.pretty_generate(result_to_h(result))
389
+ end
390
+
391
+ desc "compare LEFT RIGHT", "Diff two audits"
392
+ option :unicode_version, type: :string, default: nil
393
+ option :output, type: :string, default: nil,
394
+ desc: "Write text diff to file (default: stdout)"
395
+ def compare(left, right)
396
+ result = Commands::Audit::CompareCommand.new.call(
397
+ left, right,
398
+ unicode_version: options[:unicode_version],
399
+ output_file: options[:output],
400
+ )
401
+ if result.error
402
+ warn "compare failed: #{result.error}"
403
+ exit 1
404
+ elsif options[:output].nil?
405
+ puts result.text
406
+ else
407
+ puts "wrote #{options[:output]}"
408
+ end
409
+ end
410
+
411
+ desc "browser", "Regenerate HTML browsers from existing JSON audits"
412
+ option :input, type: :string, default: "./output/font_audit"
413
+ option :faces_only, type: :boolean, default: false
414
+ option :library_only, type: :boolean, default: false
415
+ def browser
416
+ result = Commands::Audit::BrowserCommand.new.call(
417
+ input: options[:input],
418
+ faces_only: options[:faces_only],
419
+ library_only: options[:library_only],
420
+ )
421
+ puts JSON.pretty_generate(result_to_h(result))
422
+ end
233
423
 
234
- Examples:
424
+ private
425
+
426
+ def result_to_h(result)
427
+ return { error: result.error } if result.error
428
+
429
+ result.to_h.compact.transform_values do |v|
430
+ v.is_a?(Struct) ? v.to_h : v
431
+ end
432
+ end
433
+ end
434
+
435
+ desc "audit", "Audit font coverage against the Unicode baseline"
436
+ subcommand "audit", Audit
437
+
438
+ # ─────────────── universal-set ───────────────
439
+ class UniversalSetCmd < Thor
440
+ desc "build [VERSION]", "Materialize the universal glyph set (one SVG per assigned codepoint)"
441
+ option :to, type: :string, default: "./output/universal_glyph_set",
442
+ desc: "Output directory"
443
+ option :source_config, type: :string, default: nil,
444
+ desc: "Path to a Tier 1 source config YAML " \
445
+ "(default: config/unicode17_universal_glyph_set.yml)"
446
+ option :block, type: :string, default: nil,
447
+ desc: "Limit the build to one block (canonical underscore form)"
448
+ option :parallel, type: :numeric, default: nil,
449
+ desc: "Worker pool size (default: Ucode.configuration.parallel_workers)"
450
+ def build(version = nil)
451
+ result = Commands::UniversalSet::BuildCommand.new.call(
452
+ VersionResolver.resolve(version),
453
+ output_root: options[:to],
454
+ source_config_path: options[:source_config],
455
+ block_filter: options[:block],
456
+ parallel_workers: options[:parallel] || Ucode.configuration.parallel_workers,
457
+ )
458
+ puts JSON.pretty_generate(result)
459
+ rescue Ucode::UniversalSetPreBuildError => e
460
+ warn "pre-build validation failed:"
461
+ warn JSON.pretty_generate(e.context)
462
+ exit 1
463
+ end
464
+
465
+ desc "pre-check [VERSION]", "Validate source config + fonts + coverage assertion before a build"
466
+ option :source_config, type: :string, default: nil,
467
+ desc: "Path to a Tier 1 source config YAML"
468
+ def pre_check(version = nil)
469
+ report = Commands::UniversalSet::PreCheckCommand.new.call(
470
+ VersionResolver.resolve(version),
471
+ source_config_path: options[:source_config],
472
+ )
473
+ puts JSON.pretty_generate(report.to_h)
474
+ rescue Ucode::UniversalSetPreBuildError => e
475
+ warn "pre-build validation failed:"
476
+ warn JSON.pretty_generate(e.context)
477
+ exit 1
478
+ end
479
+
480
+ desc "report [VERSION]", "Emit per-tier / per-block / gaps reports from an existing manifest"
481
+ option :from, type: :string, default: "./output/universal_glyph_set",
482
+ desc: "Output directory holding manifest.json"
483
+ def report(version = nil)
484
+ result = Commands::UniversalSet::ReportCommand.new.call(
485
+ VersionResolver.resolve(version),
486
+ output_root: options[:from],
487
+ )
488
+ puts JSON.pretty_generate(result)
489
+ end
235
490
 
236
- ucode font-coverage Lentariso=/tmp/lentariso/TTFs/Lentariso-Re.ttf \\
237
- Kedebideri=/tmp/kedebideri/Kedebideri-3.001/Kedebideri-Regular.ttf
491
+ desc "validate [OUTPUT_ROOT]", "Run post-build structural validation on a manifest + glyphs dir"
492
+ option :version, type: :string, default: nil,
493
+ desc: "Unicode version (stamps the report; defaults to manifest)"
494
+ def validate(output_root = "./output/universal_glyph_set")
495
+ result = Commands::UniversalSet::ValidateCommand.new.call(
496
+ output_root,
497
+ version: options[:version] && VersionResolver.resolve(options[:version]),
498
+ )
499
+ puts JSON.pretty_generate(result)
500
+ exit 1 unless result[:passed]
501
+ end
502
+ end
503
+
504
+ desc "universal-set", "Build and inspect the universal glyph set reference"
505
+ subcommand "universal-set", UniversalSetCmd
238
506
 
239
- ucode font-coverage Kedebideri # resolves + installs via fontist
507
+ # ─────────────── release ───────────────
508
+ desc "release", "Assemble the fontist.org release tree from per-formula audits"
509
+ long_desc <<~LONG
510
+ Walks a directory of per-formula font subdirectories and produces
511
+ the fontist.org-consumable release tree at
512
+ `<output>/font_audit_release/`. The release tree contains:
513
+
514
+ audit/<slug>/<postscript_name>/ — per-face audit subtrees
515
+ universal_glyph_set/ — pre-staged universal set
516
+ library.json — formula + face card index
517
+ manifest.json — versions, sha256s, totals
518
+
519
+ The universal-set directory is NOT copied by this command; the
520
+ CI collector is expected to pre-stage it under
521
+ `<output>/font_audit_release/universal_glyph_set/`.
240
522
  LONG
241
- option :to, type: :string, default: "./output"
242
- option :no_install, type: :boolean, default: false,
243
- desc: "Don't auto-install missing fonts via fontist"
244
- def font_coverage(*fonts)
245
- raise Thor::Error, "Provide at least one font" if fonts.empty?
523
+ option :from, type: :string, required: true,
524
+ desc: "Directory of per-formula font subdirectories"
525
+ option :output, type: :string, default: "./output",
526
+ desc: "Parent of the release root"
527
+ option :universal_set, type: :string, default: nil,
528
+ desc: "Path to the universal_glyph_set directory " \
529
+ "(default: <release_root>/universal_glyph_set)"
530
+ option :unicode_version, type: :string, default: nil
531
+ option :brief, type: :boolean, default: false
532
+ option :browse, type: :boolean, default: true,
533
+ desc: "Also write per-face HTML browsers + missing-glyph pages"
534
+ option :source_config_sha256, type: :string, default: nil,
535
+ desc: "sha256 of the Tier 1 source-config YAML"
536
+ option :reference_universal_set, type: :string, default: nil,
537
+ desc: "Path to universal-set manifest (or 'none') " \
538
+ "for the per-face coverage reference"
539
+ def release
540
+ reference = Ucode::Audit::ReferenceFactory.build_from_cli(
541
+ flag: options[:reference_universal_set],
542
+ version: options[:unicode_version],
543
+ )
544
+ result = Commands::ReleaseCommand.new.call(
545
+ from: options[:from],
546
+ output_root: options[:output],
547
+ universal_set_root: options[:universal_set],
548
+ unicode_version: options[:unicode_version],
549
+ brief: options[:brief],
550
+ browse: options[:browse],
551
+ source_config_sha256: options[:source_config_sha256],
552
+ reference: reference,
553
+ )
554
+ puts JSON.pretty_generate(result_to_h(result))
555
+ end
246
556
 
247
- results = Commands::FontCoverageCommand.new.call(
248
- fonts,
249
- output_root: options[:to],
250
- install: !options[:no_install],
557
+ # ─────────────── block-feed ───────────────
558
+ desc "block-feed", "Emit per-block Unicode data feed from ucode output"
559
+ long_desc <<~LONG
560
+ Translates ucode's canonical output tree into a compact per-block
561
+ Unicode data feed:
562
+
563
+ <target>/unicode-blocks.json
564
+ <target>/unicode-version.json
565
+ <target>/unicode/blocks/<slug>.json
566
+
567
+ Each per-block file contains the codepoints in that block with
568
+ their compact metadata (name, general category, script, combining
569
+ class, bidi class, mirrored flag). Block slugs are derived from
570
+ the block name via the standard slug algorithm.
571
+ LONG
572
+ option :ucode_output, type: :string, default: "./output",
573
+ desc: "ucode's output/ directory"
574
+ option :target, type: :string, default: "./output/block-feed",
575
+ desc: "Target directory for emitted files"
576
+ option :unicode_version, type: :string, default: nil,
577
+ desc: "UCD version stamp (default: from manifest)"
578
+ def block_feed
579
+ result = Commands::BlockFeedCommand.new.call(
580
+ ucode_output_root: options[:ucode_output],
581
+ block_feed_output_root: options[:target],
582
+ unicode_version: options[:unicode_version],
251
583
  )
252
- puts JSON.pretty_generate(results.map { |r| result_to_h(r) })
584
+ puts JSON.pretty_generate(result.to_h)
253
585
  end
254
586
 
255
587
  private
256
588
 
257
589
  def result_to_h(result)
258
- if result.error
259
- { spec: result.spec, error: result.error }
260
- else
261
- {
262
- spec: result.spec,
263
- label: result.located.name,
264
- source: result.located.path.to_s,
265
- via: result.located.via,
266
- output_path: result.output_path.to_s,
267
- complete_blocks: result.complete_blocks,
268
- }
590
+ return { error: result.error } if result.error
591
+
592
+ result.to_h.compact.transform_values do |v|
593
+ v.is_a?(Struct) ? v.to_h : v
269
594
  end
270
595
  end
271
596
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "json"
5
+
6
+ require "ucode/audit"
7
+ require "ucode/audit/browser"
8
+ require "ucode/audit/emitter/paths"
9
+
10
+ module Ucode
11
+ module Commands
12
+ module Audit
13
+ # `ucode audit browser` — regenerate HTML browsers from existing
14
+ # JSON audits, without re-running extractors.
15
+ #
16
+ # Walks the audit root and rewrites only `.html` files. Useful
17
+ # when the audit ran without `--browse`, or after a CSS/JS
18
+ # template tweak. No JSON is rewritten.
19
+ #
20
+ # Scopes:
21
+ # - default: regenerate both library-level + all face pages.
22
+ # - `faces_only: true` — only per-face pages.
23
+ # - `library_only: true` — only the library-level page.
24
+ class BrowserCommand
25
+ FaceRegen = Struct.new(:label, :path, :written, keyword_init: true)
26
+
27
+ Result = Struct.new(:input, :library_html, :faces, :error,
28
+ keyword_init: true)
29
+
30
+ # @param input [String, Pathname] audit root path. Must be a
31
+ # directory containing either `<input>/index.json` (library
32
+ # root) or per-face subdirectories each with their own
33
+ # `index.json` (face root). Either way the root is treated
34
+ # as the library root.
35
+ # @param faces_only [Boolean]
36
+ # @param library_only [Boolean]
37
+ # @return [Result]
38
+ def call(input:, faces_only: false, library_only: false)
39
+ audit_root = Pathname.new(input)
40
+
41
+ library_html =
42
+ library_only || !faces_only ? write_library(audit_root) : nil
43
+ faces =
44
+ faces_only || !library_only ? write_faces(audit_root) : []
45
+
46
+ Result.new(input: audit_root.to_s, library_html: library_html&.to_s,
47
+ faces: faces)
48
+ rescue StandardError => e
49
+ Result.new(input: input.to_s, error: "#{e.class}: #{e.message}")
50
+ end
51
+
52
+ private
53
+
54
+ # The library index.json sits at `<audit_root>/index.json`;
55
+ # the library index.html is written to the same directory.
56
+ # {Browser::LibraryPage#write} takes the output_root (one
57
+ # level up) so we pass `audit_root.parent`.
58
+ def write_library(audit_root)
59
+ index_json = audit_root.join("index.json")
60
+ return nil unless index_json.exist?
61
+
62
+ Ucode::Audit::Browser::LibraryPage.new(library_json: index_json.read)
63
+ .write(audit_root.parent)
64
+ Ucode::Audit::Emitter::Paths.library_html_path(audit_root.parent)
65
+ end
66
+
67
+ def write_faces(audit_root)
68
+ audit_root.children.select(&:directory?).filter_map do |face_dir|
69
+ json = face_dir.join("index.json")
70
+ next unless json.exist?
71
+
72
+ written = Ucode::Audit::Browser::FacePage.new(overview_json: json.read)
73
+ .write(face_dir)
74
+ FaceRegen.new(label: face_dir.basename.to_s,
75
+ path: face_dir.join("index.html").to_s,
76
+ written: written)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require "fontisan"
6
+
7
+ require "ucode/commands/audit/font_command"
8
+ require "ucode/audit/face_auditor"
9
+
10
+ module Ucode
11
+ module Commands
12
+ module Audit
13
+ # `ucode audit collection PATH` — explicit collection audit.
14
+ # Wraps {FontCommand} with two collection-specific behaviors:
15
+ #
16
+ # - Validates the source is actually a collection
17
+ # (TTC/OTC/dfong). Errors out otherwise.
18
+ # - Supports `font_index:` to audit only one face of the
19
+ # collection, producing a single-face tree.
20
+ #
21
+ # For unspecified collection options, delegates to FontCommand.
22
+ class CollectionCommand
23
+ # @param font_path [String, Pathname] must be a collection source.
24
+ # @param font_index [Integer, nil] if set, audit only this face.
25
+ # @param kwargs [Hash] forwarded to {FontCommand#call}.
26
+ # @return [FontCommand::Result] when auditing all faces, or a
27
+ # single-face variant when `font_index:` is set.
28
+ def call(font_path, font_index: nil, **kwargs)
29
+ raise CollectionRequiredError, font_path unless collection?(font_path)
30
+ return audit_single_face(font_path, font_index, kwargs) if font_index
31
+
32
+ font_command.call(font_path, **kwargs)
33
+ end
34
+
35
+ private
36
+
37
+ def collection?(path)
38
+ Fontisan::FontLoader.collection?(path.to_s)
39
+ end
40
+
41
+ def audit_single_face(font_path, index, kwargs)
42
+ output_root = kwargs.fetch(:output_root)
43
+ options = build_options(kwargs)
44
+ report = Ucode::Audit::FaceAuditor.new(font_path.to_s, options: options,
45
+ mode: mode_from(kwargs),
46
+ font_index: index,
47
+ reference: kwargs[:reference]).call
48
+
49
+ directory = Ucode::Audit::Emitter::FaceDirectory.new(
50
+ output_root: output_root,
51
+ verbose: kwargs.fetch(:verbose, false),
52
+ with_glyphs: kwargs.fetch(:with_glyphs, false),
53
+ emit_browser: kwargs.fetch(:browse, false),
54
+ universal_set_root: kwargs[:universal_set_root],
55
+ with_missing_glyph_pages: kwargs.fetch(:with_missing_glyph_pages, false),
56
+ )
57
+
58
+ label = sanitize(kwargs[:label] || report.postscript_name || "face-#{index}")
59
+ face_dir = directory.emit_face(label: label, report: report)
60
+
61
+ FontCommand::Result.new(
62
+ spec: font_path.to_s,
63
+ label: label,
64
+ output_dir: face_dir.to_s,
65
+ faces: [FontCommand::FaceOutcome.new(
66
+ label: label,
67
+ postscript_name: report.postscript_name,
68
+ output_dir: face_dir.to_s,
69
+ )],
70
+ )
71
+ end
72
+
73
+ def build_options(kwargs)
74
+ opts = {}
75
+ opts[:ucd_version] = kwargs[:unicode_version] if kwargs[:unicode_version]
76
+ opts[:audit_brief] = true if kwargs[:brief]
77
+ opts
78
+ end
79
+
80
+ def mode_from(kwargs)
81
+ kwargs[:brief] ? :brief : :full
82
+ end
83
+
84
+ def sanitize(name)
85
+ (name || "face").to_s.gsub(/[^A-Za-z0-9._-]/, "_")
86
+ end
87
+
88
+ def font_command
89
+ @font_command ||= FontCommand.new
90
+ end
91
+ end
92
+
93
+ # Raised by {CollectionCommand} when the input is not a
94
+ # collection source.
95
+ class CollectionRequiredError < StandardError
96
+ # @param path [String, Pathname]
97
+ def initialize(path)
98
+ super("#{path} is not a collection (TTC/OTC/dfong) source")
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end