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
data/lib/ucode/cli.rb CHANGED
@@ -46,6 +46,24 @@ module Ucode
46
46
  .fetch_charts(version, block_first_cps: cps, force: options[:force])
47
47
  end
48
48
 
49
+ desc "fonts", "Download specialist Tier 1 fonts (config/specialist_fonts.yml)"
50
+ option :manifest, type: :string,
51
+ desc: "Override manifest path (default config/specialist_fonts.yml)"
52
+ option :label, type: :string, desc: "Fetch only this font by label"
53
+ option :allow_proprietary, type: :boolean, default: false,
54
+ desc: "Permit non-OFL licensed fonts"
55
+ option :dry_run, type: :boolean, default: false,
56
+ desc: "Plan only; no network or disk writes"
57
+ def fonts
58
+ result = Commands::FetchCommand.new.fetch_fonts(
59
+ manifest_path: options[:manifest],
60
+ only_label: options[:label],
61
+ allow_proprietary: options[:allow_proprietary],
62
+ dry_run: options[:dry_run],
63
+ )
64
+ puts format_fonts_result(result)
65
+ end
66
+
49
67
  private
50
68
 
51
69
  def block_id_to_first_cp(id)
@@ -58,6 +76,11 @@ module Ucode
58
76
  def format_result(result)
59
77
  JSON.pretty_generate(result)
60
78
  end
79
+
80
+ def format_fonts_result(result)
81
+ clean = result.merge(results: result[:results].map { |r| r.to_h.compact })
82
+ JSON.pretty_generate(clean)
83
+ end
61
84
  end
62
85
 
63
86
  desc "fetch", "Download UCD sources"
@@ -222,50 +245,333 @@ module Ucode
222
245
  puts JSON.pretty_generate(result)
223
246
  end
