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
@@ -37,12 +37,32 @@ module Ucode
37
37
  Unihan_OtherMappings.txt
38
38
  ].freeze
39
39
 
40
+ # Filename → category symbol. The parser tags every Record
41
+ # with the category derived from its source file, so consumers
42
+ # (Coordinator → UnihanEntry) don't need to know the mapping.
43
+ # Unicode does not reorganize files across versions, so this
44
+ # mapping is stable without per-field hardcoding.
45
+ FILE_TO_CATEGORY = {
46
+ "Unihan_DictionaryIndices.txt" => :dictionary_indices,
47
+ "Unihan_DictionaryLikeData.txt" => :dictionary_like_data,
48
+ "Unihan_IRGSources.txt" => :irg_sources,
49
+ "Unihan_NumericValues.txt" => :numeric_values,
50
+ "Unihan_RadicalStrokeCounts.txt" => :radical_stroke_counts,
51
+ "Unihan_Readings.txt" => :readings,
52
+ "Unihan_Variants.txt" => :variants,
53
+ "Unihan_OtherMappings.txt" => :other_mappings,
54
+ }.freeze
55
+
40
56
  # Stream record: one Unihan line. Internal pipeline data — a Struct
41
57
  # avoids lutaml-model ceremony for transient values. The final
42
58
  # `UnihanEntry` model carries the merged, persisted shape. The
43
59
  # member is `field_values` (not `values`) to avoid overriding
44
60
  # `Struct#values` (the array of all member values).
45
- Record = Struct.new(:cp, :field, :field_values, keyword_init: true) do
61
+ #
62
+ # `category` is the symbol UnihanEntry uses to bucket the field
63
+ # into its category attribute (readings / variants / etc.). Set
64
+ # by `each_in_dir` from the source filename via FILE_TO_CATEGORY.
65
+ Record = Struct.new(:cp, :field, :field_values, :category, keyword_init: true) do
46
66
  def cp_id
47
67
  format("U+%04X", cp)
48
68
  end
@@ -50,25 +70,16 @@ module Ucode
50
70
 
51
71
  class << self
52
72
  # Yields one Record per non-comment line in a single Unihan file.
53
- # Returns a lazy Enumerator when no block is given.
54
- def each_record(path)
55
- return enum_for(:each_record, path) unless block_given?
73
+ # The caller must pass the source filename so the Record carries
74
+ # its category. Returns a lazy Enumerator when no block is given.
75
+ def each_record(path, filename: nil)
76
+ return enum_for(:each_record, path, filename: filename) unless block_given?
56
77
 
57
78
  path_str = path.to_s
58
- lineno = 0
79
+ category = FILE_TO_CATEGORY.fetch(filename || File.basename(path_str), nil)
59
80
 
60
- File.foreach(path_str) do |raw|
61
- lineno += 1
62
- line = raw.chomp
63
- next if line.empty? || line.start_with?("#")
64
-
65
- begin
66
- yield parse_line(line)
67
- rescue MalformedLineError => e
68
- e.context[:file] ||= path_str
69
- e.context[:line] ||= lineno
70
- raise
71
- end
81
+ each_line_with_lineno(path_str) do |line, lineno|
82
+ yield tagged_record(line, category, path_str, lineno)
72
83
  end
73
84
 
74
85
  nil
@@ -76,7 +87,8 @@ module Ucode
76
87
 
77
88
  # Iterates every known Unihan file in `dir`, yielding one Record
78
89
  # per data line across all files. Missing files are silently
79
- # skipped (incremental runs, partial downloads).
90
+ # skipped (incremental runs, partial downloads). Each Record
91
+ # carries its category so callers don't need to re-derive it.
80
92
  def each_in_dir(dir)
81
93
  return enum_for(:each_in_dir, dir) unless block_given?
82
94
 
@@ -85,7 +97,7 @@ module Ucode
85
97
  path = dir_path.join(filename)
86
98
  next unless path.exist?
87
99
 
88
- each_record(path) { |record| yield record }
100
+ each_record(path, filename: filename) { |record| yield record }
89
101
  end
