ucode 0.1.0 → 0.2.0

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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/TODO.full/00-README.md +116 -0
  4. data/TODO.full/01-panglyph-vision.md +112 -0
  5. data/TODO.full/02-panglyph-repo-bootstrap.md +184 -0
  6. data/TODO.full/03-panglyph-font-builder.md +201 -0
  7. data/TODO.full/04-panglyph-publish-pipeline.md +126 -0
  8. data/TODO.full/05-ucode-0-1-1-release.md +139 -0
  9. data/TODO.full/06-fontisan-remove-audit.md +142 -0
  10. data/TODO.full/07-fontisan-remove-ucd.md +125 -0
  11. data/TODO.full/08-archive-private-bin-build.md +143 -0
  12. data/TODO.full/09-archive-public-structure.md +164 -0
  13. data/TODO.full/10-fontist-org-woff-glyphs.md +131 -0
  14. data/TODO.full/11-fontist-org-audit-coverage.md +140 -0
  15. data/TODO.full/12-implementation-order.md +216 -0
  16. data/TODO.full/13-fontisan-font-writer-api.md +189 -0
  17. data/TODO.full/14-fontisan-table-writers.md +66 -0
  18. data/TODO.full/15-panglyph-builder-real.md +82 -0
  19. data/TODO.full/16-archive-public-sync-workflows.md +167 -0
  20. data/TODO.full/17-fontist-org-font-picker.md +73 -0
  21. data/TODO.full/18-comprehensive-spec-coverage.md +64 -0
  22. data/TODO.full/19-ucode-0-1-2-patch.md +32 -0
  23. data/TODO.full/20-fontisan-0-2-23-release.md +52 -0
  24. data/TODO.new/00-README.md +30 -0
  25. data/TODO.new/23-universal-glyph-set-source-map.md +312 -0
  26. data/TODO.new/24-universal-glyph-set-build.md +189 -0
  27. data/TODO.new/25-font-audit-against-universal-set.md +195 -0
  28. data/TODO.new/26-missing-glyph-reporter.md +189 -0
  29. data/TODO.new/27-fontist-org-consumer-integration.md +200 -0
  30. data/TODO.new/28-implementation-order-update.md +187 -0
  31. data/TODO.new/29-universal-set-curation-uc17.md +312 -0
  32. data/TODO.new/30-tier1-font-acquisition.md +241 -0
  33. data/TODO.new/31-universal-set-production-build.md +205 -0
  34. data/TODO.new/32-uc17-coverage-matrix.md +165 -0
  35. data/TODO.new/33-specialist-font-acquisition-refresh.md +138 -0
  36. data/TODO.new/34-pillar2-content-stream-correlator.md +147 -0
  37. data/TODO.new/35-universal-set-production-run.md +160 -0
  38. data/TODO.new/36-per-font-coverage-audit.md +145 -0
  39. data/TODO.new/37-coverage-highlight-reporter.md +125 -0
  40. data/TODO.new/38-fontist-org-glyph-consumer.md +141 -0
  41. data/TODO.new/39-implementation-order-update-32-38.md +258 -0
  42. data/TODO.new/40-archive-private-uses-ucode-audit.md +124 -0
  43. data/TODO.new/41-ucode-unicode-archive-bridge.md +160 -0
  44. data/config/specialist_fonts.yml +102 -0
  45. data/config/unicode17_tier1_fonts.yml +42 -0
  46. data/config/unicode17_universal_glyph_set.yml +293 -0
  47. data/lib/ucode/audit/block_aggregator.rb +57 -29
  48. data/lib/ucode/audit/browser/face_page.rb +128 -0
  49. data/lib/ucode/audit/browser/glyph_panel.rb +124 -0
  50. data/lib/ucode/audit/browser/library_page.rb +74 -0
  51. data/lib/ucode/audit/browser/missing_glyph_page.rb +87 -0
  52. data/lib/ucode/audit/browser/template.rb +47 -0
  53. data/lib/ucode/audit/browser/templates/face.css +200 -0
  54. data/lib/ucode/audit/browser/templates/face.html.erb +41 -0
  55. data/lib/ucode/audit/browser/templates/face.js +298 -0
  56. data/lib/ucode/audit/browser/templates/library.css +119 -0
  57. data/lib/ucode/audit/browser/templates/library.html.erb +42 -0
  58. data/lib/ucode/audit/browser/templates/library.js +99 -0
  59. data/lib/ucode/audit/browser/templates/missing_glyph_page.css +119 -0
  60. data/lib/ucode/audit/browser/templates/missing_glyph_page.html.erb +58 -0
  61. data/lib/ucode/audit/browser/templates/missing_glyph_page.js +2 -0
  62. data/lib/ucode/audit/browser.rb +32 -0
  63. data/lib/ucode/audit/context.rb +27 -1
  64. data/lib/ucode/audit/coverage_reference.rb +103 -0
  65. data/lib/ucode/audit/differ.rb +121 -0
  66. data/lib/ucode/audit/emitter/block_emitter.rb +52 -0
  67. data/lib/ucode/audit/emitter/codepoint_emitter.rb +87 -0
  68. data/lib/ucode/audit/emitter/collection_emitter.rb +80 -0
  69. data/lib/ucode/audit/emitter/face_directory.rb +212 -0
  70. data/lib/ucode/audit/emitter/glyph_emitter.rb +48 -0
  71. data/lib/ucode/audit/emitter/index_emitter.rb +149 -0
  72. data/lib/ucode/audit/emitter/library_emitter.rb +96 -0
  73. data/lib/ucode/audit/emitter/paths.rb +312 -0
  74. data/lib/ucode/audit/emitter/plane_emitter.rb +29 -0
  75. data/lib/ucode/audit/emitter/script_emitter.rb +29 -0
  76. data/lib/ucode/audit/emitter.rb +29 -0
  77. data/lib/ucode/audit/extractors/aggregations.rb +31 -2
  78. data/lib/ucode/audit/face_auditor.rb +86 -0
  79. data/lib/ucode/audit/formatters/audit_diff_text.rb +112 -0
  80. data/lib/ucode/audit/formatters/audit_text.rb +411 -0
  81. data/lib/ucode/audit/formatters/color.rb +48 -0
  82. data/lib/ucode/audit/formatters/library_summary_text.rb +98 -0
  83. data/lib/ucode/audit/formatters/text_formatter.rb +83 -0
  84. data/lib/ucode/audit/formatters.rb +23 -0
  85. data/lib/ucode/audit/library_aggregator.rb +86 -0
  86. data/lib/ucode/audit/library_auditor.rb +105 -0
  87. data/lib/ucode/audit/reference_factory.rb +66 -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 +32 -0
  97. data/lib/ucode/cli.rb +366 -41
  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.rb +19 -0
  104. data/lib/ucode/commands/block_feed.rb +73 -0
  105. data/lib/ucode/commands/build.rb +4 -0
  106. data/lib/ucode/commands/canonical_build.rb +137 -0
  107. data/lib/ucode/commands/fetch.rb +49 -15
  108. data/lib/ucode/commands/glyphs.rb +25 -67
  109. data/lib/ucode/commands/lookup.rb +11 -11
  110. data/lib/ucode/commands/parse.rb +7 -5
  111. data/lib/ucode/commands/release.rb +114 -0
  112. data/lib/ucode/commands/universal_set.rb +207 -0
  113. data/lib/ucode/commands.rb +5 -0
  114. data/lib/ucode/coordinator/indices.rb +49 -2
  115. data/lib/ucode/coordinator.rb +138 -5
  116. data/lib/ucode/error.rb +30 -2
  117. data/lib/ucode/fetch/font_fetcher/result.rb +39 -0
  118. data/lib/ucode/fetch/font_fetcher.rb +16 -0
  119. data/lib/ucode/fetch/specialist_font_fetcher.rb +280 -0
  120. data/lib/ucode/fetch.rb +7 -3
  121. data/lib/ucode/glyphs/pipeline.rb +106 -0
  122. data/lib/ucode/glyphs/real_fonts/cmap_cache.rb +74 -0
  123. data/lib/ucode/glyphs/real_fonts.rb +1 -0
  124. data/lib/ucode/glyphs/resolver.rb +62 -0
  125. data/lib/ucode/glyphs/source.rb +48 -0
  126. data/lib/ucode/glyphs/source_builder.rb +61 -0
  127. data/lib/ucode/glyphs/source_config/coverage_assertion.rb +79 -0
  128. data/lib/ucode/glyphs/source_config/gap_report.rb +54 -0
  129. data/lib/ucode/glyphs/source_config.rb +104 -0
  130. data/lib/ucode/glyphs/sources/pillar1_embedded_tounicode.rb +63 -0
  131. data/lib/ucode/glyphs/sources/pillar3_last_resort.rb +51 -0
  132. data/lib/ucode/glyphs/sources/tier1_real_font.rb +104 -0
  133. data/lib/ucode/glyphs/sources.rb +20 -0
  134. data/lib/ucode/glyphs/universal_set/builder.rb +161 -0
  135. data/lib/ucode/glyphs/universal_set/coverage_report.rb +139 -0
  136. data/lib/ucode/glyphs/universal_set/idempotency.rb +86 -0
  137. data/lib/ucode/glyphs/universal_set/manifest_accumulator.rb +195 -0
  138. data/lib/ucode/glyphs/universal_set/manifest_writer.rb +61 -0
  139. data/lib/ucode/glyphs/universal_set/pre_build_check.rb +197 -0
  140. data/lib/ucode/glyphs/universal_set/validator.rb +204 -0
  141. data/lib/ucode/glyphs/universal_set.rb +45 -0
  142. data/lib/ucode/glyphs.rb +7 -0
  143. data/lib/ucode/models/audit/baseline.rb +6 -0
  144. data/lib/ucode/models/audit/block_summary.rb +7 -0
  145. data/lib/ucode/models/audit/codepoint_provenance.rb +39 -0
  146. data/lib/ucode/models/audit/release_face.rb +42 -0
  147. data/lib/ucode/models/audit/release_formula.rb +33 -0
  148. data/lib/ucode/models/audit/release_manifest.rb +43 -0
  149. data/lib/ucode/models/audit/release_universal_set.rb +37 -0
  150. data/lib/ucode/models/audit.rb +9 -0
  151. data/lib/ucode/models/block.rb +2 -0
  152. data/lib/ucode/models/build_report.rb +109 -0
  153. data/lib/ucode/models/codepoint/glyph.rb +42 -0
  154. data/lib/ucode/models/codepoint.rb +3 -0
  155. data/lib/ucode/models/glyph_source.rb +86 -0
  156. data/lib/ucode/models/glyph_source_map.rb +138 -0
  157. data/lib/ucode/models/specialist_font.rb +70 -0
  158. data/lib/ucode/models/specialist_font_manifest.rb +48 -0
  159. data/lib/ucode/models/unihan_entry.rb +81 -9
  160. data/lib/ucode/models/unihan_field.rb +21 -0
  161. data/lib/ucode/models/universal_set_entry.rb +47 -0
  162. data/lib/ucode/models/universal_set_manifest.rb +78 -0
  163. data/lib/ucode/models/validation_report.rb +99 -0
  164. data/lib/ucode/models.rb +9 -0
  165. data/lib/ucode/parsers/named_sequences.rb +5 -5
  166. data/lib/ucode/parsers/unihan.rb +50 -19
  167. data/lib/ucode/repo/aggregate_writer.rb +82 -288
  168. data/lib/ucode/repo/block_feed_emitter.rb +153 -0
  169. data/lib/ucode/repo/build_report_accumulator.rb +138 -0
  170. data/lib/ucode/repo/build_report_writer.rb +46 -0
  171. data/lib/ucode/repo/build_validator.rb +229 -0
  172. data/lib/ucode/repo/codepoint_writer.rb +50 -1
  173. data/lib/ucode/repo/paths.rb +8 -0
  174. data/lib/ucode/repo/writers/blocks_writer.rb +73 -0
  175. data/lib/ucode/repo/writers/enums_writer.rb +38 -0
  176. data/lib/ucode/repo/writers/indexes_writer.rb +53 -0
  177. data/lib/ucode/repo/writers/manifest_writer.rb +78 -0
  178. data/lib/ucode/repo/writers/named_sequences_writer.rb +47 -0
  179. data/lib/ucode/repo/writers/planes_writer.rb +82 -0
  180. data/lib/ucode/repo/writers/relationships_writer.rb +71 -0
  181. data/lib/ucode/repo/writers/scripts_writer.rb +54 -0
  182. data/lib/ucode/repo/writers.rb +20 -0
  183. data/lib/ucode/repo.rb +5 -0
  184. data/lib/ucode/version.rb +1 -1
  185. data/schema/block-feed.output.schema.yml +134 -0
  186. metadata +158 -4
  187. data/Gemfile.lock +0 -406
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ require "ucode/models/glyph_source"
6
+
7
+ module Ucode
8
+ module Models
9
+ # Top-level shape of `config/unicode17_universal_glyph_set.yml`.
10
+ # Pairs envelope metadata (Unicode + ucode version, generated_at)
11
+ # with the block→sources map itself.
12
+ #
13
+ # Block keys are the verbatim Unicode block name with runs of
14
+ # whitespace collapsed to a single underscore — the canonical
15
+ # block id used everywhere else in this codebase (see
16
+ # {Ucode::Parsers::Blocks}): "Basic_Latin", "Greek_and_Coptic",
17
+ # "CJK_Unified_Ideographs_Extension_J". Never slugified beyond
18
+ # whitespace collapsing.
19
+ #
20
+ # Wire shape (note: `map:` is a hash keyed by block id, not an
21
+ # array):
22
+ #
23
+ # unicode_version: "17.0.0"
24
+ # ucode_version: "0.2.0"
25
+ # generated_at: "2026-06-28T00:00:00Z"
26
+ # default_sources: # applies when a block's sources are absent/empty
27
+ # - kind: fontist
28
+ # label: noto-sans
29
+ # priority: 1
30
+ # license: OFL
31
+ # map:
32
+ # Basic_Latin:
33
+ # sources:
34
+ # - kind: fontist
35
+ # label: noto-sans
36
+ # priority: 1
37
+ # Sidetic:
38
+ # sources: []
39
+ #
40
+ # An entry with `sources: []` (or omitted) is valid: it declares
41
+ # "no block-specific Tier 1 font; fall back to `default_sources`,
42
+ # then to Pillars 1-3". The fallback chain is implemented in
43
+ # {#sources_for}; the raw map is left untouched.
44
+ #
45
+ # The hash is stored as a raw `:hash` attribute (lutaml-model
46
+ # collection semantics don't pair cleanly with a hash-keyed wire
47
+ # shape); the typed accessors wrap each entry's raw hashes in
48
+ # {GlyphSource} instances on demand.
49
+ class GlyphSourceMap < Lutaml::Model::Serializable
50
+ attribute :unicode_version, :string
51
+ attribute :ucode_version, :string
52
+ attribute :generated_at, :string
53
+ attribute :default_sources_raw, :hash, collection: true, default: -> { [] }
54
+ attribute :block_sources, :hash, default: -> { {} }
55
+
56
+ key_value do
57
+ map "unicode_version", to: :unicode_version
58
+ map "ucode_version", to: :ucode_version
59
+ map "generated_at", to: :generated_at
60
+ map "default_sources", to: :default_sources_raw
61
+ map "map", to: :block_sources
62
+ end
63
+
64
+ # @param block_id [String] verbatim block id (underscore form)
65
+ # @return [Array<GlyphSource>] sources for the block, in
66
+ # priority order (ascending). Falls through block-specific →
67
+ # `default_sources` → empty.
68
+ def sources_for(block_id)
69
+ raw = block_sources[block_id]
70
+ list = extract_sources_list(raw)
71
+ list = default_sources_list if list.empty?
72
+ list.map { |h| GlyphSource.from_hash(h.transform_keys(&:to_s)) }
73
+ .sort_by(&:priority)
74
+ end
75
+
76
+ # @return [Array<GlyphSource>] the top-level default sources,
77
+ # typed and priority-sorted. Empty when not declared.
78
+ def default_sources
79
+ default_sources_list
80
+ .map { |h| GlyphSource.from_hash(h.transform_keys(&:to_s)) }
81
+ .sort_by(&:priority)
82
+ end
83
+
84
+ # @param block_id [String]
85
+ # @return [Boolean] true if the block has any entry in the map
86
+ # (even with empty sources). Does not consider `default_sources`.
87
+ def has_block?(block_id)
88
+ block_sources.key?(block_id)
89
+ end
90
+
91
+ # @return [Array<String>] every block_id that appears in the map
92
+ # (regardless of whether it has sources).
93
+ def block_ids
94
+ block_sources.keys
95
+ end
96
+
97
+ # @return [Array<String>] block_ids whose own `sources:` list has
98
+ # at least one entry. Blocks relying on `default_sources` are
99
+ # excluded — they have no block-specific policy.
100
+ def configured_block_ids
101
+ block_sources.each_with_object([]) do |(block_id, raw), acc|
102
+ acc << block_id if any_sources?(raw)
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ # Each block's value in the YAML is either:
109
+ # - `{sources: [...]}` (canonical form), or
110
+ # - `[...]` (shorthand: the sources list directly).
111
+ # Return the sources array in both cases; empty for `nil`.
112
+ def extract_sources_list(raw)
113
+ return [] if raw.nil?
114
+ return raw if raw.is_a?(Array)
115
+ return Array(raw["sources"]) if raw.is_a?(Hash) && raw.key?("sources")
116
+ return Array(raw[:sources]) if raw.is_a?(Hash) && raw.key?(:sources)
117
+
118
+ []
119
+ end
120
+
121
+ # Each block's value in the YAML is either `{sources: [...]}` or
122
+ # directly an array (shorthand). Normalize to the array of
123
+ # source-hashes form.
124
+ def any_sources?(raw)
125
+ return false if raw.nil?
126
+ return raw.any? if raw.is_a?(Array)
127
+
128
+ raw.is_a?(Hash) && Array(raw["sources"] || raw[:sources]).any?
129
+ end
130
+
131
+ # `default_sources` on the wire is a list of source hashes. Older
132
+ # configs may omit it; treat absence as an empty list.
133
+ def default_sources_list
134
+ Array(default_sources_raw)
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Ucode
6
+ module Models
7
+ # One entry in `config/specialist_fonts.yml` — a Tier 1 font that
8
+ # fontist's formula index does not carry (academic sites, SIL
9
+ # downloads, GitHub releases). The fetcher walks a list of these
10
+ # and materializes each `path` on disk.
11
+ #
12
+ # Wire shape (YAML):
13
+ #
14
+ # label: Lentariso
15
+ # version: "1.033"
16
+ # license: OFL
17
+ # url: "https://github.com/.../Lentariso.otf"
18
+ # sha256: "<hex>" # null until first successful fetch
19
+ # path: "data/fonts/Lentariso.otf"
20
+ # extract: false
21
+ # extract_member: null # required when extract: true
22
+ # provenance: "Imperial Aramaic / Phoenician / Sidetic coverage"
23
+ #
24
+ # `url: null` marks a local-only entry: the user supplies the
25
+ # file at `path` (which may use `~` and shell globs); the fetcher
26
+ # never attempts a network download for these.
27
+ class SpecialistFont < Lutaml::Model::Serializable
28
+ LICENSE_OFL = "OFL"
29
+ private_constant :LICENSE_OFL
30
+
31
+ attribute :label, :string
32
+ attribute :version, :string
33
+ attribute :license, :string, default: -> { LICENSE_OFL }
34
+ attribute :url, :string
35
+ attribute :sha256, :string
36
+ attribute :path, :string
37
+ attribute :extract, :boolean, default: -> { false }
38
+ attribute :extract_member, :string
39
+ attribute :provenance, :string
40
+
41
+ key_value do
42
+ map "label", to: :label
43
+ map "version", to: :version
44
+ map "license", to: :license
45
+ map "url", to: :url
46
+ map "sha256", to: :sha256
47
+ map "path", to: :path
48
+ map "extract", to: :extract
49
+ map "extract_member", to: :extract_member
50
+ map "provenance", to: :provenance
51
+ end
52
+
53
+ def local_only?
54
+ url.nil? || url.empty?
55
+ end
56
+
57
+ def ofl?
58
+ license == LICENSE_OFL
59
+ end
60
+
61
+ def hash_known?
62
+ !sha256.nil? && !sha256.empty?
63
+ end
64
+
65
+ def extract?
66
+ extract == true
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ require "ucode/models/specialist_font"
6
+
7
+ module Ucode
8
+ module Models
9
+ # Typed view over `config/specialist_fonts.yml`. Carries the full
10
+ # list of {SpecialistFont} entries; provides lookup by label so
11
+ # the fetcher can honor `--label Lentariso` without scanning the
12
+ # array itself.
13
+ #
14
+ # The manifest is pure data — it does not know the path it was
15
+ # loaded from. Persistence of computed SHA256 hashes back to disk
16
+ # is the responsibility of {Ucode::Fetch::SpecialistFontFetcher},
17
+ # which owns the file path and writes atomically after a run.
18
+ class SpecialistFontManifest < Lutaml::Model::Serializable
19
+ attribute :fonts, SpecialistFont, collection: true
20
+
21
+ key_value do
22
+ map "fonts", to: :fonts
23
+ end
24
+
25
+ # @param label [String] exact label match
26
+ # @return [SpecialistFont, nil]
27
+ def find_by_label(label)
28
+ fonts.find { |font| font.label == label }
29
+ end
30
+
31
+ # @return [Array<String>] labels of every entry, in declared order
32
+ def labels
33
+ fonts.map(&:label)
34
+ end
35
+
36
+ # @param label [String]
37
+ # @return [SpecialistFontManifest] a new manifest containing only
38
+ # the matching font. Returns self unchanged if the label is
39
+ # unknown (the fetcher reports it as a separate failure).
40
+ def only(label)
41
+ match = find_by_label(label)
42
+ return self if match.nil?
43
+
44
+ self.class.new(fonts: [match])
45
+ end
46
+ end
47
+ end
48
+ end
@@ -2,21 +2,93 @@
2
2
 
