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,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Ucode
6
+ module Audit
7
+ module Emitter
8
+ # Pure path conventions for the Mode 2 audit output tree.
9
+ #
10
+ # The only code that knows the on-disk layout of the audit output.
11
+ # Distinct from {Ucode::Repo::Paths} (Mode 1 canonical UCD dataset):
12
+ # Mode 2 output lives under `output/font_audit/<label>/` and carries
13
+ # a different chunk layout (planes/, blocks/, scripts/, codepoints/,
14
+ # glyphs/, missing/, plus collection-face subdirs).
15
+ #
16
+ # All methods are pure: no I/O, no global state. Returns Pathname
17
+ # instances so callers can compose further. Block names are passed
18
+ # through verbatim — never slugified (per `03-directory-output-spec.md`
19
+ # §"Block filename encoding").
20
+ module Paths
21
+ INDEX_FILENAME = "index.json"
22
+ HTML_FILENAME = "index.html"
23
+ BLOCKS_DIR = "blocks"
24
+ PLANES_DIR = "planes"
25
+ SCRIPTS_DIR = "scripts"
26
+ CODEPOINTS_DIR = "codepoints"
27
+ GLYPHS_DIR = "glyphs"
28
+ MISSING_DIR = "missing"
29
+ FONT_AUDIT_ROOT = "font_audit"
30
+ # Release-tree layout (TODO 27). The release tree is the
31
+ # fontist.org-consumable artifact assembled from one or more
32
+ # per-formula library audits plus the universal-set reference.
33
+ # Lives at `<output_root>/font_audit_release/`.
34
+ RELEASE_ROOT_DIR = "font_audit_release"
35
+ RELEASE_AUDIT_DIR = "audit"
36
+ RELEASE_LIBRARY_INDEX = "library.json"
37
+ RELEASE_MANIFEST = "manifest.json"
38
+ RELEASE_UNIVERSAL_SET_DIR = "universal_glyph_set"
39
+ RELEASE_MANIFEST_ENTRY = "manifest.json"
40
+ private_constant :INDEX_FILENAME, :HTML_FILENAME, :BLOCKS_DIR,
41
+ :PLANES_DIR, :SCRIPTS_DIR, :CODEPOINTS_DIR,
42
+ :GLYPHS_DIR, :MISSING_DIR, :FONT_AUDIT_ROOT,
43
+ :RELEASE_ROOT_DIR, :RELEASE_AUDIT_DIR,
44
+ :RELEASE_LIBRARY_INDEX, :RELEASE_MANIFEST,
45
+ :RELEASE_UNIVERSAL_SET_DIR, :RELEASE_MANIFEST_ENTRY
46
+
47
+ module_function
48
+
49
+ # Library-mode root: one level above the per-label directories.
50
+ # @param output_root [String, Pathname]
51
+ # @return [Pathname]
52
+ def library_root(output_root)
53
+ Pathname(output_root).join(FONT_AUDIT_ROOT)
54
+ end
55
+
56
+ # Per-label directory (one face, or one TTC source).
57
+ # @param output_root [String, Pathname]
58
+ # @param label [String] safe filename (caller-sanitized)
59
+ # @return [Pathname]
60
+ def face_dir(output_root, label)
61
+ library_root(output_root).join(label)
62
+ end
63
+
64
+ # `output/font_audit/<label>/index.json` — per-face compact index.
65
+ # @param output_root [String, Pathname]
66
+ # @param label [String]
67
+ # @return [Pathname]
68
+ def face_index_path(output_root, label)
69
+ face_dir(output_root, label).join(INDEX_FILENAME)
70
+ end
71
+
72
+ # `output/font_audit/<label>/index.html` — per-face browser
73
+ # (added in TODO 14).
74
+ # @param output_root [String, Pathname]
75
+ # @param label [String]
76
+ # @return [Pathname]
77
+ def face_html_path(output_root, label)
78
+ face_dir(output_root, label).join(HTML_FILENAME)
79
+ end
80
+
81
+ # `output/font_audit/<label>/blocks/<NAME>.json`. Block name is
82
+ # verbatim — Unicode block names contain no path separators.
83
+ # @param output_root [String, Pathname]
84
+ # @param label [String]
85
+ # @param block_name [String]
86
+ # @return [Pathname]
87
+ def block_path(output_root, label, block_name)
88
+ face_dir(output_root, label).join(BLOCKS_DIR, "#{block_name}.json")
89
+ end
90
+
91
+ # `output/font_audit/<label>/planes/<N>.json`.
92
+ # @param output_root [String, Pathname]
93
+ # @param label [String]
94
+ # @param plane [Integer]
95
+ # @return [Pathname]
96
+ def plane_path(output_root, label, plane)
97
+ face_dir(output_root, label).join(PLANES_DIR, "#{plane}.json")
98
+ end
99
+
100
+ # `output/font_audit/<label>/scripts/<CODE>.json`. Script code
101
+ # is the ISO 15924 short form (Latn, Grek, …).
102
+ # @param output_root [String, Pathname]
103
+ # @param label [String]
104
+ # @param script_code [String]
105
+ # @return [Pathname]
106
+ def script_path(output_root, label, script_code)
107
+ face_dir(output_root, label).join(SCRIPTS_DIR, "#{script_code}.json")
108
+ end
109
+
110
+ # `output/font_audit/<label>/codepoints/<NAME>.json` — verbose
111
+ # per-block codepoint detail.
112
+ # @param output_root [String, Pathname]
113
+ # @param label [String]
114
+ # @param block_name [String]
115
+ # @return [Pathname]
116
+ def codepoints_path(output_root, label, block_name)
117
+ face_dir(output_root, label).join(CODEPOINTS_DIR, "#{block_name}.json")
118
+ end
119
+
120
+ # `output/font_audit/<label>/glyphs/U+XXXX.svg`.
121
+ # @param output_root [String, Pathname]
122
+ # @param label [String]
123
+ # @param cp_id [String] e.g. "U+0041"
124
+ # @return [Pathname]
125
+ def glyph_path(output_root, label, cp_id)
126
+ face_dir(output_root, label).join(GLYPHS_DIR, "#{cp_id}.svg")
127
+ end
128
+
129
+ # `output/font_audit/<label>/missing/` — per-block missing-glyph
130
+ # gallery directory (TODO 26). Each touched block with missing
131
+ # codepoints gets one `<BLOCK>.html` plus a paginated
132
+ # `<BLOCK>.json` companion for large blocks.
133
+ # @param output_root [String, Pathname]
134
+ # @param label [String]
135
+ # @return [Pathname]
136
+ def missing_dir(output_root, label)
137
+ face_dir(output_root, label).join(MISSING_DIR)
138
+ end
139
+
140
+ # `output/font_audit/<label>/missing/<BLOCK>.html`.
141
+ # @param output_root [String, Pathname]
142
+ # @param label [String]
143
+ # @param block_name [String]
144
+ # @return [Pathname]
145
+ def missing_glyph_page_path(output_root, label, block_name)
146
+ missing_dir(output_root, label).join("#{block_name}.html")
147
+ end
148
+
149
+ # Collection-face subdirectory: `00-<face>/`, `01-<face>/`, ...
150
+ # The 2-digit zero-padded prefix preserves source order and
151
+ # disambiguates faces that share a PostScript name.
152
+ # @param output_root [String, Pathname]
153
+ # @param source_label [String]
154
+ # @param face_index [Integer] 0-based face index
155
+ # @param face_label [String] sanitized postscript_name
156
+ # @return [Pathname]
157
+ def collection_face_dir(output_root, source_label, face_index, face_label)
158
+ face_dir(output_root, source_label).join(format("%<idx>02d-%<label>s",
159
+ idx: face_index, label: face_label))
160
+ end
161
+
162
+ # `output/font_audit/index.json` — library-mode top-level index.
163
+ # @param output_root [String, Pathname]
164
+ # @return [Pathname]
165
+ def library_index_path(output_root)
166
+ library_root(output_root).join(INDEX_FILENAME)
167
+ end
168
+
169
+ # `output/font_audit/index.html` — library browser (TODO 15).
170
+ # @param output_root [String, Pathname]
171
+ # @return [Pathname]
172
+ def library_html_path(output_root)
173
+ library_root(output_root).join(HTML_FILENAME)
174
+ end
175
+
176
+ # ---- Inner-path helpers ----------------------------------------
177
+ # These take an explicit face_dir Pathname so chunk emitters can
178
+ # write under either a standalone face_dir or a collection face
179
+ # subdir without knowing which one they're in.
180
+
181
+ # @param face_dir [String, Pathname]
182
+ # @return [Pathname]
183
+ def index_under(face_dir)
184
+ Pathname(face_dir).join(INDEX_FILENAME)
185
+ end
186
+
187
+ # @param face_dir [String, Pathname]
188
+ # @param block_name [String]
189
+ # @return [Pathname]
190
+ def block_under(face_dir, block_name)
191
+ Pathname(face_dir).join(BLOCKS_DIR, "#{block_name}.json")
192
+ end
193
+
194
+ # @param face_dir [String, Pathname]
195
+ # @param plane [Integer]
196
+ # @return [Pathname]
197
+ def plane_under(face_dir, plane)
198
+ Pathname(face_dir).join(PLANES_DIR, "#{plane}.json")
199
+ end
200
+
201
+ # @param face_dir [String, Pathname]
202
+ # @param script_code [String]
203
+ # @return [Pathname]
204
+ def script_under(face_dir, script_code)
205
+ Pathname(face_dir).join(SCRIPTS_DIR, "#{script_code}.json")
206
+ end
207
+
208
+ # @param face_dir [String, Pathname]
209
+ # @param block_name [String]
210
+ # @return [Pathname]
211
+ def codepoints_under(face_dir, block_name)
212
+ Pathname(face_dir).join(CODEPOINTS_DIR, "#{block_name}.json")
213
+ end
214
+
215
+ # @param face_dir [String, Pathname]
216
+ # @param cp_id [String] e.g. "U+0041"
217
+ # @return [Pathname]
218
+ def glyph_under(face_dir, cp_id)
219
+ Pathname(face_dir).join(GLYPHS_DIR, "#{cp_id}.svg")
220
+ end
221
+
222
+ # @param face_dir [String, Pathname]
223
+ # @return [Pathname]
224
+ def missing_dir_under(face_dir)
225
+ Pathname(face_dir).join(MISSING_DIR)
226
+ end
227
+
228
+ # @param face_dir [String, Pathname]
229
+ # @param block_name [String]
230
+ # @return [Pathname]
231
+ def missing_glyph_page_under(face_dir, block_name)
232
+ missing_dir_under(face_dir).join("#{block_name}.html")
233
+ end
234
+
235
+ # ---- Release-tree paths (TODO 27) -----------------------------
236
+ # The release tree is the fontist.org-consumable artifact. It
237
+ # composes per-formula audit subtrees (each laid out per the
238
+ # `<output_root>/font_audit/<label>/` convention) under an
239
+ # outer `<release_root>/audit/<slug>/` root, plus the universal
240
+ # glyph set, library-level index, and release manifest.
241
+ #
242
+ # The release root lives at `<output_root>/font_audit_release/`
243
+ # so a single tarball of `font_audit_release/` is self-contained.
244
+
245
+ # `<output_root>/font_audit_release/`.
246
+ # @param output_root [String, Pathname] parent of the release root
247
+ # @return [Pathname]
248
+ def release_root(output_root)
249
+ Pathname(output_root).join(RELEASE_ROOT_DIR)
250
+ end
251
+
252
+ # `<release_root>/audit/` — top-level audit subtree.
253
+ # @param release_root [String, Pathname]
254
+ # @return [Pathname]
255
+ def release_audit_root(release_root)
256
+ Pathname(release_root).join(RELEASE_AUDIT_DIR)
257
+ end
258
+
259
+ # `<release_root>/audit/<slug>/` — one formula's audit subtree.
260
+ # Per-face directories live under here. The slug is a
261
+ # caller-sanitized formula identifier (fontist formula slug).
262
+ # @param release_root [String, Pathname]
263
+ # @param slug [String] sanitized formula slug
264
+ # @return [Pathname]
265
+ def release_formula_dir(release_root, slug)
266
+ release_audit_root(release_root).join(slug)
267
+ end
268
+
269
+ # `<release_root>/audit/<slug>/<face_label>/` — one face.
270
+ # @param release_root [String, Pathname]
271
+ # @param slug [String] sanitized formula slug
272
+ # @param face_label [String] sanitized face label
273
+ # @return [Pathname]
274
+ def release_face_dir(release_root, slug, face_label)
275
+ release_formula_dir(release_root, slug).join(face_label)
276
+ end
277
+
278
+ # `<release_root>/library.json` — release-level library index
279
+ # aggregating every formula + face card.
280
+ # @param release_root [String, Pathname]
281
+ # @return [Pathname]
282
+ def release_library_index_path(release_root)
283
+ Pathname(release_root).join(RELEASE_LIBRARY_INDEX)
284
+ end
285
+
286
+ # `<release_root>/manifest.json` — release manifest (versions,
287
+ # sha256s, totals).
288
+ # @param release_root [String, Pathname]
289
+ # @return [Pathname]
290
+ def release_manifest_path(release_root)
291
+ Pathname(release_root).join(RELEASE_MANIFEST)
292
+ end
293
+
294
+ # `<release_root>/universal_glyph_set/` — the universal-set
295
+ # reference directory (built separately by TODO 24 and copied
296
+ # or symlinked into the release tree by the CI collector).
297
+ # @param release_root [String, Pathname]
298
+ # @return [Pathname]
299
+ def release_universal_set_root(release_root)
300
+ Pathname(release_root).join(RELEASE_UNIVERSAL_SET_DIR)
301
+ end
302
+
303
+ # `<release_root>/universal_glyph_set/manifest.json`.
304
+ # @param release_root [String, Pathname]
305
+ # @return [Pathname]
306
+ def release_universal_set_manifest_path(release_root)
307
+ release_universal_set_root(release_root).join(RELEASE_MANIFEST_ENTRY)
308
+ end
309
+ end
310
+ end
311
+ end
312
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require "ucode/repo/atomic_writes"
6
+ require "ucode/audit/emitter/paths"
7
+
8
+ module Ucode
9
+ module Audit
10
+ module Emitter
11
+ # Writes `<face_dir>/planes/<N>.json` — one rollup per Unicode
12
+ # plane that has any coverage.
13
+ #
14
+ # The browser fetches these when the user switches to a
15
+ # plane-grouped view; cheaper than iterating every block.
16
+ class PlaneEmitter
17
+ include Ucode::Repo::AtomicWrites
18
+
19
+ # @param face_dir [String, Pathname]
20
+ # @param plane [Models::Audit::PlaneSummary]
21
+ # @return [Boolean] true if written, false if skipped
22
+ def emit(face_dir, plane)
23
+ write_atomic(Paths.plane_under(face_dir, plane.plane),
24
+ to_pretty_json(plane.to_hash))
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require "ucode/repo/atomic_writes"
6
+ require "ucode/audit/emitter/paths"
7
+
8
+ module Ucode
9
+ module Audit
10
+ module Emitter
11
+ # Writes `<face_dir>/scripts/<CODE>.json` — one rollup per ISO
12
+ # 15924 script code (Latn, Grek, Hani, …).
13
+ #
14
+ # The browser fetches these when the user switches to a
15
+ # script-grouped view; cheaper than iterating every block.
16
+ class ScriptEmitter
17
+ include Ucode::Repo::AtomicWrites
18
+
19
+ # @param face_dir [String, Pathname]
20
+ # @param script [Models::Audit::ScriptSummary]
21
+ # @return [Boolean] true if written, false if skipped
22
+ def emit(face_dir, script)
23
+ write_atomic(Paths.script_under(face_dir, script.script_code),
24
+ to_pretty_json(script.to_hash))
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ucode
4
+ module Audit
5
+ # Mode 2 output writers: turn an in-memory {Models::Audit::AuditReport}
6
+ # (or {Models::Audit::LibrarySummary}) into the on-disk directory tree
7
+ # documented in `TODO.new/03-directory-output-spec.md`.
8
+ #
9
+ # The emitter layer is pure I/O — no audit logic, no font parsing. Every
10
+ # emitter writes one chunk kind and is idempotent via
11
+ # {Ucode::Repo::AtomicWrites} (content-hash compare, then atomic rename).
12
+ #
13
+ # Top-level orchestrator: {Emitter::FaceDirectory}. Per-chunk emitters
14
+ # are wired together by it; callers should never instantiate the chunk
15
+ # emitters directly.
16
+ module Emitter
17
+ autoload :Paths, "ucode/audit/emitter/paths"
18
+ autoload :IndexEmitter, "ucode/audit/emitter/index_emitter"
19
+ autoload :BlockEmitter, "ucode/audit/emitter/block_emitter"
20
+ autoload :PlaneEmitter, "ucode/audit/emitter/plane_emitter"
21
+ autoload :ScriptEmitter, "ucode/audit/emitter/script_emitter"
22
+ autoload :CodepointEmitter, "ucode/audit/emitter/codepoint_emitter"
23
+ autoload :GlyphEmitter, "ucode/audit/emitter/glyph_emitter"
24
+ autoload :CollectionEmitter, "ucode/audit/emitter/collection_emitter"
25
+ autoload :LibraryEmitter, "ucode/audit/emitter/library_emitter"
26
+ autoload :FaceDirectory, "ucode/audit/emitter/face_directory"
27
+ end
28
+ end
29
+ end
@@ -18,6 +18,12 @@ module Ucode
18
18
  # ucode's own SQLite-backed Database. The Database exposes