90
102
 
91
103
  nil
@@ -93,6 +105,25 @@ module Ucode
93
105
 
94
106
  private
95
107
 
108
+ def each_line_with_lineno(path_str)
109
+ lineno = 0
110
+ File.foreach(path_str) do |raw|
111
+ lineno += 1
112
+ line = raw.chomp
113
+ next if line.empty? || line.start_with?("#")
114
+
115
+ yield line, lineno
116
+ end
117
+ end
118
+
119
+ def tagged_record(line, category, path_str, lineno)
120
+ parse_line(line).tap { |r| r.category = category }
121
+ rescue MalformedLineError => e
122
+ e.context[:file] ||= path_str
123
+ e.context[:line] ||= lineno
124
+ raise
125
+ end
126
+
96
127
  # Parses one TAB-separated Unihan data line into a Record. The
97
128
  # `split("\t", 3)` limit preserves any tabs inside the value
98
129
  # (defensive — real Unihan data does not contain them).
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
- require "json"
5
- require "time"
6
4
 
7
- require "ucode/models"
8
- require "ucode/repo/atomic_writes"
9
- require "ucode/repo/paths"
5
+ require "ucode/repo/writers/planes_writer"
6
+ require "ucode/repo/writers/blocks_writer"
7
+ require "ucode/repo/writers/scripts_writer"
8
+ require "ucode/repo/writers/indexes_writer"
9
+ require "ucode/repo/writers/relationships_writer"
10
+ require "ucode/repo/writers/enums_writer"
11
+ require "ucode/repo/writers/named_sequences_writer"
12
+ require "ucode/repo/writers/manifest_writer"
10
13
 
11
14
  module Ucode
12
15
  module Repo
@@ -14,71 +17,35 @@ module Ucode
14
17
  #
15
18
  # output/planes/<n>.json
16
19
  # output/blocks/<ID>.json
17
- # output/blocks/index.json (block index)
20
+ # output/blocks/index.json
18
21
  # output/scripts/<code>.json
19
- # output/index/names.json (cp_id → name)
20
- # output/index/labels.json (cp_id → {name, gc, sc})
21
- # output/index/codepoint_to_block.json (cp_id → block_id)
22
- # output/relationships/*.json (per-property tables)
23
- # output/enums.json (property aliases + value aliases)
22
+ # output/index/names.json
23
+ # output/index/labels.json
24
+ # output/index/codepoint_to_block.json
25
+ # output/relationships/*.json
26
+ # output/enums.json
24
27
  # output/named_sequences/<slug>.json
25
28
  # output/manifest.json
26
29
  #
27
- # **Single pass**: callers feed one CodePoint at a time via `#add`;
28
- # `#flush` writes all derived files using the Coordinator's indices
29
- # for the static tables (relationships, enums, named sequences).
30
+ # **Single pass**: callers feed one CodePoint at a time via `#add`,
31
+ # which folds into the streaming accumulators. `#flush` then
32
+ # composes eight per-concern writer classes (one per output kind)
33
+ # and runs them in order. Adding a new aggregate = adding one
34
+ # writer class + one line here. See Candidate 5 of the 2026-06-29
35
+ # architecture review.
30
36
  #
31
37
  # **MECE**:
32
38
  # - paths: `Repo::Paths`
33
39
  # - atomic writes: `Repo::AtomicWrites`
34
- # - stream aggregation: this class
40
+ # - stream aggregation: this class (the `#add` half)
41
+ # - per-concern writers: `Repo::Writers::*`
35
42
  # - serialization: lutaml-model `to_yaml_hash` / `to_json`
36
43
  class AggregateWriter