3
3
  require "lutaml/model"
4
4
 
5
+ require "ucode/models/unihan_field"
6
+
5
7
  module Ucode
6
8
  module Models
7
- # Unihan dictionary data for CJK codepoints. Flat-hash design: every
8
- # `kFoo` field is a key in `fields`, with array values (Unihan fields
9
- # are space-separated lists; uniform arrays simplify the shape).
9
+ # Unihan dictionary data for CJK codepoints, grouped into the 8
10
+ # categories defined by the Unihan standard. Each category
11
+ # corresponds to one Unihan file:
12
+ #
13
+ # Unihan_DictionaryIndices.txt → dictionary_indices
14
+ # Unihan_DictionaryLikeData.txt → dictionary_like_data
15
+ # Unihan_IRGSources.txt → irg_sources
16
+ # Unihan_NumericValues.txt → numeric_values
17
+ # Unihan_RadicalStrokeCounts.txt → radical_stroke_counts
18
+ # Unihan_Readings.txt → readings
19
+ # Unihan_Variants.txt → variants
20
+ # Unihan_OtherMappings.txt → other_mappings
10
21
  #
11
- # The semantic grouping (readings / radicals / variants / sources / etc.)
12
- # is a presentation concern, derived client-side by prefix. The data
13
- # model stays open Unihan adds fields across versions, and the hash
14
- # absorbs additions without model changes.
22
+ # Each category attribute is a collection of {UnihanField} records.
23
+ # Category is set at parse time from the source filename (via
24
+ # `FILE_TO_CATEGORY`) Unicode does not reorganize files across
25
+ # versions, so this is stable without per-field hardcoding.
15
26
  class UnihanEntry < Lutaml::Model::Serializable
