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,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "json"
5
+
6
+ require "ucode/audit"
7
+ require "ucode/audit/differ"
8
+ require "ucode/audit/face_auditor"
9
+ require "ucode/audit/formatters"
10
+ require "ucode/glyphs/real_fonts"
11
+
12
+ module Ucode
13
+ module Commands
14
+ module Audit
15
+ # `ucode audit compare LEFT RIGHT` — diff two audits.
16
+ #
17
+ # Each of LEFT and RIGHT can be:
18
+ # - A path to a font file (audited on-the-fly with
19
+ # {Audit::FaceAuditor}).
20
+ # - A path to a face audit directory — its `index.json` is
21
+ # read for the precomputed report shape.
22
+ # - A path to a saved `index.json` file directly.
23
+ #
24
+ # Note: reading from disk only recovers the *derived* overview
25
+ # shape from {Emitter::IndexEmitter}, not a full AuditReport.
26
+ # The compare therefore uses the field subset that the
27
+ # overview shape preserves (identity + coverage totals). For a
28
+ # full-feature diff, audit both inputs fresh from their font
29
+ # paths.
30
+ class CompareCommand
31
+ Result = Struct.new(:left_source, :right_source, :diff, :text,
32
+ :error, keyword_init: true)
33
+
34
+ # @param left [String] font path | audit dir | index.json path
35
+ # @param right [String] same forms as left
36
+ # @param unicode_version [String, nil]
37
+ # @param output_file [String, Pathname, nil] write text to file
38
+ # (default: stdout, captured as `text` in the result)
39
+ # @return [Result]
40
+ def call(left, right, unicode_version: nil, output_file: nil)
41
+ left_report = load_or_audit(left, unicode_version: unicode_version)
42
+ right_report = load_or_audit(right, unicode_version: unicode_version)
43
+
44
+ diff = Ucode::Audit::Differ.new(left_report, right_report).diff
45
+ text = Ucode::Audit::Formatters::AuditDiffText.new(diff).render
46
+
47
+ write_output(text, output_file) if output_file
48
+
49
+ Result.new(left_source: left, right_source: right, diff: diff,
50
+ text: text)
51
+ rescue StandardError => e
52
+ Result.new(left_source: left, right_source: right,
53
+ error: "#{e.class}: #{e.message}")
54
+ end
55
+
56
+ private
57
+
58
+ def load_or_audit(spec, unicode_version:)
59
+ case resolve_kind(spec)
60
+ when :font_file then audit_freshly(spec, unicode_version: unicode_version)
61
+ when :audit_dir then load_overview(Pathname.new(spec).join("index.json"))
62
+ when :index_json then load_overview(Pathname.new(spec))
63
+ end
64
+ end
65
+
66
+ # Heuristic: a path is an index.json if it ends in `.json`;
67
+ # an audit directory if it doesn't end in `.json` and isn't
68
+ # a font file; a font file otherwise.
69
+ def resolve_kind(spec)
70
+ path = Pathname.new(spec)
71
+ return :index_json if path.file? && path.extname == ".json"
72
+ return :audit_dir if path.directory?
73
+ return :audit_dir if path.join("index.json").exist?
74
+
75
+ :font_file
76
+ end
77
+
78
+ def audit_freshly(font_path, unicode_version:)
79
+ options = {}
80
+ options[:ucd_version] = unicode_version if unicode_version
81
+ Ucode::Audit::FaceAuditor.new(font_path.to_s, options: options).call
82
+ end
83
+
84
+ # Reconstructs a partial AuditReport from the derived overview
85
+ # shape. Only the fields Differ consults are populated; others
86
+ # are nil. This is best-effort — see class docs.
87
+ def load_overview(index_json_path)
88
+ hash = JSON.parse(index_json_path.read)
89
+ font = hash["font"] || {}
90
+ totals = hash["totals"] || {}
91
+ Models::Audit::AuditReport.new(
92
+ source_file: font["source_file"],
93
+ source_sha256: font["source_sha256"],
94
+ family_name: font["family_name"],
95
+ subfamily_name: font["subfamily_name"],
96
+ full_name: font["full_name"],
97
+ postscript_name: font["postscript_name"],
98
+ version: font["version"],
99
+ font_revision: font["font_revision"],
100
+ weight_class: font["weight_class"],
101
+ width_class: font["width_class"],
102
+ italic: font["italic"],
103
+ bold: font["bold"],
104
+ panose: font["panose"],
105
+ total_codepoints: totals["covered_codepoints_total"] || font["total_codepoints"],
106
+ total_glyphs: font["total_glyphs"],
107
+ codepoint_ranges: codepoint_ranges(font["codepoint_ranges"]),
108
+ scripts: scripts(hash["script_summaries"]),
109
+ blocks: blocks(hash["block_summaries"]),
110
+ baseline: baseline(hash["baseline"]),
111
+ plane_summaries: plane_summaries(hash["plane_summaries"]),
112
+ discrepancies: [],
113
+ )
114
+ end
115
+
116
+ def codepoint_ranges(arr)
117
+ return [] unless arr
118
+
119
+ arr.map do |h|
120
+ Models::Audit::CodepointRange.new(
121
+ first_cp: h["first_cp"], last_cp: h["last_cp"],
122
+ )
123
+ end
124
+ end
125
+
126
+ def scripts(arr)
127
+ return [] unless arr
128
+
129
+ arr.map do |h|
130
+ Models::Audit::ScriptSummary.new(
131
+ script_code: h["script_code"], script_name: h["script_name"],
132
+ blocks_total: h["blocks_total"], assigned_total: h["assigned_total"],
133
+ covered_total: h["covered_total"], coverage_percent: h["coverage_percent"],
134
+ status: h["status"],
135
+ )
136
+ end
137
+ end
138
+
139
+ def blocks(arr)
140
+ return [] unless arr
141
+
142
+ arr.map do |h|
143
+ Models::Audit::BlockSummary.new(
144
+ name: h["name"], first_cp: h["first_cp"], last_cp: h["last_cp"],
145
+ range: h["range"], plane: h["plane"],
146
+ total_assigned: h["total_assigned"],
147
+ covered_count: h["covered_count"],
148
+ missing_count: h["missing_count"],
149
+ coverage_percent: h["coverage_percent"],
150
+ status: h["status"],
151
+ )
152
+ end
153
+ end
154
+
155
+ def baseline(hash)
156
+ return nil unless hash
157
+
158
+ Models::Audit::Baseline.new(
159
+ unicode_version: hash["unicode_version"],
160
+ ucode_version: hash["ucode_version"],
161
+ fontisan_version: hash["fontisan_version"],
162
+ source: hash["source"],
163
+ generated_at: hash["generated_at"],
164
+ )
165
+ end
166
+
167
+ def plane_summaries(arr)
168
+ return [] unless arr
169
+
170
+ arr.map do |h|
171
+ Models::Audit::PlaneSummary.new(
172
+ plane: h["plane"], blocks_total: h["blocks_total"],
173
+ assigned_total: h["assigned_total"],
174
+ covered_total: h["covered_total"],
175
+ coverage_percent: h["coverage_percent"],
176
+ )
177
+ end
178
+ end
179
+
180
+ def write_output(text, target)
181
+ path = Pathname.new(target)
182
+ path.dirname.mkpath
183
+ path.write(text)
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "fileutils"
5
+
6
+ require "ucode/glyphs/real_fonts"
7
+ require "ucode/audit"
8
+ require "ucode/audit/face_auditor"
9
+ require "ucode/audit/emitter/face_directory"
10
+ require "ucode/audit/emitter/paths"
11
+
12
+ module Ucode
13
+ module Commands
14
+ module Audit
15
+ # `ucode audit font PATH` — audit a single font file or
16
+ # fontist-resolvable name. Writes the per-face directory tree
17
+ # under `<output_root>/font_audit/<label>/`.
18
+ #
19
+ # Auto-detects collection sources (TTC/OTC/dfong) and falls
20
+ # through to collection-style emission in that case — one tree
21
+ # per face. For an explicit, intent-revealing form, use
22
+ # {CollectionCommand}.
23
+ #
24
+ # Pure: Thor never touches this. Real work is delegated to
25
+ # {Glyphs::RealFonts::FontLocator} (resolve spec → path),
26
+ # {Audit::FaceAuditor} (build report), and
27
+ # {Audit::Emitter::FaceDirectory} (write tree).
28
+ class FontCommand
29
+ FaceOutcome = Struct.new(:label, :postscript_name, :output_dir,
30
+ keyword_init: true)
31
+
32
+ Result = Struct.new(:spec, :label, :output_dir, :faces, :error,
33
+ keyword_init: true)
34
+
35
+ # @param spec [String] font spec — direct path, or `label=path`,
36
+ # or a fontist formula name.
37
+ # @param label [String, nil] output label override. Defaults
38
+ # to the report's postscript_name, or the file basename.
39
+ # @param unicode_version [String, nil] baseline UCD version.
40
+ # @param verbose [Boolean] emit per-codepoint detail chunks.
41
+ # @param with_glyphs [Boolean] emit per-codepoint SVG chunks
42
+ # (no-op until TODO 20 wires the 4-tier resolver).
43
+ # @param brief [Boolean] cheap-extractor-only mode.
44
+ # @param output_root [String, Pathname] parent directory; the
45
+ # audit root is `<output_root>/font_audit`.
46
+ # @param browse [Boolean] also write the HTML browsers.
47
+ # @param install [Boolean] allow fontist install on miss.
48
+ # @param reference [Ucode::Audit::CoverageReference, nil] the
49
+ # baseline to compare against (TODO 25). When nil, defaults
50
+ # to UCD-only inside {FaceAuditor}.
51
+ # @param universal_set_root [String, Pathname, nil] forwarded
52
+ # to {Emitter::FaceDirectory} for the face browser's
53
+ # universal-set section (TODO 26).
54
+ # @param with_missing_glyph_pages [Boolean] forward per-block
55
+ # standalone missing-glyph galleries (TODO 26).
56
+ # @return [Result]
57
+ def call(spec, output_root:, label: nil, unicode_version: nil, verbose: false,
58
+ with_glyphs: false, brief: false, browse: false,
59
+ install: true, reference: nil,
60
+ universal_set_root: nil, with_missing_glyph_pages: false)
61
+ located = locate(spec, install: install)
62
+ reports = Array(audit_faces(located.path, unicode_version: unicode_version,
63
+ brief: brief, reference: reference))
64
+
65
+ face_label = label || derived_face_label(reports.first, located)
66
+ sanitized = sanitize(face_label)
67
+
68
+ directory = Ucode::Audit::Emitter::FaceDirectory.new(
69
+ output_root: output_root,
70
+ verbose: verbose,
71
+ with_glyphs: with_glyphs,
72
+ emit_browser: browse,
73
+ universal_set_root: universal_set_root,
74
+ with_missing_glyph_pages: with_missing_glyph_pages,
75
+ )
76
+
77
+ face_outcomes, top_dir =
78
+ if reports.one?
79
+ emit_standalone(directory, sanitized, reports.first)
80
+ else
81
+ emit_collection(directory, sanitized, reports, output_root)
82
+ end
83
+
84
+ Result.new(spec: spec, label: face_label, output_dir: top_dir.to_s,
85
+ faces: face_outcomes)
86
+ rescue StandardError => e
87
+ Result.new(spec: spec, error: "#{e.class}: #{e.message}")
88
+ end
89
+
90
+ private
91
+
92
+ def locate(spec, install:)
93
+ Ucode::Glyphs::RealFonts::FontLocator.new.locate(spec, install: install)
94
+ end
95
+
96
+ def audit_faces(path, unicode_version:, brief:, reference: nil)
97
+ options = audit_options(unicode_version: unicode_version, brief: brief)
98
+ mode = brief ? :brief : :full
99
+ Ucode::Audit::FaceAuditor.new(path, options: options, mode: mode,
100
+ reference: reference).call
101
+ end
102
+
103
+ def audit_options(unicode_version:, brief:)
104
+ opts = {}
105
+ opts[:ucd_version] = unicode_version if unicode_version
106
+ opts[:audit_brief] = true if brief
107
+ opts
108
+ end
109
+
110
+ def derived_face_label(report, located)
111
+ report&.postscript_name || located.name || File.basename(located.path.to_s, ".*")
112
+ end
113
+
114
+ def emit_standalone(directory, label, report)
115
+ face_dir = directory.emit_face(label: label, report: report)
116
+ outcome = FaceOutcome.new(label: label,
117
+ postscript_name: report.postscript_name,
118
+ output_dir: face_dir.to_s)
119
+ [[outcome], face_dir]
120
+ end
121
+
122
+ def emit_collection(directory, source_label, reports, output_root)
123
+ subdirs = directory.emit_collection(source_label: source_label,
124
+ reports: reports)
125
+ top = Ucode::Audit::Emitter::Paths.face_dir(output_root, source_label)
126
+ outcomes = reports.zip(subdirs).map do |report, name|
127
+ FaceOutcome.new(label: name,
128
+ postscript_name: report.postscript_name,
129
+ output_dir: top.join(name).to_s)
130
+ end
131
+ [outcomes, top]
132
+ end
133
+
134
+ def sanitize(name)
135
+ (name || "face").to_s.gsub(/[^A-Za-z0-9._-]/, "_")
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require "ucode/audit"
6
+ require "ucode/audit/library_auditor"
7
+ require "ucode/audit/emitter/face_directory"
8
+ require "ucode/audit/emitter/paths"
9
+
10
+ module Ucode
11
+ module Commands
12
+ module Audit
13
+ # `ucode audit library DIR` — walk a directory of fonts and
14
+ # produce one per-face audit plus a library-level rollup.
15
+ #
16
+ # Delegates directory walking + per-face audit to
17
+ # {Audit::LibraryAuditor}, then writes the per-face trees +
18
+ # library-level `index.json` via {Audit::Emitter::FaceDirectory}.
19
+ class LibraryCommand
20
+ SkippedFile = Struct.new(:path, :reason, keyword_init: true)
21
+
22
+ Result = Struct.new(:root, :total_files, :total_faces, :output_dir,
23
+ :skipped, :error, keyword_init: true)
24
+
25
+ # @param dir [String, Pathname] directory containing fonts.
26
+ # @param recursive [Boolean] walk subdirectories.
27
+ # @param unicode_version [String, nil] baseline UCD version.
28
+ # @param verbose [Boolean] per-codepoint detail chunks per face.
29
+ # @param with_glyphs [Boolean] per-codepoint SVG chunks.
30
+ # @param brief [Boolean] cheap-extractor-only mode.
31
+ # @param output_root [String, Pathname] parent of the audit root.
32
+ # @param browse [Boolean] also write library + face HTML browsers.
33
+ # @param reference [Ucode::Audit::CoverageReference, nil] baseline
34
+ # forwarded to every per-face audit (TODO 25).
35
+ # @param universal_set_root [String, Pathname, nil] forwarded to
36
+ # {Emitter::FaceDirectory} for the face browser's
37
+ # universal-set section (TODO 26).
38
+ # @param with_missing_glyph_pages [Boolean] emit per-block
39
+ # standalone missing-glyph galleries per face (TODO 26).
40
+ # @return [Result]
41
+ def call(dir, output_root:, recursive: false, unicode_version: nil, verbose: false,
42
+ with_glyphs: false, brief: false, browse: false, reference: nil,
43
+ universal_set_root: nil, with_missing_glyph_pages: false)
44
+ options = library_options(unicode_version: unicode_version, brief: brief)
45
+ auditor = Ucode::Audit::LibraryAuditor.new(dir, recursive: recursive,
46
+ options: options,
47
+ reference: reference)
48
+ summary = auditor.audit
49
+
50
+ directory = Ucode::Audit::Emitter::FaceDirectory.new(
51
+ output_root: output_root,
52
+ verbose: verbose,
53
+ with_glyphs: with_glyphs,
54
+ emit_browser: browse,
55
+ universal_set_root: universal_set_root,
56
+ with_missing_glyph_pages: with_missing_glyph_pages,
57
+ )
58
+ directory.emit_library(summary: summary)
59
+
60
+ Result.new(
61
+ root: dir.to_s,
62
+ total_files: summary.total_files,
63
+ total_faces: summary.total_faces,
64
+ output_dir: Ucode::Audit::Emitter::Paths.library_root(output_root).to_s,
65
+ skipped: auditor.skipped.map { |s| parse_skipped(s) },
66
+ )
67
+ rescue StandardError => e
68
+ Result.new(root: dir.to_s, error: "#{e.class}: #{e.message}")
69
+ end
70
+
71
+ private
72
+
73
+ def library_options(unicode_version:, brief:)
74
+ opts = {}
75
+ opts[:ucd_version] = unicode_version if unicode_version
76
+ opts[:audit_brief] = true if brief
77
+ opts
78
+ end
79
+
80
+ def parse_skipped(entry)
81
+ path, _, reason = entry.rpartition(": ")
82
+ SkippedFile.new(path: path, reason: reason)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Ucode
6
+ module Commands
7
+ module Audit
8
+ # Translates CLI flags into a {Ucode::Audit::CoverageReference}.
9
+ #
10
+ # The audit CLI exposes the universal-set reference via a
11
+ # `--reference-universal-set=<path>` flag (and a default lookup
12
+ # at `output/universal_glyph_set/manifest.json`). This builder
13
+ # resolves the flag value into a concrete reference instance
14
+ # backed by a freshly-opened {Ucode::Database}, so the command
15
+ # classes don't repeat the same branching.
16
+ #
17
+ # Behavior:
18
+ #
19
+ # - flag = "none" → nil (force UCD-only even if a default manifest exists)
20
+ # - flag = path to .json → {Ucode::Audit::UniversalSetReference}
21
+ # - flag = nil → look at DEFAULT_MANIFEST_PATH; use it if present,
22
+ # else nil (UCD-only)
23
+ module ReferenceBuilder
24
+ DEFAULT_MANIFEST_PATH = Pathname.new("output/universal_glyph_set/manifest.json")
25
+
26
+ module_function
27
+
28
+ # @param flag [String, nil] value of the
29
+ # `--reference-universal-set` CLI option.
30
+ # @param version [String, nil] UCD version for the database
31
+ # that backs the reference. When nil, the default UCD
32
+ # version is resolved.
33
+ # @return [Ucode::Audit::CoverageReference, nil]
34
+ def build(flag:, version: nil)
35
+ return nil if flag == "none"
36
+
37
+ path = resolve_manifest_path(flag)
38
+ return nil unless path && File.exist?(path)
39
+
40
+ database = open_database(version)
41
+ return nil unless database
42
+
43
+ Ucode::Audit::UniversalSetReference.new(
44
+ manifest: path, database: database,
45
+ )
46
+ end
47
+
48
+ def resolve_manifest_path(flag)
49
+ return Pathname.new(flag) if flag && flag != "none"
50
+ return DEFAULT_MANIFEST_PATH if DEFAULT_MANIFEST_PATH.exist?
51
+
52
+ nil
53
+ end
54
+
55
+ def open_database(version)
56
+ resolved = version || Ucode::VersionResolver.resolve(nil)
57
+ Ucode::Database.open(resolved)
58
+ rescue Ucode::UnknownVersionError, Ucode::DatabaseMissingError
59
+ nil
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ucode
4
+ module Commands
5
+ # `ucode audit *` command classes. Pure Ruby — Thor (in
6
+ # `lib/ucode/cli.rb`) is responsible only for argument parsing
7
+ # and dispatch. Each class here is a structured-result command
8
+ # that delegates to the {Ucode::Audit} pipeline
9
+ # ({Audit::FaceAuditor}, {Audit::LibraryAuditor}, {Audit::Differ},
10
+ # {Audit::Emitter::FaceDirectory}, {Audit::Browser::*}).
11
+ module Audit
12
+ autoload :FontCommand, "ucode/commands/audit/font_command"
13
+ autoload :CollectionCommand, "ucode/commands/audit/collection_command"
14
+ autoload :LibraryCommand, "ucode/commands/audit/library_command"
15
+ autoload :CompareCommand, "ucode/commands/audit/compare_command"
16
+ autoload :BrowserCommand, "ucode/commands/audit/browser_command"
17
+ autoload :ReferenceBuilder, "ucode/commands/audit/reference_builder"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require "ucode/repo"
6
+ require "ucode/version_resolver"
7
+
8
+ module Ucode
9
+ module Commands
10
+ # `ucode block-feed` — emit a compact per-block Unicode data feed
11
+ # from ucode's canonical output tree.
12
+ #
13
+ # Reads ucode's `output/` and produces three artifacts at the target
14
+ # directory:
15
+ #
16
+ # <target>/unicode-blocks.json
17
+ # <target>/unicode-version.json
18
+ # <target>/unicode/blocks/<slug>.json
19
+ #
20
+ # Each per-block file contains the codepoints in that block with
21
+ # their compact Unicode metadata (name, general category, script,
22
+ # combining class, bidi class, mirrored flag). Block slugs are
23
+ # derived from the block name via the standard slug algorithm.
24
+ class BlockFeedCommand
25
+ Result = Struct.new(:ucode_output_root, :block_feed_output_root,
26
+ :unicode_version, :blocks_written,
27
+ :codepoints_written, :unicode_blocks_path,
28
+ :unicode_version_path, keyword_init: true)
29
+
30
+ # @param ucode_output_root [String, Pathname] ucode's `output/`
31
+ # (must contain blocks/index.json, blocks/<ID>/index.json,
32
+ # index/labels.json).
33
+ # @param block_feed_output_root [String, Pathname] target directory.
34
+ # @param unicode_version [String, nil] UCD version to stamp on
35
+ # unicode-version.json. Defaults to the version recorded in
36
+ # ucode's manifest.json.
37
+ # @return [Result]
38
+ def call(ucode_output_root:, block_feed_output_root:, unicode_version: nil)
39
+ ucode_root = Pathname.new(ucode_output_root)
40
+ feed_root = Pathname.new(block_feed_output_root)
41
+ version = unicode_version || manifest_version(ucode_root)
42
+
43
+ emitter = Repo::BlockFeedEmitter.new(ucode_root, feed_root)
44
+ outcome = emitter.emit(ucd_version: version)
45
+
46
+ Result.new(
47
+ ucode_output_root: ucode_root.to_s,
48
+ block_feed_output_root: feed_root.to_s,
49
+ unicode_version: version,
50
+ blocks_written: outcome[:blocks_written],
51
+ codepoints_written: outcome[:codepoints_written],
52
+ unicode_blocks_path: outcome[:unicode_blocks_path],
53
+ unicode_version_path: outcome[:unicode_version_path],
54
+ )
55
+ end
56
+
57
+ private
58
+
59
+ def manifest_version(ucode_root)
60
+ manifest = ucode_root.join("manifest.json")
61
+ return default_version unless manifest.exist?
62
+
63
+ JSON.parse(manifest.read)["ucd_version"] || default_version
64
+ rescue JSON::ParserError
65
+ default_version
66
+ end
67
+
68
+ def default_version
69
+ VersionResolver.resolve(nil)
70
+ end
71
+ end
72
+ end
73
+ end