37
- include AtomicWrites
38
-
39
- # Static metadata for the 17 Unicode planes. Planes 4–13 are
40
- # unassigned in Unicode 17; their entries use placeholder names.
41
- PLANE_TABLE = {
42
- 0 => ["Basic Multilingual Plane", "BMP"],
43
- 1 => ["Supplementary Multilingual Plane", "SMP"],
44
- 2 => ["Supplementary Ideographic Plane", "SIP"],
45
- 3 => ["Tertiary Ideographic Plane", "TIP"],
46
- 4 => ["Unassigned Plane 4", "—"],
47
- 5 => ["Unassigned Plane 5", "—"],
48
- 6 => ["Unassigned Plane 6", "—"],
49
- 7 => ["Unassigned Plane 7", "—"],
50
- 8 => ["Unassigned Plane 8", "—"],
51
- 9 => ["Unassigned Plane 9", "—"],
52
- 10 => ["Unassigned Plane 10", "—"],
53
- 11 => ["Unassigned Plane 11", "—"],
54
- 12 => ["Unassigned Plane 12", "—"],
55
- 13 => ["Unassigned Plane 13", "—"],
56
- 14 => ["Supplementary Special-purpose Plane", "SSP"],
57
- 15 => ["Supplementary Private Use Area-A", "SPUA-A"],
58
- 16 => ["Supplementary Private Use Area-B", "SPUA-B"],
59
- }.freeze
60
- private_constant :PLANE_TABLE
61
-
62
- # Coordinator::Indices fields paired with the file slug used
63
- # under `output/relationships/`. Each field is a Hash<Integer,
64
- # Record> or Hash<Integer, Array<Record>>.
65
- RELATIONSHIP_SOURCES = {
66
- special_casing: "special_casing",
67
- case_folding: "case_folding",
68
- bidi_mirroring: "bidi_mirroring",
69
- bidi_brackets: "bidi_brackets",
70
- cjk_radicals: "cjk_radicals",
71
- standardized_variants: "standardized_variants",
72
- name_aliases: "name_aliases",
73
- }.freeze
74
- private_constant :RELATIONSHIP_SOURCES
75
-
76
- attr_reader :codepoint_count
77
-
78
44
  # @param output_root [String, Pathname]
79
45
  def initialize(output_root)
80
46
  @output_root = Pathname.new(output_root)
81
47
  @block_codepoint_ids = Hash.new { |h, k| h[k] = [] }
48
+ @block_ages = Hash.new { |h, k| h[k] = nil }
82
49
  @script_codepoint_ids = Hash.new { |h, k| h[k] = [] }
83
50
  @names_index = {}
84
51
  @labels_index = {}
@@ -86,6 +53,8 @@ module Ucode
86
53
  @codepoint_count = 0
87
54
  end
88
55
 
56
+ attr_reader :codepoint_count
57
+
89
58
  # Fold one CodePoint into the stream accumulators. No-ops if the
90
59
  # cp has no block_id (it has no home in the output tree).
91
60
  # @param cp [Ucode::Models::CodePoint]
@@ -94,6 +63,7 @@ module Ucode
94
63
  return if cp.block_id.nil?
95
64
 
96
65
  @block_codepoint_ids[cp.block_id] << cp.id
66
+ track_block_age(cp)
97
67
  if cp.script_code
98
68
  @script_codepoint_ids[cp.script_code] << cp.id
99
69
  end
@@ -105,10 +75,8 @@ module Ucode
105
75
  @codepoint_count += 1
106
76
  end
107
77
 
108
- # Write every aggregate file. Optional params supply data that is
109
- # not in `Coordinator::Indices` (the Coordinator only resolves the
110
- # `sc` subset of PropertyValueAliases; the full alias tables and
111
- # the named sequences are passed through from the CLI/parsers).
78
+ # Compose the eight per-concern writers, run them in order, and
79
+ # return the total number of files written.
112
80
  #
113
81
  # @param ucd_version [String]
114
82
  # @param indices [Ucode::Coordinator::Indices]
@@ -116,19 +84,40 @@ module Ucode
116
84
  # @param property_value_aliases [Array<Ucode::Models::PropertyValueAlias>]
117
85
  # @param named_sequences [Array<Ucode::Models::NamedSequence>]
118
86
  # @param glyph_count [Integer]
119
- # @return [Integer] number of files written
87
+ # @return [Integer]
120
88
  def flush(ucd_version:, indices:, property_aliases: [],
121
89
  property_value_aliases: [], named_sequences: [], glyph_count: 0)