16
- attribute :fields, :hash, default: -> { {} }
27
+ # Symbol → attribute name. Mirrors the 8 Unihan files.
28
+ CATEGORIES = {
29
+ dictionary_indices: :dictionary_indices,
30
+ dictionary_like_data: :dictionary_like_data,
31
+ irg_sources: :irg_sources,
32
+ numeric_values: :numeric_values,
33
+ radical_stroke_counts: :radical_stroke_counts,
34
+ readings: :readings,
35
+ variants: :variants,
36
+ other_mappings: :other_mappings,
37
+ }.freeze
38
+
39
+ # Filename → category symbol. Used by the parser to bucket
40
+ # records without callers needing to know the mapping.
41
+ FILE_TO_CATEGORY = {
42
+ "Unihan_DictionaryIndices.txt" => :dictionary_indices,
43
+ "Unihan_DictionaryLikeData.txt" => :dictionary_like_data,
44
+ "Unihan_IRGSources.txt" => :irg_sources,
45
+ "Unihan_NumericValues.txt" => :numeric_values,
46
+ "Unihan_RadicalStrokeCounts.txt" => :radical_stroke_counts,
47
+ "Unihan_Readings.txt" => :readings,
48
+ "Unihan_Variants.txt" => :variants,
49
+ "Unihan_OtherMappings.txt" => :other_mappings,
50
+ }.freeze
51
+
52
+ attribute :dictionary_indices, UnihanField, collection: true, default: -> { [] }
53
+ attribute :dictionary_like_data, UnihanField, collection: true, default: -> { [] }
54
+ attribute :irg_sources, UnihanField, collection: true, default: -> { [] }
55
+ attribute :numeric_values, UnihanField, collection: true, default: -> { [] }
56
+ attribute :radical_stroke_counts, UnihanField, collection: true, default: -> { [] }
57
+ attribute :readings, UnihanField, collection: true, default: -> { [] }
58
+ attribute :variants, UnihanField, collection: true, default: -> { [] }
59
+ attribute :other_mappings, UnihanField, collection: true, default: -> { [] }
60
+
61
+ # Pushes a field into the right category bucket. Used by the
62
+ # Coordinator when streaming records from the parser.
63
+ #
64
+ # @param category [Symbol] one of CATEGORIES keys
65
+ # @param name [String] e.g. "kMandarin"
66
+ # @param values [Array<String>] space-split values from Unihan
67
+ def add(category, name, values)
68
+ attr_name = CATEGORIES.fetch(category) { return }
69
+ public_send(attr_name) << UnihanField.new(name: name, values: values)
70
+ end
71
+
72
+ # True if any category has data.
73
+ def any?
74
+ CATEGORIES.keys.any? { |sym| !public_send(sym).empty? }
75
+ end
76
+
77
+ # All fields across every category, flattened to {name => values}.
78
+ # Iteration helper for consumers that want a flat view (search
79
+ # indexing, downstream filtering).
80
+ #
81
+ # @return [Hash{String => Array<String>}]
82
+ def all_fields
83
+ CATEGORIES.keys.each_with_object({}) do |sym, h|
84
+ public_send(sym).each { |f| h[f.name] = f.values }
85
+ end
86
+ end
17
87
 