224
247
 
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`.
248
+ # ─────────────── audit ───────────────
249
+ class Audit < Thor
250
+ desc "font PATH", "Audit a single font (or fontist formula name)"
251
+ option :label, type: :string, default: nil,
252
+ desc: "Output directory name (default: postscript_name)"
253
+ option :unicode_version, type: :string, default: nil
254
+ option :output, type: :string, default: "./output"
255
+ option :verbose, type: :boolean, default: false,
256
+ desc: "Emit per-codepoint detail chunks"
257
+ option :with_glyphs, type: :boolean, default: false,
258
+ desc: "Emit per-codepoint SVG chunks (no-op until TODO 20)"
259
+ option :brief, type: :boolean, default: false,
260
+ desc: "Cheap-extractor-only mode"
261
+ option :browse, type: :boolean, default: false,
262
+ desc: "Also write the self-contained HTML browser"
263
+ option :no_install, type: :boolean, default: false,
264
+ desc: "Don't auto-install missing fonts via fontist"
265
+ option :reference_universal_set, type: :string, default: nil,
266
+ desc: "Path to universal-set manifest (or 'none'); " \
267
+ "default: output/universal_glyph_set/manifest.json " \
268
+ "if present, else UCD-only"
269
+ option :universal_set_root, type: :string, default: nil,
270
+ desc: "Path to universal-set build root (e.g. " \
271
+ "output/universal_glyph_set). Required for " \
272
+ "--with-missing-glyph-pages."
273
+ option :with_missing_glyph_pages, type: :boolean, default: false,
274
+ desc: "Emit per-block missing-glyph galleries " \
275
+ "(requires --browse + --universal-set-root)"
276
+ def font(path)
277
+ reference = Commands::Audit::ReferenceBuilder.build(
278
+ flag: options[:reference_universal_set],
279
+ version: options[:unicode_version],
280
+ )
281
+ result = Commands::Audit::FontCommand.new.call(
282
+ path,
283
+ label: options[:label],
284
+ unicode_version: options[:unicode_version],
285
+ verbose: options[:verbose],
286
+ with_glyphs: options[:with_glyphs],
287
+ brief: options[:brief],
288
+ output_root: options[:output],
289
+ browse: options[:browse],
290
+ install: !options[:no_install],
291
+ reference: reference,
292
+ universal_set_root: options[:universal_set_root],
293
+ with_missing_glyph_pages: options[:with_missing_glyph_pages],
294
+ )
295
+ puts JSON.pretty_generate(result_to_h(result))
296
+ end
297
+
298
+ desc "collection PATH", "Audit a TTC/OTC/dfong collection"
299
+ option :font_index, type: :numeric, default: nil,
300
+ desc: "Audit only face N (single-face output)"
301
+ option :label, type: :string, default: nil
302
+ option :unicode_version, type: :string, default: nil
303
+ option :output, type: :string, default: "./output"
304
+ option :verbose, type: :boolean, default: false
305
+ option :with_glyphs, type: :boolean, default: false
306
+ option :brief, type: :boolean, default: false
307
+ option :browse, type: :boolean, default: false
308
+ option :reference_universal_set, type: :string, default: nil,
309
+ desc: "Path to universal-set manifest (or 'none')"
310
+ option :universal_set_root, type: :string, default: nil,
311
+ desc: "Path to universal-set build root"
312
+ option :with_missing_glyph_pages, type: :boolean, default: false,
313
+ desc: "Emit per-block missing-glyph galleries"
314
+ def collection(path)
315
+ reference = Commands::Audit::ReferenceBuilder.build(
316
+ flag: options[:reference_universal_set],
317
+ version: options[:unicode_version],
318
+ )
319
+ result = Commands::Audit::CollectionCommand.new.call(
320
+ path,
321
+ font_index: options[:font_index],
322
+ label: options[:label],
323
+ unicode_version: options[:unicode_version],
324
+ verbose: options[:verbose],
325
+ with_glyphs: options[:with_glyphs],
326
+ brief: options[:brief],
327
+ output_root: options[:output],
328
+ browse: options[:browse],
329
+ reference: reference,
330
+ universal_set_root: options[:universal_set_root],
331
+ with_missing_glyph_pages: options[:with_missing_glyph_pages],
332
+ )
333
+ puts JSON.pretty_generate(result_to_h(result))
334
+ end
335
+
336
+ desc "library DIR", "Walk a directory of fonts and audit each"
337
+ option :recursive, type: :boolean, default: false
338
+ option :unicode_version, type: :string, default: nil
339
+ option :output, type: :string, default: "./output"
340
+ option :verbose, type: :boolean, default: false
341
+ option :with_glyphs, type: :boolean, default: false
342
+ option :brief, type: :boolean, default: false
343
+ option :browse, type: :boolean, default: false,
344
+ desc: "Also write the library + face HTML browsers"
345
+ option :reference_universal_set, type: :string, default: nil,
346
+ desc: "Path to universal-set manifest (or 'none')"
347
+ option :universal_set_root, type: :string, default: nil,
348
+ desc: "Path to universal-set build root"
349
+ option :with_missing_glyph_pages, type: :boolean, default: false,
350
+ desc: "Emit per-block missing-glyph galleries"
351
+ def library(dir)
352
+ reference = Commands::Audit::ReferenceBuilder.build(
353
+ flag: options[:reference_universal_set],
354
+ version: options[:unicode_version],
355
+ )
356
+ result = Commands::Audit::LibraryCommand.new.call(
357
+ dir,
358
+ recursive: options[:recursive],
359
+ unicode_version: options[:unicode_version],
360
+ verbose: options[:verbose],
361
+ with_glyphs: options[:with_glyphs],
362
+ brief: options[:brief],
363
+ output_root: options[:output],
364
+ browse: options[:browse],
365
+ reference: reference,
366
+ universal_set_root: options[:universal_set_root],
367
+ with_missing_glyph_pages: options[:with_missing_glyph_pages],
368
+ )
369
+ puts JSON.pretty_generate(result_to_h(result))
370
+ end
371
+
372
+ desc "compare LEFT RIGHT", "Diff two audits"
373
+ option :unicode_version, type: :string, default: nil
374
+ option :output, type: :string, default: nil,
375
+ desc: "Write text diff to file (default: stdout)"
376
+ def compare(left, right)
377
+ result = Commands::Audit::CompareCommand.new.call(
378
+ left, right,
379
+ unicode_version: options[:unicode_version],
380
+ output_file: options[:output],
381
+ )
382
+ if result.error
383
+ warn "compare failed: #{result.error}"
384
+ exit 1
385
+ elsif options[:output].nil?
386
+ puts result.text
387
+ else
388
+ puts "wrote #{options[:output]}"
389
+ end
390
+ end
233
391
 
234
- Examples:
392
+ desc "browser", "Regenerate HTML browsers from existing JSON audits"
393
+ option :input, type: :string, default: "./output/font_audit"
394
+ option :faces_only, type: :boolean, default: false
395
+ option :library_only, type: :boolean, default: false
396
+ def browser
397
+ result = Commands::Audit::BrowserCommand.new.call(
398
+ input: options[:input],
399
+ faces_only: options[:faces_only],
400
+ library_only: options[:library_only],
401
+ )
402
+ puts JSON.pretty_generate(result_to_h(result))
403
+ end
235
404
 
236
- ucode font-coverage Lentariso=/tmp/lentariso/TTFs/Lentariso-Re.ttf \\
237
- Kedebideri=/tmp/kedebideri/Kedebideri-3.001/Kedebideri-Regular.ttf
405
+ private
238
406
 
239
- ucode font-coverage Kedebideri # resolves + installs via fontist
407
+ def result_to_h(result)
408
+ return { error: result.error } if result.error
409
+
410
+ result.to_h.compact.transform_values do |v|
411
+ v.is_a?(Struct) ? v.to_h : v
412
+ end
413
+ end
414
+ end
415
+
416
+ desc "audit", "Audit font coverage against the Unicode baseline"
417
+ subcommand "audit", Audit
418
+
419
+ # ─────────────── universal-set ───────────────
420
+ class UniversalSetCmd < Thor
421
+ desc "build [VERSION]", "Materialize the universal glyph set (one SVG per assigned codepoint)"
422
+ option :to, type: :string, default: "./output/universal_glyph_set",
423
+ desc: "Output directory"
424
+ option :source_config, type: :string, default: nil,
425
+ desc: "Path to a Tier 1 source config YAML " \
426
+ "(default: config/unicode17_universal_glyph_set.yml)"
427
+ option :block, type: :string, default: nil,
428
+ desc: "Limit the build to one block (canonical underscore form)"
429
+ option :parallel, type: :numeric, default: nil,
430
+ desc: "Worker pool size (default: Ucode.configuration.parallel_workers)"
431
+ def build(version = nil)
432
+ result = Commands::UniversalSet::BuildCommand.new.call(
433
+ version,
434
+ output_root: options[:to],
435
+ source_config_path: options[:source_config],
436
+ block_filter: options[:block],
437
+ parallel_workers: options[:parallel] || Ucode.configuration.parallel_workers,
438
+ )
439
+ puts JSON.pretty_generate(result)
440
+ rescue Ucode::UniversalSetPreBuildError => e
441
+ warn "pre-build validation failed:"
442
+ warn JSON.pretty_generate(e.context)
443
+ exit 1
444
+ end
445
+
446
+ desc "pre-check [VERSION]", "Validate source config + fonts + coverage assertion before a build"
447
+ option :source_config, type: :string, default: nil,
448
+ desc: "Path to a Tier 1 source config YAML"
449
+ def pre_check(version = nil)
450
+ report = Commands::UniversalSet::PreCheckCommand.new.call(
451
+ version,
452
+ source_config_path: options[:source_config],
453
+ )
454
+ puts JSON.pretty_generate(report.to_h)
455
+ rescue Ucode::UniversalSetPreBuildError => e
456
+ warn "pre-build validation failed:"
457
+ warn JSON.pretty_generate(e.context)
458
+ exit 1
459
+ end
460
+
461
+ desc "report [VERSION]", "Emit per-tier / per-block / gaps reports from an existing manifest"
462
+ option :from, type: :string, default: "./output/universal_glyph_set",
463
+ desc: "Output directory holding manifest.json"
464
+ def report(version = nil)
465
+ result = Commands::UniversalSet::ReportCommand.new.call(
466
+ version,
467
+ output_root: options[:from],
468
+ )
469
+ puts JSON.pretty_generate(result)
470
+ end
471
+
472
+ desc "validate [OUTPUT_ROOT]", "Run post-build structural validation on a manifest + glyphs dir"
473
+ option :version, type: :string, default: nil,
474
+ desc: "Unicode version (stamps the report; defaults to manifest)"
475
+ def validate(output_root = "./output/universal_glyph_set")
476
+ result = Commands::UniversalSet::ValidateCommand.new.call(
477
+ output_root,
478
+ version_intent: options[:version],
479
+ )
480
+ puts JSON.pretty_generate(result)
481
+ exit 1 unless result[:passed]
482
+ end
483
+ end
484
+
485
+ desc "universal-set", "Build and inspect the universal glyph set reference"
486
+ subcommand "universal-set", UniversalSetCmd
487
+
488
+ # ─────────────── release ───────────────
489
+ desc "release", "Assemble the fontist.org release tree from per-formula audits"
490
+ long_desc <<~LONG
491
+ Walks a directory of per-formula font subdirectories and produces
492
+ the fontist.org-consumable release tree at
493
+ `<output>/font_audit_release/`. The release tree contains:
494
+
495
+ audit/<slug>/<postscript_name>/ — per-face audit subtrees
496
+ universal_glyph_set/ — pre-staged universal set
497
+ library.json — formula + face card index
498
+ manifest.json — versions, sha256s, totals
499
+
500
+ The universal-set directory is NOT copied by this command; the
501
+ CI collector is expected to pre-stage it under
502
+ `<output>/font_audit_release/universal_glyph_set/`.
240
503
  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?
504
+ option :from, type: :string, required: true,
505
+ desc: "Directory of per-formula font subdirectories"
506
+ option :output, type: :string, default: "./output",
507
+ desc: "Parent of the release root"
508
+ option :universal_set, type: :string, default: nil,
509
+ desc: "Path to the universal_glyph_set directory " \
510
+ "(default: <release_root>/universal_glyph_set)"
511
+ option :unicode_version, type: :string, default: nil
512
+ option :brief, type: :boolean, default: false
513
+ option :browse, type: :boolean, default: true,
514
+ desc: "Also write per-face HTML browsers + missing-glyph pages"
515
+ option :source_config_sha256, type: :string, default: nil,
516
+ desc: "sha256 of the Tier 1 source-config YAML"
517
+ option :reference_universal_set, type: :string, default: nil,
518
+ desc: "Path to universal-set manifest (or 'none') " \
519
+ "for the per-face coverage reference"
520
+ def release
521
+ reference = Commands::Audit::ReferenceBuilder.build(
522
+ flag: options[:reference_universal_set],
523
+ version: options[:unicode_version],
524
+ )
525
+ result = Commands::ReleaseCommand.new.call(
526
+ from: options[:from],
527
+ output_root: options[:output],
528
+ universal_set_root: options[:universal_set],
529
+ unicode_version: options[:unicode_version],
530
+ brief: options[:brief],
531
+ browse: options[:browse],
532
+ source_config_sha256: options[:source_config_sha256],
533
+ reference: reference,
534
+ )
535
+ puts JSON.pretty_generate(result_to_h(result))
536
+ end
246
537
 
247
- results = Commands::FontCoverageCommand.new.call(
248
- fonts,
249
- output_root: options[:to],
250
- install: !options[:no_install],
538
+ # ─────────────── block-feed ───────────────
539
+ desc "block-feed", "Emit per-block Unicode data feed from ucode output"
540
+ long_desc <<~LONG
541
+ Translates ucode's canonical output tree into a compact per-block
542
+ Unicode data feed:
543
+
544
+ <target>/unicode-blocks.json
545
+ <target>/unicode-version.json
546
+ <target>/unicode/blocks/<slug>.json
547
+
548
+ Each per-block file contains the codepoints in that block with
549
+ their compact metadata (name, general category, script, combining
550
+ class, bidi class, mirrored flag). Block slugs are derived from
551
+ the block name via the standard slug algorithm.
552
+ LONG
553
+ option :ucode_output, type: :string, default: "./output",
554
+ desc: "ucode's output/ directory"
555
+ option :target, type: :string, default: "./output/block-feed",
556
+ desc: "Target directory for emitted files"
557
+ option :unicode_version, type: :string, default: nil,
558
+ desc: "UCD version stamp (default: from manifest)"
559
+ def block_feed
560
+ result = Commands::BlockFeedCommand.new.call(
561
+ ucode_output_root: options[:ucode_output],
562
+ block_feed_output_root: options[:target],
563
+ unicode_version: options[:unicode_version],
251
564
  )
252
- puts JSON.pretty_generate(results.map { |r| result_to_h(r) })
565
+ puts JSON.pretty_generate(result.to_h)
253
566
  end
254
567
 
255
568
  private
256
569
 
257
570
  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
- }
571
+ return { error: result.error } if result.error
572
+
573
+ result.to_h.compact.transform_values do |v|
574
+ v.is_a?(Struct) ? v.to_h : v
269
575
  end
270
576
  end
271
577
  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