122
- writes = 0
123
- writes += write_planes(indices.blocks)
124
- writes += write_blocks(indices.blocks)
125
- writes += write_scripts(indices.scripts)
126
- writes += write_indexes
127
- writes += write_relationships(indices)
128
- writes += write_enums(property_aliases, property_value_aliases)
129
- writes += write_named_sequences(named_sequences)
130
- writes += write_manifest(ucd_version: ucd_version, glyph_count: glyph_count)
131
- writes
90
+ writers(ucd_version, indices, property_aliases, property_value_aliases,
91
+ named_sequences, glyph_count).sum(&:write)
92
+ end
93
+
94
+ # @api private — exposed for testing.
95
+ def writers(ucd_version, indices, property_aliases,
96
+ property_value_aliases, named_sequences, glyph_count)
97
+ [
98
+ Writers::PlanesWriter.new(output_root: @output_root, blocks: indices.blocks),
99
+ Writers::BlocksWriter.new(output_root: @output_root,
100
+ blocks: indices.blocks,
101
+ block_codepoint_ids: @block_codepoint_ids,
102
+ block_ages: @block_ages),
103
+ Writers::ScriptsWriter.new(output_root: @output_root,
104
+ scripts: indices.scripts,
105
+ script_codepoint_ids: @script_codepoint_ids),
106
+ Writers::IndexesWriter.new(output_root: @output_root,
107
+ names: @names_index,
108
+ labels: @labels_index,
109
+ cp_to_block: @cp_to_block),
110
+ Writers::RelationshipsWriter.new(output_root: @output_root, indices: indices),
111
+ Writers::EnumsWriter.new(output_root: @output_root,
112
+ property_aliases: property_aliases,
113
+ property_value_aliases: property_value_aliases),
114
+ Writers::NamedSequencesWriter.new(output_root: @output_root,
115
+ named_sequences: named_sequences),
116
+ Writers::ManifestWriter.new(output_root: @output_root,
117
+ ucd_version: ucd_version,
118
+ codepoint_count: @codepoint_count,
119
+ glyph_count: glyph_count),
120
+ ]
132
121
  end
133
122
 
134
123
  private
@@ -136,229 +125,34 @@ module Ucode
136
125
  # ---- Per-codepoint accumulator helpers ---------------------------
137
126
 
138
127
  def build_label(cp)