18
88
  key_value do
19
- map "fields", to: :fields
89
+ CATEGORIES.each do |symbol, attr_name|
90
+ map attr_name, to: symbol
91
+ end
20
92
  end
21
93
  end
22
94
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Ucode
6
+ module Models
7
+ # One Unihan field assignment: a k-field name plus its space-split
8
+ # values. e.g. `kMandarin → ["jìng"]`, `kHanyuPinyin → ["64047.030:jìng"]`.
9
+ # The values list is uniform across all Unihan fields — even single-valued
10
+ # ones are arrays, which simplifies consumer logic.
11
+ class UnihanField < Lutaml::Model::Serializable
12
+ attribute :name, :string
13
+ attribute :values, :string, collection: true, default: -> { [] }
14
+
15
+ key_value do
16
+ map "name", to: :name
17
+ map "values", to: :values
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Ucode
6
+ module Models
7
+ # One row in a {UniversalSetManifest}. Records the resolved glyph
8
+ # for a single codepoint: which tier produced it, which source
9
+ # font, and a stable content hash + size so downstream consumers
10
+ # can detect changes without re-reading the SVG.
11
+ #
12
+ # Wire shape (one entry per assigned codepoint in the manifest's
13
+ # `entries:` array):
14
+ #
15
+ # {
16
+ # "codepoint": 65,
17
+ # "id": "U+0041",
18
+ # "tier": "tier-1",
19
+ # "source": "noto-sans",
20
+ # "svg_sha256": "abc...",
21
+ # "svg_size_bytes": 412
22
+ # }
23
+ #
24
+ # `source` is the source identifier extracted from the resolver
25
+ # {Ucode::Glyphs::Source::Result#provenance} — i.e. the part after
26
+ # the `tier:` prefix ("noto-sans" for "tier-1:noto-sans"). This is
27
+ # what audits (TODO 25) group by when answering "how many
28
+ # codepoints does font X cover in this set?".
29
+ class UniversalSetEntry < Lutaml::Model::Serializable
30
+ attribute :codepoint, :integer
31
+ attribute :id, :string
32
+ attribute :tier, :string
33
+ attribute :source, :string
34
+ attribute :svg_sha256, :string
35
+ attribute :svg_size_bytes, :integer, default: 0
36
+
37
+ key_value do
38
+ map "codepoint", to: :codepoint
39
+ map "id", to: :id
40
+ map "tier", to: :tier
41
+ map "source", to: :source
42
+ map "svg_sha256", to: :svg_sha256
43
+ map "svg_size_bytes", to: :svg_size_bytes
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ require "ucode/models/universal_set_entry"
6
+
7
+ module Ucode
8
+ module Models
9
+ # Manifest emitted at the end of a universal glyph set build
10
+ # (TODO 24). The single index into the set: every codepoint that
11
+ # was attempted gets one {UniversalSetEntry}, and the totals +
12
+ # per-tier rollups let consumers (audits, fontist.org) answer
13
+ # "what does this set cover?" without reading every SVG.
14
+ #
15
+ # Wire shape:
16
+ #
17
+ # {
18
+ # "unicode_version": "17.0.0",
19
+ # "ucode_version": "0.2.0",
20
+ # "generated_at": "2026-06-28T00:00:00Z",
21
+ # "source_config_sha256": "abc...",
22
+ # "totals": {
23
+ # "codepoints_assigned": 150012,
24
+ # "codepoints_built": 150012,
25
+ # "codepoints_skipped": 0,
26
+ # "codepoints_failed": 0
27
+ # },
28
+ # "by_tier": {
29
+ # "tier-1": 148512, "pillar-1": 800,
30
+ # "pillar-2": 200, "pillar-3": 1500
31
+ # },
32
+ # "entries": [ { ... UniversalSetEntry ... }, ... ]
33
+ # }
34
+ #
35
+ # `source_config_sha256` pins which Tier 1 source map produced
36
+ # this set. Audits use it to detect drift between the reference
37
+ # set and the config they were validated against.
38
+ #
39
+ # This class is passive — accumulation logic lives in
40
+ # {Ucode::Glyphs::UniversalSet::ManifestAccumulator}; this class
41
+ # only describes the wire shape and handles (de)serialization via
42
+ # lutaml-model.
43
+ class UniversalSetManifest < Lutaml::Model::Serializable
44
+ # Total counts for one build run.
45
+ class Totals < Lutaml::Model::Serializable
46
+ attribute :codepoints_assigned, :integer, default: 0
47
+ attribute :codepoints_built, :integer, default: 0
48
+ attribute :codepoints_skipped, :integer, default: 0
49
+ attribute :codepoints_failed, :integer, default: 0
50
+
51
+ key_value do
52
+ map "codepoints_assigned", to: :codepoints_assigned
53
+ map "codepoints_built", to: :codepoints_built
54
+ map "codepoints_skipped", to: :codepoints_skipped
55
+ map "codepoints_failed", to: :codepoints_failed
56
+ end
57
+ end
58
+
59
+ attribute :unicode_version, :string
60
+ attribute :ucode_version, :string
61
+ attribute :generated_at, :string
62
+ attribute :source_config_sha256, :string
63
+ attribute :totals, Totals
64
+ attribute :by_tier, :hash, default: -> { {} }
65
+ attribute :entries, UniversalSetEntry, collection: true, default: -> { [] }
66
+
67
+ key_value do
68
+ map "unicode_version", to: :unicode_version
69
+ map "ucode_version", to: :ucode_version
70
+ map "generated_at", to: :generated_at
71
+ map "source_config_sha256", to: :source_config_sha256
72
+ map "totals", to: :totals
73
+ map "by_tier", to: :by_tier
74
+ map "entries", to: :entries
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Ucode
6
+ module Models
7
+ # Post-build validation report (TODO 21 §Validation). Emitted as
8
+ # `output/validation-report.json` by {Ucode::Repo::BuildValidator}
9
+ # after a canonical build run. Records the outcome of the four
10
+ # automated validation checks:
11
+ #
12
+ # 1. `completeness` — every codepoint folder has both
13
+ # `index.json` and `glyph.svg`.
14
+ # 2. `schema` — every `index.json` deserializes via
15
+ # `Ucode::Models::CodePoint.from_hash`.
16
+ # 3. `provenance_sanity` — every deserialized CodePoint carries
17
+ # a non-nil `glyph.source.tier`.
18
+ # 4. `block_coverage` — per-block built count matches the
19
+ # baseline (skipped when no baseline is supplied).
20
+ #
21
+ # The fifth TODO 21 check (sample inspection) is manual and out
22
+ # of scope for the automated validator.
23
+ #
24
+ # Like {BuildReport}, this model is passive: the accumulation
25
+ # logic lives in {Ucode::Repo::BuildValidator}; this class only
26
+ # describes the wire shape and handles (de)serialization.
27
+ class ValidationReport < Lutaml::Model::Serializable
28
+ # Aggregate pass/fail counts for the run.
29
+ class Totals < Lutaml::Model::Serializable
30
+ attribute :codepoints_checked, :integer, default: 0
31
+ attribute :failures, :integer, default: 0
32
+ attribute :checks_run, :integer, default: 0
33
+ attribute :checks_passed, :integer, default: 0
34
+
35
+ key_value do
36
+ map "codepoints_checked", to: :codepoints_checked
37
+ map "failures", to: :failures
38
+ map "checks_run", to: :checks_run
39
+ map "checks_passed", to: :checks_passed
40
+ end
41
+ end
42
+
43
+ # Per-check summary. `status` is one of `passed` / `failed` /
44
+ # `skipped`. `total` is the number of codepoints the check
45
+ # evaluated against (0 for `skipped`). `failures` is the count
46
+ # of recorded failures for this check.
47
+ class CheckSummary < Lutaml::Model::Serializable
48
+ STATUS_PASSED = "passed"
49
+ STATUS_FAILED = "failed"
50
+ STATUS_SKIPPED = "skipped"
51
+
52
+ attribute :name, :string
53
+ attribute :status, :string
54
+ attribute :total, :integer, default: 0
55
+ attribute :failures, :integer, default: 0
56
+
57
+ key_value do
58
+ map "name", to: :name
59
+ map "status", to: :status
60
+ map "total", to: :total
61
+ map "failures", to: :failures
62
+ end
63
+ end
64
+
65
+ # One failure record. `codepoint` is the integer codepoint (or
66
+ # nil for structural failures like block_coverage); `block` is
67
+ # the verbatim block id (folder name); `check` names the check
68
+ # that produced this failure; `message` is a free-form
69
+ # human-readable explanation.
70
+ class Failure < Lutaml::Model::Serializable
71
+ attribute :codepoint, :integer
72
+ attribute :block, :string
73
+ attribute :check, :string
74
+ attribute :message, :string
75
+
76
+ key_value do
77
+ map "codepoint", to: :codepoint
78
+ map "block", to: :block
79
+ map "check", to: :check
80
+ map "message", to: :message
81
+ end
82
+ end
83
+
84
+ attribute :unicode_version, :string
85
+ attribute :generated_at, :string
86
+ attribute :totals, Totals
87
+ attribute :checks, CheckSummary, collection: true, default: -> { [] }
88
+ attribute :failures, Failure, collection: true, default: -> { [] }
89
+
90
+ key_value do
91
+ map "unicode_version", to: :unicode_version
92
+ map "generated_at", to: :generated_at
93
+ map "totals", to: :totals
94
+ map "checks", to: :checks
95
+ map "failures", to: :failures
96
+ end
97
+ end
98
+ end
99
+ end
data/lib/ucode/models.rb CHANGED
@@ -31,6 +31,7 @@ module Ucode
31
31
  autoload :Script, "ucode/models/script"
