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,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require "ucode/audit/emitter/paths"
6
+ require "ucode/audit/emitter/index_emitter"
7
+ require "ucode/audit/emitter/block_emitter"
8
+ require "ucode/audit/emitter/plane_emitter"
9
+ require "ucode/audit/emitter/script_emitter"
10
+ require "ucode/audit/emitter/codepoint_emitter"
11
+ require "ucode/audit/emitter/glyph_emitter"
12
+ require "ucode/audit/emitter/collection_emitter"
13
+ require "ucode/audit/emitter/library_emitter"
14
+ require "ucode/audit/browser/face_page"
15
+ require "ucode/audit/browser/library_page"
16
+
17
+ module Ucode
18
+ module Audit
19
+ module Emitter
20
+ # Top-level Mode 2 orchestrator. Walks an AuditReport (or a list
21
+ # of reports for a collection, or a library summary) and emits the
22
+ # full directory tree per `03-directory-output-spec.md`.
23
+ #
24
+ # Owns the chunk-emitter composition: callers never touch
25
+ # {IndexEmitter} / {BlockEmitter} / etc. directly. The chunk
26
+ # emitters themselves stay single-purpose (one chunk kind each)
27
+ # and take an explicit `face_dir` Pathname — they don't know
28
+ # whether the face lives at `output/font_audit/<label>/` or under
29
+ # a collection subdir.
30
+ #
31
+ # Three entry points:
32
+ #
33
+ # - {#emit_face} — one standalone face
34
+ # - {#emit_collection} — one TTC source (N sibling faces)
35
+ # - {#emit_library} — directory-mode (M face labels)
36
+ #
37
+ # Idempotency is delegated to each chunk emitter via
38
+ # {Ucode::Repo::AtomicWrites}; re-running the same audit produces
39
+ # zero writes on the second pass.
40
+ class FaceDirectory
41
+ # @param output_root [String, Pathname] top-level output root
42
+ # (e.g. "output"). The library root is `<output_root>/font_audit`.
43
+ # @param verbose [Boolean] emit codepoints/<NAME>.json per block
44
+ # @param with_glyphs [Boolean] emit glyphs/U+XXXX.svg per covered cp
45
+ # @param glyph_resolver [Proc(Integer) -> String, nil] SVG source
46
+ # for {GlyphEmitter}; defaults to a proc that returns nil
47
+ # (no glyphs emitted). Replaced by the canonical 4-tier
48
+ # resolver (TODO 20) when ready.
49
+ # @param database [Ucode::Database, nil] baseline UCD lookup for
50
+ # {CodepointEmitter} enrichment
51
+ # @param emit_browser [Boolean] also write the self-contained
52
+ # HTML browsers — `<face_dir>/index.html` per face and
53
+ # `<library_root>/index.html` for library mode. Default false.
54
+ # @param universal_set_root [String, Pathname, nil] root of a
55
+ # co-located universal-set build. When present and
56
+ # `emit_browser:` is true, the face browser advertises glyph
57
+ # paths in its overview JSON so missing-codepoint chips can
58
+ # render the universal-set glyph at runtime.
59
+ # @param with_missing_glyph_pages [Boolean] emit one standalone
60
+ # `<face_dir>/missing/<BLOCK>.html` per touched block with
61
+ # missing codepoints. Requires `emit_browser:` and a reachable
62
+ # `universal_set_root:` (silently no-ops otherwise).
63
+ def initialize(output_root:, verbose: false, with_glyphs: false,
64
+ glyph_resolver: GlyphEmitter::DEFAULT_RESOLVER,
65
+ database: nil, emit_browser: false,
66
+ universal_set_root: nil, with_missing_glyph_pages: false)
67
+ @output_root = output_root
68
+ @verbose = verbose
69
+ @with_glyphs = with_glyphs
70
+ @emit_browser = emit_browser
71
+ @database = database
72
+ @universal_set_root = universal_set_root
73
+ @with_missing_glyph_pages = with_missing_glyph_pages
74
+ @index_emitter = IndexEmitter.new
75
+ @block_emitter = BlockEmitter.new
76
+ @plane_emitter = PlaneEmitter.new
77
+ @script_emitter = ScriptEmitter.new
78
+ @codepoint_emitter = CodepointEmitter.new
79
+ @glyph_emitter = GlyphEmitter.new(glyph_resolver: glyph_resolver)
80
+ @collection_emitter = CollectionEmitter.new
81
+ @library_emitter = LibraryEmitter.new
82
+ end
83
+
84
+ # @param label [String] sanitized face label (caller-sanitized)
85
+ # @param report [Models::Audit::AuditReport]
86
+ # @return [Pathname] the per-face directory written
87
+ def emit_face(label:, report:)
88
+ emit_face_at(Paths.face_dir(@output_root, label), report)
89
+ end
90
+
91
+ # @param source_label [String] sanitized collection label
92
+ # @param reports [Array<Models::Audit::AuditReport>]
93
+ # @return [Array<String>] per-face subdirectory names
94
+ def emit_collection(source_label:, reports:)
95
+ @collection_emitter.emit(@output_root, source_label, reports,
96
+ face_directory: self)
97
+ end
98
+
99
+ # @param summary [Models::Audit::LibrarySummary]
100
+ # @return [Boolean] true if library index was written
101
+ def emit_library(summary:)
102
+ summary.per_face_reports.each do |report|
103
+ emit_face(label: face_label(report), report: report)
104
+ end
105
+ written = @library_emitter.emit(@output_root, summary)
106
+ emit_library_browser(summary) if @emit_browser
107
+ written
108
+ end
109
+
110
+ # Hook called by {CollectionEmitter} to write one face under a
111
+ # collection root. Computes the per-face subdirectory name from
112
+ # the face_index so the source order is preserved on disk.
113
+ #
114
+ # @api private
115
+ # @param source_label [String]
116
+ # @param face_index [Integer]
117
+ # @param report [Models::Audit::AuditReport]
118
+ # @return [String] the per-face subdirectory name (e.g. "00-Mona")
119
+ def emit_collection_face(source_label:, face_index:, report:)
120
+ face_label = format("%<idx>02d-%<label>s",
121
+ idx: face_index,
122
+ label: sanitize(report.postscript_name))
123
+ emit_face_at(
124
+ Paths.collection_face_dir(@output_root, source_label, face_index,
125
+ sanitize(report.postscript_name)),
126
+ report,
127
+ )
128
+ face_label
129
+ end
130
+
131
+ # Write one face's full chunk tree under an explicit face_dir.
132
+ #
133
+ # Public entry point for callers that compute their own face_dir
134
+ # (e.g. {Ucode::Audit::Release::Emitter} builds the release tree
135
+ # at `<release_root>/audit/<slug>/<face>/`). Callers that want
136
+ # the default library-mode layout should use {#emit_face}.
137
+ #
138
+ # @param face_dir [String, Pathname] explicit per-face directory
139
+ # @param report [Models::Audit::AuditReport]
140
+ # @return [Pathname] the face directory written
141
+ def emit_face_at(face_dir, report)
142
+ @index_emitter.emit(face_dir, report, universal_set_root: @universal_set_root)
143
+ report.blocks.each { |b| @block_emitter.emit(face_dir, b) }
144
+ report.plane_summaries.each { |p| @plane_emitter.emit(face_dir, p) }
145
+ report.scripts.each { |s| @script_emitter.emit(face_dir, s) }
146
+ emit_codepoints(face_dir, report) if @verbose
147
+ emit_glyphs(face_dir, report) if @with_glyphs
148
+ emit_browsers(face_dir, report) if @emit_browser
149
+ face_dir
150
+ end
151
+
152
+ private
153
+
154
+ def emit_browsers(face_dir, report)
155
+ emit_face_browser(face_dir, report)
156
+ emit_missing_glyph_pages(face_dir, report) if @with_missing_glyph_pages
157
+ end
158
+
159
+ def emit_face_browser(face_dir, report)
160
+ Ucode::Audit::Browser::FacePage.new(
161
+ report: report,
162
+ verbose: @verbose,
163
+ with_glyphs: @with_glyphs,
164
+ universal_set_root: @universal_set_root,
165
+ face_dir: face_dir,
166
+ ).write(face_dir)
167
+ end
168
+
169
+ def emit_missing_glyph_pages(face_dir, report)
170
+ panel = Ucode::Audit::Browser::GlyphPanel.new(universal_set_root: @universal_set_root)
171
+ report.blocks.each do |block|
172
+ next if block.missing_codepoints.empty?
173
+
174
+ Ucode::Audit::Browser::MissingGlyphPage.new(
175
+ block_name: block.name,
176
+ missing_codepoints: block.missing_codepoints,
177
+ glyph_panel: panel,
178
+ ).write(face_dir)
179
+ end
180
+ end
181
+
182
+ def emit_library_browser(summary)
183
+ Ucode::Audit::Browser::LibraryPage.new(summary: summary).write(@output_root)
184
+ end
185
+
186
+ def emit_codepoints(face_dir, report)
187
+ report.blocks.each do |block|
188
+ next if block.covered_codepoints.empty?
189
+
190
+ @codepoint_emitter.emit(face_dir, block,
191
+ database: @database,
192
+ with_glyph_paths: @with_glyphs)
193
+ end
194
+ end
195
+
196
+ def emit_glyphs(face_dir, report)
197
+ report.blocks.flat_map(&:covered_codepoints).sort.each do |cp|
198
+ @glyph_emitter.emit(face_dir, cp)
199
+ end
200
+ end
201
+
202
+ def face_label(report)
203
+ report.postscript_name || File.basename(report.source_file, ".*")
204
+ end
205
+
206
+ def sanitize(name)
207
+ (name || "face").to_s.gsub(/[^A-Za-z0-9._-]/, "_")
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,48 @@
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>/glyphs/U+XXXX.svg` — the SVG outline of one
12
+ # audited glyph, emitted only in `--with-glyphs` mode.
13
+ #
14
+ # Glyph production is delegated to a caller-injected
15
+ # `glyph_resolver` proc. The proc takes a codepoint Integer and
16
+ # returns either an SVG string (write it) or nil (skip — no glyph
17
+ # available). ucode 0.2 ships with a default proc that always
18
+ # returns nil; the canonical 4-tier resolver (TODO 20) replaces it
19
+ # with the real fontist/fontisan + Last-Resort pipeline.
20
+ #
21
+ # Lazy by design: the resolver is invoked once per codepoint, and
22
+ # only for codepoints the caller actually iterates. No upfront
23
+ # font-load cost.
24
+ class GlyphEmitter
25
+ include Ucode::Repo::AtomicWrites
26
+
27
+ DEFAULT_RESOLVER = proc { |_codepoint| }
28
+
29
+ # @param glyph_resolver [Proc(Integer) -> String, nil] SVG source
30
+ def initialize(glyph_resolver: DEFAULT_RESOLVER)
31
+ @glyph_resolver = glyph_resolver
32
+ end
33
+
34
+ # @param face_dir [String, Pathname]
35
+ # @param codepoint [Integer]
36
+ # @return [Boolean] true if written, false if skipped (no glyph
37
+ # available, or content-identical to existing file)
38
+ def emit(face_dir, codepoint)
39
+ svg = @glyph_resolver.call(codepoint)
40
+ return false if svg.nil?
41
+
42
+ path = Paths.glyph_under(face_dir, format("U+%04X", codepoint))
43
+ write_atomic(path, svg)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require "ucode/repo/atomic_writes"
6
+ require "ucode/audit/emitter/paths"
7
+ require "ucode/models/audit/block_summary"
8
+
9
+ module Ucode
10
+ module Audit
11
+ module Emitter
12
+ # Writes `<face_dir>/index.json` — the compact face overview the
13
+ # browser fetches first.
14
+ #
15
+ # Compactness rules (per `03-directory-output-spec.md`):
16
+ #
17
+ # - `codepoint_details` never appears in `index.json`. The verbose
18
+ # per-block detail is emitted by {CodepointEmitter}.
19
+ # - `covered_codepoints` is dropped from each block entry. The
20
+ # browser fetches `codepoints/<NAME>.json` for that.
21
+ # - `missing_codepoints` is kept per block — it's the actionable
22
+ # gap list and small in practice.
23
+ # - Adds a derived `totals` block so a renderer doesn't have to
24
+ # re-aggregate to draw the headline numbers.
25
+ #
26
+ # Idempotent via {Ucode::Repo::AtomicWrites}.
27
+ class IndexEmitter
28
+ include Ucode::Repo::AtomicWrites
29
+
30
+ # @param face_dir [String, Pathname]
31
+ # @param report [Models::Audit::AuditReport]
32
+ # @param universal_set_root [String, Pathname, nil] when both
33
+ # this and `face_dir` are present and the root exists on
34
+ # disk, the index embeds a `universal_set` section with
35
+ # relative paths to the manifest + glyphs dir. nil otherwise.
36
+ # @return [Boolean] true if the file was written, false if skipped
37
+ def emit(face_dir, report, universal_set_root: nil)
38
+ payload = to_pretty_json(build_index(report, universal_set_root: universal_set_root,
39
+ face_dir: face_dir))
40
+ write_atomic(Paths.index_under(face_dir), payload)
41
+ end
42
+
43
+ # Build the index.json shape (a Hash) for a report. Exposed so
44
+ # the HTML browser ({Browser::FacePage}) can reuse the exact
45
+ # same shape when inlining overview data into its template.
46
+ #
47
+ # @param report [Models::Audit::AuditReport]
48
+ # @param universal_set_root [String, Pathname, nil]
49
+ # @param face_dir [String, Pathname, nil] required when
50
+ # `universal_set_root` is supplied (relative path resolution).
51
+ # @return [Hash]
52
+ def build_index(report, universal_set_root: nil, face_dir: nil)
53
+ {
54
+ "generated_at" => report.generated_at,
55
+ "ucode_version" => report.ucode_version,
56
+ "font" => font_section(report),
57
+ "baseline" => report.baseline&.to_hash,
58
+ "totals" => build_totals(report),
59
+ "discrepancies" => report.discrepancies.map(&:to_hash),
60
+ "plane_summaries" => report.plane_summaries.map(&:to_hash),
61
+ "block_summaries" => block_summaries(report),
62
+ "script_summaries" => report.scripts.map(&:to_hash),
63
+ "universal_set" => universal_set_section(universal_set_root, face_dir),
64
+ }.compact
65
+ end
66
+
67
+ private
68
+
69
+ def font_section(report)
70
+ {
71
+ "source_file" => report.source_file,
72
+ "source_sha256" => report.source_sha256,
73
+ "source_format" => report.source_format,
74
+ "font_index" => report.font_index,
75
+ "num_fonts_in_source" => report.num_fonts_in_source,
76
+ "family_name" => report.family_name,
77
+ "subfamily_name" => report.subfamily_name,
78
+ "full_name" => report.full_name,
79
+ "postscript_name" => report.postscript_name,
80
+ "version" => report.version,
81
+ "font_revision" => report.font_revision,
82
+ "weight_class" => report.weight_class,
83
+ "width_class" => report.width_class,
84
+ "italic" => report.italic,
85
+ "bold" => report.bold,
86
+ "panose" => report.panose,
87
+ "total_codepoints" => report.total_codepoints,
88
+ "total_glyphs" => report.total_glyphs,
89
+ "cmap_subtables" => report.cmap_subtables,
90
+ "codepoint_ranges" => report.codepoint_ranges.map(&:to_hash),
91
+ }
92
+ end
93
+
94
+ def block_summaries(report)
95
+ report.blocks.map do |block|
96
+ hash = block.to_hash.except("covered_codepoints")
97
+ # Spec: per-block `missing_codepoints` is always embedded.
98
+ # lutaml-model omits empty arrays by default; re-add the key.
99
+ hash["missing_codepoints"] = block.missing_codepoints
100
+ hash
101
+ end
102
+ end
103
+
104
+ def build_totals(report)
105
+ {
106
+ "assigned_codepoints_total" => assigned_total(report),
107
+ "covered_codepoints_total" => report.total_codepoints,
108
+ "blocks_touched" => report.blocks.size,
109
+ "blocks_complete" => report.blocks.count do |b|
110
+ b.status == Models::Audit::BlockSummary::STATUS_COMPLETE
111
+ end,
112
+ "blocks_partial" => report.blocks.count do |b|
113
+ b.status == Models::Audit::BlockSummary::STATUS_PARTIAL
114
+ end,
115
+ "scripts_touched" => report.scripts.size,
116
+ }
117
+ end
118
+
119
+ def assigned_total(report)
120
+ report.blocks.sum(&:total_assigned)
121
+ end
122
+
123
+ def universal_set_section(root, face_dir)
124
+ return nil if root.nil? || face_dir.nil?
125
+
126
+ root_path = Pathname.new(root)
127
+ unless root_path.directory?
128
+ return {
129
+ "available" => false,
130
+ "reason" => "universal_set_root not found: #{root}",
131
+ }
132
+ end
133
+
134
+ {
135
+ "available" => true,
136
+ "manifest_path" => relative_path(face_dir, root_path.join("manifest.json")),
137
+ "glyphs_dir" => "#{relative_path(face_dir, root_path.join('glyphs'))}/",
138
+ }
139
+ end
140
+
141
+ def relative_path(from_dir, to_path)
142
+ to_path.expand_path.relative_path_from(Pathname.new(from_dir).expand_path).to_s
143
+ rescue ArgumentError
144
+ to_path.to_s
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,96 @@
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 `output/font_audit/index.json` — the library-mode
12
+ # top-level index pointing at each face's `index.json`.
13
+ #
14
+ # Aggregates the per-face {Models::Audit::LibrarySummary} into a
15
+ # compact card list. The browser fetches this once on load and
16
+ # uses the cards to render the library browser; clicking a card
17
+ # fetches that face's per-face directory.
18
+ class LibraryEmitter
19
+ include Ucode::Repo::AtomicWrites
20
+
21
+ # @param output_root [String, Pathname]
22
+ # @param summary [Models::Audit::LibrarySummary]
23
+ # @return [Boolean] true if written, false if skipped
24
+ def emit(output_root, summary)
25
+ path = Paths.library_index_path(output_root)
26
+ payload = to_pretty_json(build_index(summary))
27
+ write_atomic(path, payload)
28
+ end
29
+
30
+ # Build the library index.json shape (a Hash). Exposed so the
31
+ # HTML browser ({Browser::LibraryPage}) can reuse the exact
32
+ # same shape when inlining overview data into its template.
33
+ #
34
+ # @param summary [Models::Audit::LibrarySummary]
35
+ # @return [Hash]
36
+ def build_index(summary)
37
+ {
38
+ "root_path" => summary.root_path,
39
+ "total_files" => summary.total_files,
40
+ "total_faces" => summary.total_faces,
41
+ "scanned_extensions" => summary.scanned_extensions,
42
+ "aggregate_metrics" => summary.aggregate_metrics,
43
+ "license_distribution" => summary.license_distribution,
44
+ "duplicate_groups" => summary.duplicate_groups.map(&:to_hash),
45
+ "script_coverage" => summary.script_coverage.map(&:to_hash),
46
+ "faces" => face_cards(summary),
47
+ }
48
+ end
49
+
50
+ private
51
+
52
+ def face_cards(summary)
53
+ summary.per_face_reports.map do |report|
54
+ label = face_label(report)
55
+ covered_total, assigned_total, complete, partial = block_rollup(report)
56
+ {
57
+ "label" => label,
58
+ "family_name" => report.family_name,
59
+ "postscript_name" => report.postscript_name,
60
+ "weight_class" => report.weight_class,
61
+ "total_codepoints" => report.total_codepoints,
62
+ "total_glyphs" => report.total_glyphs,
63
+ "covered_total" => covered_total,
64
+ "total_assigned_total" => assigned_total,
65
+ "blocks_complete" => complete,
66
+ "blocks_partial" => partial,
67
+ "source_sha256" => report.source_sha256,
68
+ "index_path" => "#{label}/index.json",
69
+ "html_path" => "#{label}/index.html",
70
+ }
71
+ end
72
+ end
73
+
74
+ def block_rollup(report)
75
+ covered = 0
76
+ assigned = 0
77
+ complete = 0
78
+ partial = 0
79
+ report.blocks.each do |b|
80
+ covered += b.covered_count
81
+ assigned += b.total_assigned
82
+ case b.status
83
+ when Models::Audit::BlockSummary::STATUS_COMPLETE then complete += 1
84
+ when Models::Audit::BlockSummary::STATUS_PARTIAL then partial += 1
85
+ end
86
+ end
87
+ [covered, assigned, complete, partial]
88
+ end
89
+
90
+ def face_label(report)
91
+ report.postscript_name || File.basename(report.source_file, ".*")
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end