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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ucode
4
+ module Audit
5
+ # Pure cross-face aggregation over a list of AuditReports.
6
+ #
7
+ # No I/O, no font parsing — operates only on already-built reports.
8
+ # The orchestrator ({LibraryAuditor}) handles file discovery and
9
+ # per-face auditing; this class owns the rollups that span faces.
10
+ #
11
+ # ucode deltas vs fontisan:
12
+ #
13
+ # - `build_script_coverage` reads `report.scripts` (ScriptSummary[])
14
+ # rather than fontisan's `unicode_scripts` (String[]). The script
15
+ # identifier is `script_code` (ISO 15924), and only scripts with
16
+ # non-zero coverage are surfaced.
17
+ # - `aggregate_metrics` sums `total_codepoints` and `total_glyphs`.
18
+ # ucode reports both as Integer (never nil) per the schema.
19
+ class LibraryAggregator
20
+ # @param reports [Array<Models::Audit::AuditReport>]
21
+ # @return [Hash{Symbol => Object}] keys: :aggregate_metrics,
22
+ # :script_coverage, :duplicate_groups, :license_distribution
23
+ def aggregate(reports)
24
+ {
25
+ aggregate_metrics: aggregate_metrics(reports),
26
+ script_coverage: build_script_coverage(reports),
27
+ duplicate_groups: find_duplicates(reports),
28
+ license_distribution: license_distribution(reports),
29
+ }
30
+ end
31
+
32
+ private
33
+
34
+ def aggregate_metrics(reports)
35
+ {
36
+ total_codepoints: reports.sum { |r| r.total_codepoints.to_i },
37
+ total_glyphs: reports.sum { |r| r.total_glyphs.to_i },
38
+ }
39
+ end
40
+
41
+ def build_script_coverage(reports)
42
+ by_script = Hash.new { |h, k| h[k] = [] }
43
+ reports.each do |report|
44
+ face = report.postscript_name || report.source_file
45
+ scripts_for(report).each { |script| by_script[script] << face }
46
+ end
47
+ by_script.map do |script, faces|
48
+ Models::Audit::ScriptCoverageRow.new(
49
+ script: script,
50
+ face_count: faces.size,
51
+ faces: faces.uniq.sort,
52
+ )
53
+ end.sort_by { |row| [-row.face_count, row.script] }
54
+ end
55
+
56
+ def find_duplicates(reports)
57
+ reports.group_by(&:source_sha256)
58
+ .select { |_sha, group| group.size > 1 }
59
+ .map do |sha, group|
60
+ Models::Audit::DuplicateGroup.new(
61
+ source_sha256: sha,
62
+ files: group.map(&:source_file).sort,
63
+ )
64
+ end
65
+ .sort_by(&:source_sha256)
66
+ end
67
+
68
+ def license_distribution(reports)
69
+ reports.each_with_object({}) do |report, counts|
70
+ url = license_url_for(report)
71
+ counts[url] = counts.fetch(url, 0) + 1
72
+ end
73
+ end
74
+
75
+ # Read the script identifier from the structured ScriptSummary[]
76
+ # rather than fontisan's flat String[] list.
77
+ def scripts_for(report)
78
+ Array(report.scripts).map(&:script_code)
79
+ end
80
+
81
+ def license_url_for(report)
82
+ report.licensing&.license_url || "(none)"
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Ucode
6
+ module Audit
7
+ # Orchestrates a library-wide audit pass.
8
+ #
9
+ # Owns the file-system side: discovers font files under a root path
10
+ # (recursively or not), audits each via {FaceAuditor}, and assembles
11
+ # a {Models::Audit::LibrarySummary} combining the per-face reports
12
+ # with cross-face rollups from {LibraryAggregator}.
13
+ #
14
+ # Aggregation logic lives in the pure {LibraryAggregator}; this
15
+ # class stays focused on discovery + per-face auditing + summary
16
+ # assembly. Errors auditing a single file are captured in
17
+ # `#skipped` so a corrupt file doesn't abort the whole pass.
18
+ #
19
+ # ucode delta vs fontisan: delegates per-face work to {FaceAuditor}
20
+ # instead of fontisan's Commands::AuditCommand. The discovery and
21
+ # rollup logic is otherwise identical.
22
+ class LibraryAuditor
23
+ FONT_EXTENSIONS = %w[.ttf .otf .ttc .otc .dfont .woff .woff2
24
+ .pfb .pfa .svg].freeze
25
+
26
+ # @param root_path [String, Pathname] directory containing fonts
27
+ # @param recursive [Boolean] walk into subdirectories
28
+ # @param options [Hash] forwarded to {FaceAuditor} (ucd_version,
29
+ # all_codepoints, audit_brief, …). Library-only keys are stripped.
30
+ # @param reference [CoverageReference, nil] baseline forwarded to
31
+ # every per-face {FaceAuditor} (TODO 25). When nil, each face
32
+ # defaults to UCD-only.
33
+ def initialize(root_path, recursive:, options:, reference: nil)
34
+ @root_path = Pathname.new(root_path)
35
+ @recursive = recursive
36
+ @options = options
37
+ @reference = reference
38
+ @aggregator = LibraryAggregator.new
39
+ @skipped = []
40
+ end
41
+
42
+ # @return [Models::Audit::LibrarySummary]
43
+ def audit
44
+ paths = discover_font_paths
45
+ reports = paths.flat_map { |p| audit_one(p) }
46
+ rolled_up = aggregates(reports)
47
+
48
+ Models::Audit::LibrarySummary.new(
49
+ root_path: @root_path.to_s,
50
+ total_files: paths.size,
51
+ total_faces: reports.size,
52
+ scanned_extensions: scanned_extensions(paths),
53
+ aggregate_metrics: rolled_up[:aggregate_metrics].merge(
54
+ total_size_bytes: paths.sum { |p| File.size(p) },
55
+ ),
56
+ script_coverage: rolled_up[:script_coverage],
57
+ duplicate_groups: rolled_up[:duplicate_groups],
58
+ license_distribution: rolled_up[:license_distribution],
59
+ per_face_reports: reports,
60
+ )
61
+ end
62
+
63
+ # @return [Array<String>] source files that could not be audited,
64
+ # formatted as "path: message"
65
+ attr_reader :skipped
66
+
67
+ private
68
+
69
+ def discover_font_paths
70
+ method = @recursive ? :find : :children
71
+ @root_path.public_send(method).select do |entry|
72
+ next false unless entry.file?
73
+ next false if entry.symlink?
74
+
75
+ FONT_EXTENSIONS.include?(entry.extname.downcase)
76
+ end.map(&:to_s).sort
77
+ end
78
+
79
+ def audit_one(path)
80
+ Array(FaceAuditor.new(path, options: audit_options, mode: audit_mode,
81
+ reference: @reference).call)
82
+ rescue StandardError => e
83
+ @skipped << "#{path}: #{e.message}"
84
+ []
85
+ end
86
+
87
+ # Library-only options that don't apply to per-face audit.
88
+ def audit_options
89
+ @options.except(:recursive, :summary, :output)
90
+ end
91
+
92
+ def audit_mode
93
+ @options[:audit_brief] ? :brief : :full
94
+ end
95
+
96
+ def scanned_extensions(paths)
97
+ paths.map { |p| File.extname(p).downcase }.uniq.sort
98
+ end
99
+
100
+ def aggregates(reports)
101
+ @aggregator.aggregate(reports)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "json"
5
+
6
+ require "ucode/repo/atomic_writes"
7
+ require "ucode/audit/emitter/paths"
8
+ require "ucode/audit/emitter/face_directory"
9
+ require "ucode/audit/release/formula_audits"
10
+ require "ucode/audit/release/face_card"
11
+ require "ucode/audit/release/library_index_builder"
12
+ require "ucode/audit/release/manifest_builder"
13
+
14
+ module Ucode
15
+ module Audit
16
+ module Release
17
+ # Orchestrator that assembles the fontist.org release tree from
18
+ # a list of per-formula {FormulaAudits} (TODO 27).
19
+ #
20
+ # Drives {Emitter::FaceDirectory} per formula to emit each face's
21
+ # audit subtree under `<release_root>/audit/<slug>/<face>/`, then
22
+ # writes the two top-level indices:
23
+ #
24
+ # - `<release_root>/library.json` — formula + face card index
25
+ # via {LibraryIndexBuilder}.
26
+ # - `<release_root>/manifest.json` — release manifest via
27
+ # {ManifestBuilder} (a {Models::Audit::ReleaseManifest}).
28
+ #
29
+ # The universal-set directory is NOT copied by this emitter. The
30
+ # CI collector is expected to pre-stage
31
+ # `<release_root>/universal_glyph_set/` (built separately by
32
+ # `ucode universal-set build`). The manifest records whether that
33
+ # directory is present.
34
+ #
35
+ # Idempotent: every write goes through {Repo::AtomicWrites}
36
+ # (byte-compare, then rename). Re-running on unchanged input
37
+ # produces zero file writes on the second pass.
38
+ class Emitter
39
+ include Ucode::Repo::AtomicWrites
40
+
41
+ Result = Struct.new(:release_root, :formulas_total, :faces_total,
42
+ :library_index_written, :manifest_written,
43
+ :universal_set_available, keyword_init: true)
44
+
45
+ # @param output_root [String, Pathname] parent of the release
46
+ # root. The release tree lives at
47
+ # `<output_root>/font_audit_release/`.
48
+ # @param universal_set_root [String, Pathname, nil] location of
49
+ # the universal_glyph_set directory. Defaults to
50
+ # `<release_root>/universal_glyph_set` (the canonical location
51
+ # inside the release tree).
52
+ # @param face_directory [Emitter::FaceDirectory] injectable for
53
+ # testing. Defaults to a fresh instance configured with the
54
+ # same `universal_set_root` and `emit_browser: true`.
55
+ # @param verbose [Boolean] emit per-codepoint detail chunks per
56
+ # face. Forwarded to {Emitter::FaceDirectory}.
57
+ # @param with_glyphs [Boolean] emit per-codepoint SVG chunks.
58
+ # Forwarded to {Emitter::FaceDirectory}.
59
+ # @param with_missing_glyph_pages [Boolean] emit per-block
60
+ # missing-glyph galleries. Forwarded to
61
+ # {Emitter::FaceDirectory}.
62
+ def initialize(output_root:, universal_set_root: nil, face_directory: nil,
63
+ verbose: false, with_glyphs: false, with_missing_glyph_pages: true)
64
+ @output_root = output_root
65
+ @universal_set_root = universal_set_root
66
+ @verbose = verbose
67
+ @with_glyphs = with_glyphs
68
+ @with_missing_glyph_pages = with_missing_glyph_pages
69
+ @face_directory = face_directory || build_default_face_directory
70
+ @library_index_builder = LibraryIndexBuilder.new
71
+ @manifest_builder = ManifestBuilder.new
72
+ end
73
+
74
+ # @param formulas [Array<FormulaAudits>]
75
+ # @param unicode_version [String, nil]
76
+ # @param generated_at [String] ISO8601 timestamp
77
+ # @param source_config_sha256 [String, nil]
78
+ # @return [Result]
79
+ def emit(formulas:, unicode_version:, generated_at:,
80
+ source_config_sha256: nil)
81
+ release_root = Ucode::Audit::Emitter::Paths.release_root(@output_root)
82
+ formulas.each { |fa| emit_formula(release_root, fa) }
83
+ manifest = @manifest_builder.build(
84
+ formulas: formulas,
85
+ release_root: release_root,
86
+ unicode_version: unicode_version,
87
+ ucode_version: Ucode::VERSION,
88
+ generated_at: generated_at,
89
+ source_config_sha256: source_config_sha256,
90
+ universal_set_root: resolved_universal_set_root(release_root),
91
+ )
92
+ library_written = write_library_index(release_root, formulas, generated_at)
93
+ manifest_written = write_manifest(release_root, manifest)
94
+ Result.new(
95
+ release_root: release_root.to_s,
96
+ formulas_total: formulas.size,
97
+ faces_total: formulas.sum(&:faces_total),
98
+ library_index_written: library_written,
99
+ manifest_written: manifest_written,
100
+ universal_set_available: manifest.universal_set.available,
101
+ )
102
+ end
103
+
104
+ private
105
+
106
+ def build_default_face_directory
107
+ Ucode::Audit::Emitter::FaceDirectory.new(
108
+ output_root: @output_root,
109
+ verbose: @verbose,
110
+ with_glyphs: @with_glyphs,
111
+ emit_browser: true,
112
+ universal_set_root: @universal_set_root,
113
+ with_missing_glyph_pages: @with_missing_glyph_pages,
114
+ )
115
+ end
116
+
117
+ def resolved_universal_set_root(release_root)
118
+ @universal_set_root || Ucode::Audit::Emitter::Paths.release_universal_set_root(release_root)
119
+ end
120
+
121
+ def emit_formula(release_root, formula_audits)
122
+ slug = formula_audits.slug
123
+ formula_audits.face_reports.each do |report|
124
+ label = face_label_for(report, slug, release_root)
125
+ face_dir = Ucode::Audit::Emitter::Paths.release_face_dir(release_root, slug, label)
126
+ @face_directory.emit_face_at(face_dir, report)
127
+ end
128
+ end
129
+
130
+ def face_label_for(report, slug, release_root)
131
+ FaceCard.new(report, slug, release_root).label
132
+ end
133
+
134
+ def write_library_index(release_root, formulas, generated_at)
135
+ path = Ucode::Audit::Emitter::Paths.release_library_index_path(release_root)
136
+ hash = @library_index_builder.build(
137
+ formulas: formulas,
138
+ release_root: release_root,
139
+ generated_at: generated_at,
140
+ ucode_version: Ucode::VERSION,
141
+ )
142
+ write_atomic(path, to_pretty_json(hash))
143
+ end
144
+
145
+ def write_manifest(release_root, manifest)
146
+ path = Ucode::Audit::Emitter::Paths.release_manifest_path(release_root)
147
+ write_atomic(path, manifest.to_json(pretty: true))
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require "ucode/audit/emitter/paths"
6
+ require "ucode/models/audit"
7
+
8
+ module Ucode
9
+ module Audit
10
+ module Release
11
+ # Value object deriving the per-face card fields shared by
12
+ # {LibraryIndexBuilder} (Hash for `library.json`) and
13
+ # {ManifestBuilder} ({Models::Audit::ReleaseFaceEntry} for
14
+ # `manifest.json`).
15
+ #
16
+ # Single source of truth for the rollup math (covered total,
17
+ # assigned total, complete/partial block counts) and the path
18
+ # conventions (face label sanitization, relative index/html
19
+ # paths under `<release_root>/audit/<slug>/<label>/`).
20
+ #
21
+ # Pure: no I/O, no mutation. Callers compose the final shape
22
+ # (Hash or model) from the derived fields.
23
+ class FaceCard
24
+ attr_reader :report, :slug, :release_root
25
+
26
+ # @param report [Models::Audit::AuditReport]
27
+ # @param slug [String] formula slug
28
+ # @param release_root [String, Pathname]
29
+ def initialize(report, slug, release_root)
30
+ @report = report
31
+ @slug = slug
32
+ @release_root = release_root
33
+ end
34
+
35
+ # @return [String] sanitized face label (postscript name with
36
+ # non-filename chars replaced by underscore)
37
+ def label
38
+ name = report.postscript_name || File.basename(report.source_file.to_s, ".*")
39
+ (name || "face").to_s.gsub(/[^A-Za-z0-9._-]/, "_")
40
+ end
41
+
42
+ # @return [Pathname] `<release_root>/audit/<slug>/<label>`
43
+ def face_dir
44
+ Ucode::Audit::Emitter::Paths.release_face_dir(release_root, slug, label)
45
+ end
46
+
47
+ # @return [Integer]
48
+ def covered_total
49
+ report.blocks.sum(&:covered_count)
50
+ end
51
+
52
+ # @return [Integer]
53
+ def assigned_total
54
+ report.blocks.sum(&:total_assigned)
55
+ end
56
+
57
+ # @return [Integer]
58
+ def blocks_complete
59
+ report.blocks.count do |b|
60
+ b.status == Models::Audit::BlockSummary::STATUS_COMPLETE
61
+ end
62
+ end
63
+
64
+ # @return [Integer]
65
+ def blocks_partial
66
+ report.blocks.count do |b|
67
+ b.status == Models::Audit::BlockSummary::STATUS_PARTIAL
68
+ end
69
+ end
70
+
71
+ # @return [String] relative path from release root to index.json
72
+ def index_path
73
+ relative_path(face_dir.join("index.json"))
74
+ end
75
+
76
+ # @return [String] relative path from release root to index.html
77
+ def html_path
78
+ relative_path(face_dir.join("index.html"))
79
+ end
80
+
81
+ private
82
+
83
+ def relative_path(to)
84
+ Pathname.new(to).expand_path
85
+ .relative_path_from(Pathname.new(release_root).expand_path)
86
+ .to_s
87
+ rescue ArgumentError
88
+ Pathname.new(to).to_s
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ucode
4
+ module Audit
5
+ module Release
6
+ # Value object pairing a formula slug with the library-wide audit
7
+ # summary produced by running {Audit::LibraryAuditor} on that
8
+ # formula's font directory.
9
+ #
10
+ # Used as the input unit to {Emitter}: callers pass a list of
11
+ # these and the emitter walks each one's `summary.per_face_reports`
12
+ # to emit the per-face audit subtrees.
13
+ #
14
+ # The slug MUST be caller-sanitized (fontist formula slug form:
15
+ # lowercase, hyphen-separated, filesystem-safe). The emitter does
16
+ # not re-sanitize — it uses the slug verbatim as the directory
17
+ # name under `<release_root>/audit/`.
18
+ FormulaAudits = Struct.new(:slug, :summary, keyword_init: true) do
19
+ # Sanity check at construction time so a malformed slug fails
20
+ # fast at the call site instead of producing a broken tree.
21
+ def initialize(slug:, summary:)
22
+ raise ArgumentError, "slug must not be empty" if slug.to_s.strip.empty?
23
+ raise ArgumentError, "slug contains path separators: #{slug.inspect}" if slug[%r{/}]
24
+ raise ArgumentError, "summary is required" unless summary
25
+
26
+ slug = slug.to_s
27
+ raise ArgumentError, "slug is not filesystem-safe: #{slug.inspect}" unless safe_slug?(slug)
28
+
29
+ super(slug: slug, summary: summary)
30
+ end
31
+
32
+ # @return [Integer] number of face reports in the summary
33
+ def faces_total
34
+ summary.total_faces
35
+ end
36
+
37
+ # @return [Enumerable<Models::Audit::AuditReport>]
38
+ def face_reports
39
+ summary.per_face_reports
40
+ end
41
+
42
+ private
43
+
44
+ def safe_slug?(slug)
45
+ slug.match?(/\A[A-Za-z0-9._-]+\z/)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require "ucode/audit/emitter/paths"
6
+ require "ucode/audit/release/formula_audits"
7
+ require "ucode/audit/release/face_card"
8
+
9
+ module Ucode
10
+ module Audit
11
+ module Release
12
+ # Pure builder for the release-level `library.json` (TODO 27).
13
+ #
14
+ # Aggregates a list of {FormulaAudits} into a single Hash shape
15
+ # consumed by fontist.org's renderer. Each formula contributes a
16
+ # formula card with its face cards; the renderer iterates the
17
+ # formula list to build its font index.
18
+ #
19
+ # The shape mirrors {Emitter::LibraryEmitter#build_index} but
20
+ # adds the formula layer. Paths are relative to the release root
21
+ # so the JSON is portable across hosts.
22
+ #
23
+ # Pure: no I/O, no global state. Caller writes the result.
24
+ class LibraryIndexBuilder
25
+ # @param formulas [Array<FormulaAudits>]
26
+ # @param release_root [String, Pathname]
27
+ # @param generated_at [String] ISO8601 timestamp
28
+ # @param ucode_version [String]
29
+ # @return [Hash]
30
+ def build(formulas:, release_root:, generated_at:, ucode_version:)
31
+ @release_root = release_root
32
+ {
33
+ "generated_at" => generated_at,
34
+ "ucode_version" => ucode_version,
35
+ "formulas_total" => formulas.size,
36
+ "faces_total" => formulas.sum(&:faces_total),
37
+ "formulas" => formulas.map { |fa| formula_card(fa) },
38
+ }
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :release_root
44
+
45
+ def formula_card(formula_audits)
46
+ summary = formula_audits.summary
47
+ {
48
+ "slug" => formula_audits.slug,
49
+ "source_path" => summary.root_path,
50
+ "faces_total" => summary.total_faces,
51
+ "scanned_extensions" => summary.scanned_extensions,
52
+ "aggregate_metrics" => summary.aggregate_metrics,
53
+ "license_distribution" => summary.license_distribution,
54
+ "faces" => formula_audits.face_reports.map { |r| face_card(r, formula_audits.slug) },
55
+ }
56
+ end
57
+
58
+ def face_card(report, slug)
59
+ card = FaceCard.new(report, slug, release_root)
60
+ {
61
+ "label" => card.label,
62
+ "postscript_name" => report.postscript_name,
63
+ "family_name" => report.family_name,
64
+ "weight_class" => report.weight_class,
65
+ "total_codepoints" => report.total_codepoints,
66
+ "covered_total" => card.covered_total,
67
+ "total_assigned_total" => card.assigned_total,
68
+ "blocks_complete" => card.blocks_complete,
69
+ "blocks_partial" => card.blocks_partial,
70
+ "source_sha256" => report.source_sha256,
71
+ "index_path" => card.index_path,
72
+ "html_path" => card.html_path,
73
+ }
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "json"
5
+
6
+ require "ucode/audit/emitter/paths"
7
+ require "ucode/audit/release/formula_audits"
8
+ require "ucode/audit/release/face_card"
9
+ require "ucode/models/audit/release_manifest"
10
+ require "ucode/models/audit/release_formula"
11
+ require "ucode/models/audit/release_face"
12
+ require "ucode/models/audit/release_universal_set"
13
+
14
+ module Ucode
15
+ module Audit
16
+ module Release
17
+ # Pure builder for the release-level `manifest.json` (TODO 27).
18
+ #
19
+ # Produces a ready-to-serialize {Models::Audit::ReleaseManifest}
20
+ # from a list of {FormulaAudits} plus the universal-set location.
21
+ # Records the ucode/unicode versions, optional source-config
22
+ # sha256 (for Tier 1 curation provenance), aggregate formula/face
23
+ # counts, and the universal-set reference section.
24
+ #
25
+ # Pure: no I/O, no global state. Caller serializes the model and
26
+ # writes the file.
27
+ class ManifestBuilder
28
+ UNIVERSAL_SET_DIR = "universal_glyph_set"
29
+ UNIVERSAL_SET_MANIFEST = "manifest.json"
30
+ UNIVERSAL_SET_GLYPHS_DIR = "glyphs"
31
+
32
+ # @param formulas [Array<FormulaAudits>]
33
+ # @param release_root [String, Pathname]
34
+ # @param unicode_version [String, nil] baseline UCD version
35
+ # @param ucode_version [String]
36
+ # @param generated_at [String] ISO8601 timestamp
37
+ # @param source_config_sha256 [String, nil] sha256 of the Tier 1
38
+ # source-config YAML (TODO 23). nil when not applicable.
39
+ # @param universal_set_root [String, Pathname, nil] expected
40
+ # location of the universal_glyph_set directory inside the
41
+ # release tree (default: `<release_root>/universal_glyph_set`).
42
+ # @return [Models::Audit::ReleaseManifest]
43
+ def build(formulas:, release_root:, unicode_version:, ucode_version:,
44
+ generated_at:, source_config_sha256: nil, universal_set_root: nil)
45
+ @release_root = release_root
46
+ resolved_uset_root = universal_set_root ||
47
+ Ucode::Audit::Emitter::Paths.release_universal_set_root(release_root)
48
+ Models::Audit::ReleaseManifest.new(
49
+ ucode_version: ucode_version,
50
+ unicode_version: unicode_version,
51
+ generated_at: generated_at,
52
+ source_config_sha256: source_config_sha256,
53
+ formulas_total: formulas.size,
54
+ faces_total: formulas.sum(&:faces_total),
55
+ universal_set: build_universal_set(resolved_uset_root),
56
+ formulas: formulas.map { |fa| build_formula_entry(fa) },
57
+ )
58
+ end
59
+
60
+ private
61
+
62
+ attr_reader :release_root
63
+
64
+ def build_universal_set(uset_root)
65
+ path = Pathname.new(uset_root)
66
+ manifest_path = path.join(UNIVERSAL_SET_MANIFEST)
67
+ glyphs_dir = path.join(UNIVERSAL_SET_GLYPHS_DIR)
68
+ unless path.directory?
69
+ return unavailable("universal-set directory not found at #{path}")
70
+ end
71
+ unless manifest_path.file?
72
+ return unavailable("manifest.json not found at #{manifest_path}")
73
+ end
74
+ unless glyphs_dir.directory?
75
+ return unavailable("glyphs/ directory not found at #{glyphs_dir}")
76
+ end
77
+
78
+ manifest = load_manifest(manifest_path)
79
+ Models::Audit::ReleaseUniversalSet.new(
80
+ available: true,
81
+ manifest_path: manifest_path.relative_path_from(path.parent).to_s,
82
+ glyphs_dir: glyphs_dir.relative_path_from(path.parent).to_s,
83
+ unicode_version: manifest["unicode_version"],
84
+ totals: manifest["totals"] || {},
85
+ )
86
+ end
87
+
88
+ def unavailable(reason)
89
+ Models::Audit::ReleaseUniversalSet.new(available: false, reason: reason)
90
+ end
91
+
92
+ def load_manifest(manifest_path)
93
+ JSON.parse(manifest_path.read)
94
+ rescue JSON::ParserError => e
95
+ { "parse_error" => e.message }
96
+ end
97
+
98
+ def build_formula_entry(formula_audits)
99
+ Models::Audit::ReleaseFormulaEntry.new(
100
+ slug: formula_audits.slug,
101
+ source_path: formula_audits.summary.root_path,
102
+ faces_total: formula_audits.faces_total,
103
+ faces: formula_audits.face_reports.map do |report|
104
+ build_face_entry(report, formula_audits.slug)
105
+ end,
106
+ )
107
+ end
108
+
109
+ def build_face_entry(report, slug)
110
+ card = FaceCard.new(report, slug, release_root)
111
+ Models::Audit::ReleaseFaceEntry.new(
112
+ postscript_name: report.postscript_name,
113
+ family_name: report.family_name,
114
+ weight_class: report.weight_class,
115
+ total_codepoints: report.total_codepoints,
116
+ covered_codepoints: card.covered_total,
117
+ blocks_complete: card.blocks_complete,
118
+ blocks_partial: card.blocks_partial,
119
+ source_sha256: report.source_sha256,
120
+ index_path: card.index_path,
121
+ html_path: card.html_path,
122
+ )
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end