32
32
  autoload :CodePoint, "ucode/models/codepoint"
33
33
  autoload :UnihanEntry, "ucode/models/unihan_entry"
34
+ autoload :UnihanField, "ucode/models/unihan_field"
34
35
  autoload :NamesListEntry, "ucode/models/names_list_entry"
35
36
  autoload :NameAlias, "ucode/models/name_alias"
36
37
  autoload :NamedSequence, "ucode/models/named_sequence"
@@ -43,5 +44,13 @@ module Ucode
43
44
  autoload :BinaryPropertyAssignment, "ucode/models/binary_property_assignment"
44
45
  autoload :Relationship, "ucode/models/relationship"
45
46
  autoload :Audit, "ucode/models/audit"
47
+ autoload :BuildReport, "ucode/models/build_report"
48
+ autoload :ValidationReport, "ucode/models/validation_report"
49
+ autoload :GlyphSource, "ucode/models/glyph_source"
50
+ autoload :GlyphSourceMap, "ucode/models/glyph_source_map"
51
+ autoload :SpecialistFont, "ucode/models/specialist_font"
52
+ autoload :SpecialistFontManifest, "ucode/models/specialist_font_manifest"
53
+ autoload :UniversalSetEntry, "ucode/models/universal_set_entry"
54
+ autoload :UniversalSetManifest, "ucode/models/universal_set_manifest"
46
55
  end
47
56
  end
@@ -8,10 +8,10 @@ module Ucode
8
8
  # Parses `NamedSequences.txt` — named multi-codepoint sequences.
9
9
  #
10
10
  # Format (UAX #44):
11
- # cp1 cp2 cp3 ...; Name
11
+ # Name; cp1 cp2 cp3 ...
12
12
  #
13
- # The first field is a space-separated list of hex codepoints; the
14
- # second is the human-readable name.
13
+ # The first field is the human-readable name; the second is a
14
+ # space-separated list of hex codepoints.
15
15
  class NamedSequences < Base
16
16
  class << self
17
17
  # Yields one NamedSequence per non-comment line. Returns a lazy
@@ -23,8 +23,8 @@ module Ucode
23
23
  fields = line.fields
24
24
  next if fields.length < 2
25
25
 
26
- sequence_field = fields[0]
27
- name = fields[1]
26
+ name = fields[0]
27
+ sequence_field = fields[1]
28
28
  next if name.nil? || name.empty?
29
29
 
30
30
  yield Models::NamedSequence.new(