19
19
  # `lookup_block`, `lookup_script`, `block_ranges_by_name`, and
20
20
  # `script_ranges_by_name` — those power every aggregation here.
21
+ #
22
+ # TODO 25: the BlockAggregator now takes a {CoverageReference}
23
+ # rather than a raw Database. The Context supplies one —
24
+ # UcdOnlyReference by default, UniversalSetReference when a
25
+ # universal-set manifest is supplied via the CLI
26
+ # (`--reference-universal-set=<path>`).
21
27
  class Aggregations < Base
22
28
  # @param context [Ucode::Audit::Context]
23
29
  # @return [Hash{Symbol=>Object}]
@@ -26,14 +32,15 @@ module Ucode
26
32
  return empty_with_warning(baseline) unless baseline.available?
27
33
 
28
34
  codepoints = context.codepoints
29
- blocks = BlockAggregator.new(baseline.database).call(codepoints)
35
+ reference = context.reference
36
+ blocks = BlockAggregator.new(reference).call(codepoints)
30
37
  scripts = ScriptAggregator.new(baseline.database).call(codepoints)
31
38
  planes = PlaneAggregator.new.call(blocks)
32
39
  discrepancies = DiscrepancyDetector.new(**os2_args(context))
33
40
  .call
34
41
 
35
42
  {
36
- baseline: baseline.metadata,
43
+ baseline: baseline_metadata(baseline, reference),
37
44
  blocks: blocks,
38
45
  scripts: scripts,
39
46
  plane_summaries: planes,
@@ -43,6 +50,28 @@ module Ucode
43
50
 
44
51
  private
45
52
 
53
+ # Merge reference provenance (e.g. source_config_sha256,
54
+ # reference_kind) into the baseline metadata so the report's
55
+ # `baseline` block self-describes which reference produced
56
+ # the per-block counts. For UcdOnlyReference this is a no-op.
57
+ def baseline_metadata(baseline, reference)
58
+ return baseline.metadata unless reference.is_a?(UniversalSetReference)
59
+
60
+ merge_universal_set_metadata(baseline.metadata, reference)
61
+ end
62
+
63
+ def merge_universal_set_metadata(metadata, reference)
64
+ extra = reference.baseline_metadata
65
+ metadata.class.new(
66
+ unicode_version: extra["unicode_version"] || metadata.unicode_version,
67
+ ucode_version: extra["ucode_version"] || metadata.ucode_version,
68
+ fontisan_version: metadata.fontisan_version,
69
+ source: metadata.source,
70
+ generated_at: metadata.generated_at,
71
+ reference_kind: "universal-set",
72
+ )
73
+ end
74
+
46
75
  def empty_with_warning(baseline)
47
76
  {
48
77
  baseline: baseline.metadata,
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fontisan"
4
+
5
+ module Ucode
6
+ module Audit
7
+ # Per-face orchestrator: takes a font path, runs every Extractor in
8
+ # the {Registry}, and assembles a single {Models::Audit::AuditReport}.
9
+ #
10
+ # For standalone fonts (TTF/OTF/WOFF/WOFF2) #call returns one
11
+ # AuditReport. For collections (TTC/OTC/dfont) it returns
12
+ # Array<AuditReport> — one per face, in source order.
13
+ #
14
+ # Extracted as its own class so {LibraryAuditor} (per-file iteration)
15
+ # and the future CLI AuditCommand (single face) share one orchestration
16
+ # path. Neither caller enumerates extractors directly — they go
17
+ # through this class and the {Registry}.
18
+ class FaceAuditor
19
+ # @param font_path [String, Pathname] font file to audit
20
+ # @param options [Hash{Symbol=>Object}] forwarded to {Context}
21
+ # (ucd_version, all_codepoints, with_glyphs, audit_brief, …)
22
+ # @param mode [Symbol] :full (default) or :brief
23
+ # @param font_index [Integer, nil] when set and the source is a
24
+ # collection (TTC/OTC/dfong), audit only that face index and
25
+ # return a single AuditReport. Ignored for single-face sources.
26
+ # @param reference [CoverageReference, nil] the baseline the
27
+ # audit compares against. When nil, defaults to UCD-only
28
+ # (TODO 25). Pass a {UniversalSetReference} to attach
29
+ # per-codepoint provenance to missing-codepoint rows.
30
+ def initialize(font_path, options: {}, mode: :full, font_index: nil,
31
+ reference: nil)
32
+ @font_path = font_path.to_s
33
+ @options = options
34
+ @mode = mode
35
+ @font_index = font_index
36
+ @reference = reference
37
+ end
38
+
39
+ # @return [Models::Audit::AuditReport, Array<Models::Audit::AuditReport>]
40
+ def call
41
+ if Fontisan::FontLoader.collection?(@font_path)
42
+ audit_collection
43
+ else
44
+ audit_face(load_face(0), 0, 1)
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def audit_collection
51
+ collection = Fontisan::FontLoader.load_collection(@font_path)
52
+ num = collection.num_fonts
53
+ indices = @font_index ? [@font_index] : (0...num).to_a
54
+ results = indices.map do |index|
55
+ font = Fontisan::FontLoader.load(@font_path, font_index: index)
56
+ audit_face(font, index, num)
57
+ end
58
+ @font_index ? results.first : results
59
+ end
60
+
61
+ def audit_face(font, font_index, num_fonts_in_source)
62
+ context = Context.new(
63
+ font: font,
64
+ font_path: @font_path,
65
+ font_index: font_index,
66
+ num_fonts_in_source: num_fonts_in_source,
67
+ options: @options,
68
+ reference: @reference,
69
+ )
70
+
71
+ fields = {}
72
+ Registry.each(mode: @mode) do |extractor_class|
73
+ fields.merge!(extractor_class.new.extract(context))
74
+ end
75
+
76
+ fields[:warning] = context.baseline.warning
77
+
78
+ Models::Audit::AuditReport.new(**fields)
79
+ end
80
+
81
+ def load_face(_index)
82
+ Fontisan::FontLoader.load(@font_path)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ucode
4
+ module Audit
5
+ module Formatters
6
+ # Human-readable diff of two {Models::Audit::AuditReport}s.
7
+ #
8
+ # Output groups changes by kind: scalar field changes, codepoint
9
+ # set deltas (added/removed counts and a preview of the ranges),
10
+ # then structural inventory changes (scripts, features, blocks).
11
+ # Empty sections are omitted so a no-op diff prints only the
12
+ # header and a "(no differences)" footer.
13
+ #
14
+ # ucode delta vs fontisan's AuditDiffTextRenderer: drops the
15
+ # LANGUAGES section (CLDR is out of scope).
16
+ class AuditDiffText
17
+ SEPARATOR = "=" * 80
18
+ LIST_LIMIT = 10
19
+
20
+ # @param diff [Models::Audit::AuditDiff]
21
+ def initialize(diff)
22
+ @diff = diff
23
+ @lines = []
24
+ @helper = TextFormatter.new
25
+ end
26
+
27
+ # @return [String]
28
+ def render
29
+ render_header
30
+ render_field_changes
31
+ render_codepoint_delta
32
+ render_structural_changes
33
+ render_empty_note
34
+ @lines.join("\n")
35
+ end
36
+
37
+ private
38
+
39
+ def render_header
40
+ @lines << Color.bold("AUDIT DIFF")
41
+ @lines << Color.dim(SEPARATOR)
42
+ @lines << " left: #{@diff.left_source}"
43
+ @lines << " right: #{@diff.right_source}"
44
+ end
45
+
46
+ def render_field_changes
47
+ changes = Array(@diff.field_changes)
48
+ return if changes.empty?
49
+
50
+ section("FIELD CHANGES (#{changes.size})")
51
+ changes.each do |change|
52
+ @lines << " #{change.field}: #{change.left.inspect} → #{change.right.inspect}"
53
+ end
54
+ end
55
+
56
+ def render_codepoint_delta
57
+ delta = @diff.codepoints
58
+ return unless delta && (delta.added_count.to_i.positive? || delta.removed_count.to_i.positive?)
59
+
60
+ section("CODEPOINT COVERAGE")
61
+ @lines << " added: #{delta.added_count}"
62
+ @lines << " removed: #{delta.removed_count}"
63
+ @lines << " unchanged: #{delta.unchanged_count}"
64
+ preview_added(delta)
65
+ preview_removed(delta)
66
+ end
67
+
68
+ def preview_added(delta)
69
+ ranges = Array(delta.added)
70
+ return if ranges.empty?
71
+
72
+ @lines << " + #{@helper.truncate_ranges(ranges)}"
73
+ end
74
+
75
+ def preview_removed(delta)
76
+ ranges = Array(delta.removed)
77
+ return if ranges.empty?
78
+
79
+ @lines << " - #{@helper.truncate_ranges(ranges)}"
80
+ end
81
+
82
+ def render_structural_changes
83
+ render_set("SCRIPTS", @diff.added_scripts, @diff.removed_scripts)
84
+ render_set("FEATURES", @diff.added_features, @diff.removed_features)
85
+ render_set("BLOCKS", @diff.added_blocks, @diff.removed_blocks)
86
+ end
87
+
88
+ def render_set(name, added, removed)
89
+ added = Array(added)
90
+ removed = Array(removed)
91
+ return if added.empty? && removed.empty?
92
+
93
+ section("#{name} CHANGES")
94
+ @lines << " + #{@helper.truncate_list(added)}" unless added.empty?
95
+ @lines << " - #{@helper.truncate_list(removed)}" unless removed.empty?
96
+ end
97
+
98
+ def render_empty_note
99
+ return unless @diff.empty?
100
+
101
+ @lines << ""
102
+ @lines << "(no differences)"
103
+ end
104
+
105
+ def section(title)
106
+ @lines << ""
107
+ @lines << Color.bold(title)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end