139
- label = { "name" => cp.name, "gc" => cp.general_category, "sc" => cp.script_code }
140
- label.reject { |_, v| v.nil? }
141
- end
142
-
143
- # ---- Plane files -------------------------------------------------
144
-
145
- def write_planes(blocks)
146
- plane_block_ids = group_block_ids_by_plane(blocks)
147
- count = 0
148
- (0..16).each do |n|
149
- path = Paths.plane_metadata_path(@output_root, n)
150
- count += 1 if write_atomic(path, plane_payload(n, plane_block_ids[n] || []))
151
- end
152
- count
153
- end
154
-
155
- def group_block_ids_by_plane(blocks)
156
- blocks.each_with_object(Hash.new { |h, k| h[k] = [] }) do |block, h|
157
- h[block.plane_number] << block.id
158
- end
159
- end
160
-
161
- def plane_payload(plane_number, block_ids)
162
- name, abbrev = PLANE_TABLE.fetch(plane_number)
163
- range_first = plane_number * 0x10000
164
- range_last = range_first + 0xFFFF
165
- to_pretty_json(
166
- "number" => plane_number,
167
- "name" => name,
168
- "abbrev" => abbrev,
169
- "range_first" => range_first,
170
- "range_last" => range_last,
171
- "block_ids" => block_ids,
172
- )
173
- end
174
-
175
- # ---- Block files -------------------------------------------------
176
-
177
- def write_blocks(blocks)
178
- count = blocks.sum do |block|
179
- path = Paths.block_metadata_path(@output_root, block.id)
180
- write_atomic(path, block_payload(block)) ? 1 : 0
181
- end
182
- count + write_blocks_index(blocks)
183
- end
184
-
185
- def write_blocks_index(blocks)
186
- path = Paths.blocks_index_path(@output_root)
187
- summary = blocks.map do |block|
188
- {
189
- "id" => block.id,
190
- "name" => block.name,
191
- "first_cp" => block.range_first,
192
- "last_cp" => block.range_last,
193
- "plane_number" => block.plane_number,
194
- }
195
- end
196
- write_atomic(path, to_pretty_json(summary)) ? 1 : 0
197
- end
198
-
199
- def block_payload(block)
200
- to_pretty_json(
201
- "id" => block.id,
202
- "name" => block.name,
203
- "range_first" => block.range_first,
204
- "range_last" => block.range_last,
205
- "plane_number" => block.plane_number,
206
- "codepoint_ids" => (@block_codepoint_ids[block.id] || []),
207
- )
208
- end
209
-
210
- # ---- Script files ------------------------------------------------
211
-
212
- def write_scripts(scripts)
213
- count = 0
214
- scripts.group_by(&:code).each do |code, ranges|
215
- next if code.nil? || code.empty?
216
-
217
- path = Paths.script_metadata_path(@output_root, code)
218
- count += 1 if write_atomic(path, script_payload(code, ranges))
219
- end
220
- count
221
- end
222
-
223
- def script_payload(code, ranges)
224
- to_pretty_json(
225
- "code" => code,
226
- "name" => ranges.first&.name,
227
- "range_first" => ranges.map(&:range_first).min,
228
- "range_last" => ranges.map(&:range_last).max,
229
- "codepoint_ids" => (@script_codepoint_ids[code] || []),
230
- )
231
- end
232
-
233
- # ---- Lookup indexes ---------------------------------------------
234
-
235
- def write_indexes
236
- count = 0
237
- count += 1 if write_atomic(Paths.names_index_path(@output_root), to_pretty_json(@names_index))
238
- count += 1 if write_atomic(Paths.labels_index_path(@output_root), to_pretty_json(@labels_index))
239
- count += 1 if write_atomic(codepoint_to_block_path, to_pretty_json(@cp_to_block))
240
- count
241
- end
242
-
243
- def codepoint_to_block_path
244
- Pathname(@output_root).join("index", "codepoint_to_block.json")
245
- end
246
-
247
- # ---- Relationships ----------------------------------------------
248
-
249
- def write_relationships(indices)
250
- RELATIONSHIP_SOURCES.sum do |field, slug|
251
- records = indices.public_send(field)
252
- write_relationship_file(slug, records)
253
- end
254
- end
255
-
256
- def write_relationship_file(slug, records)
257
- return 0 if records.nil? || records.empty?
258
-
259
- path = Pathname(@output_root).join("relationships", "#{slug}.json")
260
- write_atomic(path, relationship_payload(records)) ? 1 : 0
261
- end
262
-
263
- # records is Hash<Integer, Record>, Hash<Integer, Array<Record>>,
264
- # Hash<String, Record>, or Hash<String, Array<Record>>.
265
- # Output: { "U+XXXX" => record.to_yaml_hash, ... } or
266
- # { "U+XXXX" => [record.to_yaml_hash, ...], ... }
267
- def relationship_payload(records)
268
- payload = records.each_with_object({}) do |(key, value), h|
269
- h[key_to_cp_id(key)] = serialize_value(value)
270
- end
271
- to_pretty_json(payload)
272
- end
273
-
274
- # Indices that are keyed by Integer codepoint (most of them) get
275
- # formatted into "U+XXXX". Indices keyed by string ids already
276
- # (cjk_radicals by ideograph_id, standardized_variants by base_id)
277
- # are passed through verbatim.
278
- def key_to_cp_id(key)
279
- key.is_a?(Integer) ? Paths.cp_id(key) : key
280
- end
281
-
282
- def serialize_value(value)
283
- return value.map { |v| serialize_one(v) } if value.is_a?(Array)
284
-
285
- serialize_one(value)
286
- end
287
-
288
- def serialize_one(record)
289
- record.to_yaml_hash
290
- end
291
-
292
- # ---- Enums -------------------------------------------------------
293
-
294
- def write_enums(property_aliases, property_value_aliases)
295
- path = Pathname(@output_root).join("enums.json")
296
- payload = {
297
- "properties" => property_aliases.map(&:to_yaml_hash),
298
- "property_values" => property_value_aliases.map(&:to_yaml_hash),
299
- }
300
- write_atomic(path, to_pretty_json(payload)) ? 1 : 0
301
- end
302
-
303
- # ---- Named sequences --------------------------------------------
304
-
305
- def write_named_sequences(named_sequences)
306
- return 0 if named_sequences.nil? || named_sequences.empty?
307
-
308
- dir = Pathname(@output_root).join("named_sequences")
309
- named_sequences.sum do |ns|
310
- path = dir.join("#{slug_for(ns)}.json")
311
- write_atomic(path, ns.to_json(pretty: true)) ? 1 : 0
312
- end
313
- end
314
-
315
- # Slug derived from the name: downcase, non-alphanumerics → "_".
316
- def slug_for(named_sequence)
317
- named_sequence.name
318
- .downcase
319
- .gsub(/[^a-z0-9]+/, "_")
320
- .gsub(/^_+|_+$/, "")
321
- end
322
-
323
- # ---- Manifest ---------------------------------------------------
324
-
325
- # Fields that define the manifest's semantic content. When these
326
- # match the existing manifest on disk, we preserve the old
327
- # `generated_at` so that re-runs are byte-idempotent (no rewrite
328
- # unless something actually changed).
329
- MANIFEST_CONTENT_KEYS = %w[
330
- ucd_version codepoint_count glyph_count schema_version
331
- ].freeze
332
- private_constant :MANIFEST_CONTENT_KEYS
333
-
334
- def write_manifest(ucd_version:, glyph_count:)
335
- path = Paths.manifest_path(@output_root)
336
- content = {
337
- "ucd_version" => ucd_version,
338
- "codepoint_count" => @codepoint_count,
339
- "glyph_count" => glyph_count,
340
- "schema_version" => "1",
128
+ label = {
129
+ "name" => cp.name,
130
+ "gc" => cp.general_category,
131
+ "sc" => cp.script_code,
132
+ "cc" => cp.combining_class,
133
+ "bc" => cp.bidi&.bidi_class,
134
+ "mir" => cp.bidi&.is_mirrored ? true : nil,
341
135
  }
342
- ts = preserved_or_new_timestamp(path, content)
343
- payload = content.merge("generated_at" => ts)
344
- write_atomic(path, to_pretty_json(payload)) ? 1 : 0
136
+ label.reject { |_, v| v.nil? }
345
137
  end
346
138
 
347
- def preserved_or_new_timestamp(path, content)
348
- existing = read_manifest(path)
349
- return Time.now.utc.iso8601 unless existing
139
+ # Per-block `age` is the earliest DerivedAge of any codepoint in
140
+ # the block, compared as a Gem::Version. Stored as the original
141
+ # string (e.g. "1.1", "17.0.0").
142
+ def track_block_age(cp)
143
+ return if cp.age.nil? || cp.age.empty?
350
144
 
351
- unchanged = MANIFEST_CONTENT_KEYS.all? { |k| existing[k] == content[k] }
352
- unchanged ? existing["generated_at"] : Time.now.utc.iso8601
145
+ current = @block_ages[cp.block_id]
146
+ @block_ages[cp.block_id] = if current.nil?
147
+ cp.age
148
+ else
149
+ min_age(current, cp.age)
150
+ end
353
151
  end
354
152
 
355
- def read_manifest(path)
356
- return nil unless path.exist?
357
-
358
- JSON.parse(path.read)
359
- rescue JSON::ParserError
360
- nil
153
+ def min_age(a, b)
154
+ Gem::Version.new(a) < Gem::Version.new(b) ? a : b
361
155
  end
362
156
  end
363
157
  end
364
- end
158
+ end