ucode 0.3.3 → 0.5.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -6
  3. data/config/unicode17_universal_glyph_set.yml +1 -1
  4. data/lib/ucode/cli.rb +201 -32
  5. data/lib/ucode/code_chart/batch_runner.rb +184 -0
  6. data/lib/ucode/code_chart/block_index.rb +89 -0
  7. data/lib/ucode/code_chart/coverage_gap_index.rb +115 -0
  8. data/lib/ucode/code_chart/extractor.rb +65 -13
  9. data/lib/ucode/code_chart/fetcher.rb +112 -0
  10. data/lib/ucode/code_chart/gap_analyzer/block_gap.rb +35 -0
  11. data/lib/ucode/code_chart/gap_analyzer/essenfont_manifest.rb +60 -0
  12. data/lib/ucode/code_chart/gap_analyzer/manifest.rb +39 -0
  13. data/lib/ucode/code_chart/gap_analyzer.rb +83 -0
  14. data/lib/ucode/code_chart/provenance.rb +99 -58
  15. data/lib/ucode/code_chart/sidecar.rb +3 -4
  16. data/lib/ucode/code_chart/verifier/builder.rb +49 -0
  17. data/lib/ucode/code_chart/verifier/mutool_strategy.rb +80 -0
  18. data/lib/ucode/code_chart/verifier/page_render_cache.rb +63 -0
  19. data/lib/ucode/code_chart/verifier/result.rb +27 -0
  20. data/lib/ucode/code_chart/verifier/resvg_strategy.rb +71 -0
  21. data/lib/ucode/code_chart/verifier/strategy.rb +73 -0
  22. data/lib/ucode/code_chart/verifier.rb +152 -0
  23. data/lib/ucode/code_chart/writer.rb +21 -14
  24. data/lib/ucode/code_chart.rb +6 -0
  25. data/lib/ucode/error.rb +16 -1
  26. data/lib/ucode/fetch/http.rb +23 -3
  27. data/lib/ucode/glyphs/embedded_fonts/catalog.rb +66 -3
  28. data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper/correlator_strategy.rb +46 -0
  29. data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper/strategy.rb +54 -0
  30. data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper/tounicode_strategy.rb +41 -0
  31. data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper/trace_strategy.rb +64 -0
  32. data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper.rb +150 -90
  33. data/lib/ucode/glyphs/embedded_fonts/mutool/draw.rb +26 -0
  34. data/lib/ucode/glyphs/embedded_fonts/mutool/info.rb +23 -0
  35. data/lib/ucode/glyphs/embedded_fonts/mutool/show.rb +36 -0
  36. data/lib/ucode/glyphs/embedded_fonts/mutool/trace.rb +26 -0
  37. data/lib/ucode/glyphs/embedded_fonts/mutool.rb +30 -0
  38. data/lib/ucode/glyphs/embedded_fonts/page_trace_cache.rb +112 -0
  39. data/lib/ucode/glyphs/embedded_fonts/pdf_indexer.rb +50 -56
  40. data/lib/ucode/glyphs/embedded_fonts/{pdf_location.rb → pdf_source.rb} +1 -1
  41. data/lib/ucode/glyphs/embedded_fonts/renderer.rb +19 -2
  42. data/lib/ucode/glyphs/embedded_fonts/trace_runner.rb +12 -28
  43. data/lib/ucode/glyphs/embedded_fonts.rb +3 -1
  44. data/lib/ucode/glyphs/real_fonts/coverage_auditor.rb +0 -4
  45. data/lib/ucode/glyphs/real_fonts/font_coverage_report.rb +0 -2
  46. data/lib/ucode/glyphs/real_fonts/writer.rb +0 -2
  47. data/lib/ucode/glyphs/source.rb +11 -1
  48. data/lib/ucode/glyphs/sources/pillar1_embedded_tounicode.rb +11 -6
  49. data/lib/ucode/version.rb +1 -1
  50. data/lib/ucode.rb +2 -0
  51. metadata +28 -74
  52. data/TODO.extract-code-chart/01-pdf-fetch-validation.md +0 -80
  53. data/TODO.extract-code-chart/02-block-name-resolver.md +0 -68
  54. data/TODO.extract-code-chart/03-codechart-namespace.md +0 -82
  55. data/TODO.extract-code-chart/04-codechart-extractor.md +0 -154
  56. data/TODO.extract-code-chart/05-provenance-and-sidecar.md +0 -147
  57. data/TODO.extract-code-chart/06-codechart-writer.md +0 -134
  58. data/TODO.extract-code-chart/07-codechart-cli.md +0 -135
  59. data/TODO.extract-code-chart/08-specs.md +0 -87
  60. data/TODO.full/00-README.md +0 -116
  61. data/TODO.full/01-panglyph-vision.md +0 -112
  62. data/TODO.full/02-panglyph-repo-bootstrap.md +0 -184
  63. data/TODO.full/03-panglyph-font-builder.md +0 -201
  64. data/TODO.full/04-panglyph-publish-pipeline.md +0 -126
  65. data/TODO.full/05-ucode-0-1-1-release.md +0 -139
  66. data/TODO.full/06-fontisan-remove-audit.md +0 -142
  67. data/TODO.full/07-fontisan-remove-ucd.md +0 -125
  68. data/TODO.full/08-archive-private-bin-build.md +0 -143
  69. data/TODO.full/09-archive-public-structure.md +0 -164
  70. data/TODO.full/10-fontist-org-woff-glyphs.md +0 -131
  71. data/TODO.full/11-fontist-org-audit-coverage.md +0 -140
  72. data/TODO.full/12-implementation-order.md +0 -216
  73. data/TODO.full/13-fontisan-font-writer-api.md +0 -189
  74. data/TODO.full/14-fontisan-table-writers.md +0 -66
  75. data/TODO.full/15-panglyph-builder-real.md +0 -82
  76. data/TODO.full/16-archive-public-sync-workflows.md +0 -167
  77. data/TODO.full/17-fontist-org-font-picker.md +0 -73
  78. data/TODO.full/18-comprehensive-spec-coverage.md +0 -64
  79. data/TODO.full/19-ucode-0-1-2-patch.md +0 -32
  80. data/TODO.full/20-fontisan-0-2-23-release.md +0 -52
  81. data/TODO.new/00-README.md +0 -96
  82. data/TODO.new/01-pillar-terminology-alignment.md +0 -69
  83. data/TODO.new/02-audit-schema-design.md +0 -255
  84. data/TODO.new/03-directory-output-spec.md +0 -203
  85. data/TODO.new/04-fontist-org-contract.md +0 -173
  86. data/TODO.new/05-baseline-unicode17-coverage-audit.md +0 -144
  87. data/TODO.new/06-audit-namespace-skeleton.md +0 -105
  88. data/TODO.new/07-audit-models-port.md +0 -132
  89. data/TODO.new/08-extractors-cheap-port.md +0 -113
  90. data/TODO.new/09-extractors-expensive-port.md +0 -99
  91. data/TODO.new/10-aggregations-ucd-rewrite.md +0 -168
  92. data/TODO.new/11-differ-and-library-auditor-port.md +0 -102
  93. data/TODO.new/12-formatters-port.md +0 -115
  94. data/TODO.new/13-directory-emitter.md +0 -147
  95. data/TODO.new/14-html-face-browser.md +0 -144
  96. data/TODO.new/15-html-library-browser.md +0 -102
  97. data/TODO.new/16-cli-audit-subcommands.md +0 -142
  98. data/TODO.new/17-fontisan-cleanup-audit.md +0 -147
  99. data/TODO.new/18-fontisan-cleanup-ucd.md +0 -156
  100. data/TODO.new/19-fontisan-docs-update.md +0 -155
  101. data/TODO.new/20-canonical-resolver-4-tier.md +0 -182
  102. data/TODO.new/21-canonical-unicode17-build.md +0 -148
  103. data/TODO.new/22-implementation-order.md +0 -176
  104. data/TODO.new/23-universal-glyph-set-source-map.md +0 -312
  105. data/TODO.new/24-universal-glyph-set-build.md +0 -189
  106. data/TODO.new/25-font-audit-against-universal-set.md +0 -195
  107. data/TODO.new/26-missing-glyph-reporter.md +0 -189
  108. data/TODO.new/27-fontist-org-consumer-integration.md +0 -200
  109. data/TODO.new/28-implementation-order-update.md +0 -187
  110. data/TODO.new/29-universal-set-curation-uc17.md +0 -312
  111. data/TODO.new/30-tier1-font-acquisition.md +0 -241
  112. data/TODO.new/31-universal-set-production-build.md +0 -205
  113. data/TODO.new/32-uc17-coverage-matrix.md +0 -165
  114. data/TODO.new/33-specialist-font-acquisition-refresh.md +0 -138
  115. data/TODO.new/34-pillar2-content-stream-correlator.md +0 -147
  116. data/TODO.new/35-universal-set-production-run.md +0 -160
  117. data/TODO.new/36-per-font-coverage-audit.md +0 -145
  118. data/TODO.new/37-coverage-highlight-reporter.md +0 -125
  119. data/TODO.new/38-fontist-org-glyph-consumer.md +0 -141
  120. data/TODO.new/39-implementation-order-update-32-38.md +0 -258
  121. data/TODO.new/40-archive-private-uses-ucode-audit.md +0 -124
  122. data/TODO.new/41-ucode-unicode-archive-bridge.md +0 -160
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d21572adc317e05b3a29573cf799cc8cedd61c56949339d844022c9ccf92b0e
4
- data.tar.gz: 41ff686beb95370ff0927002fc0cc9350aa448221b8dbf12c87712d005059e98
3
+ metadata.gz: 0d2232b7f9bb393115f100e6eeb7e81757743ba215953149071a6a38d46b9ddd
4
+ data.tar.gz: a57ee93b28d31266b8f6e53595d68066b724b735aa50ddfa551ff87f896bf15b
5
5
  SHA512:
6
- metadata.gz: 2de58e0bab78c0be15ef4e99ab699333198702012832985958eb86910c0151f47b4147b07f958c2ba881d9f9750d7336586151a264ad835339a54064412907e6
7
- data.tar.gz: f3a90c63779e0eec7d93fd221063f80cf8d64df16707185a3f9c201abf8d2e1376ece32a0da2af1b5513d0190fdb0a785b5dbcccf65fbb427ff31ae745c22ca7
6
+ metadata.gz: a2b3b88ff4b4cae4549435999951c62b6ff663f692505f3518c21cbc1b0f8be0d4d90635b8c94cd23aa51c02eeedead1aa8eef2bde1ea040b26bc0ecea95bd98
7
+ data.tar.gz: 4eda9506f93e4feff2d24f2348a2b5e629df089c90e99db1c52b6c43a53332c16e5885c0268e3e785e89b7840ff7e2115b797c8dce769d694510ed3dfc248c96
data/README.md CHANGED
@@ -149,6 +149,30 @@ Pillar 1 → Pillar 2 → Pillar 3). No new extraction logic — the
149
149
  `ucode code-chart` subcommand is a thin CLI wrapper over `Ucode::CodeChart::Writer`,
150
150
  which orchestrates `Ucode::Glyphs::Resolver` for each codepoint.
151
151
 
152
+ ### Which blocks work
153
+
154
+ Most Unicode 17 blocks extract cleanly via pillar 1 (PDF-embedded font
155
+ with a trustworthy `/ToUnicode` CMap). Three known failure shapes
156
+ require pillar 2 (positional correlation) to take over:
157
+
158
+ - **No `/ToUnicode` at all** — older subset practice (Garay, Ol Onal,
159
+ Kana Extended-A/B, Small Kana Extension). The CMap is missing; pillar
160
+ 2 attributes glyphs via chart-grid geometry.
161
+ - **`/ToUnicode` encodes the wrong codepoints** — the Enclosed
162
+ Ideographic Supplement class (U+1F200..U+1F2FF). The font's CMap
163
+ encodes the *composing* ideographs shown in chart annotations (中, 新,
164
+ 三) rather than the specimens themselves (🈀, 🈁, 🈂). The orchestrator
165
+ auto-detects this via block-scope awareness: when the ToUnicode result
166
+ has zero in-block intersection, pillar 2 takes over.
167
+ - **Partial overlap** — the CMap covers some in-block codepoints but
168
+ misses others. Caller can force positional attribution via
169
+ `force_positional_for_font_ids:` on `Ucode::Glyphs::EmbeddedFonts::Catalog`.
170
+
171
+ For the full strategy-chain semantics (positional? predicate,
172
+ partition, range-aware bail, positional-precedence merge) see
173
+ [docs/architecture.md → The 4-tier glyph sourcing
174
+ strategy](docs/architecture.md#the-4-tier-glyph-sourcing-strategy).
175
+
152
176
  ## Glyph extraction (4-tier pipeline)
153
177
 
154
178
  The `ucode glyphs` command and the `--include-glyphs` flag on `ucode build`
@@ -446,18 +470,33 @@ Pillar 1 handles only the fonts where correlation is unambiguous:
446
470
  skipped. These codepoints fall through to **pillar 2** (content-stream
447
471
  positional correlation), and from there to **pillar 3** (Last Resort)
448
472
  if pillar 2 cannot resolve them either.
473
+ - **Type0 fonts whose `/ToUnicode` CMap encodes the wrong
474
+ codepoints** — the Enclosed Ideographic Supplement failure mode.
475
+ Some Code Charts PDFs embed a CID font whose CMap maps the font's
476
+ CIDs to the *composing* ideographs used in chart annotations (e.g.
477
+ U+4E2D 中, U+65B0 新) rather than to the block's actual specimens
478
+ (e.g. U+1F200 🈀, U+1F201 🈁). Without block-scope awareness,
479
+ pillar 1 "succeeds" with the wrong codepoints and the positional
480
+ fallback never runs. The `CodepointMapper` orchestrator now
481
+ auto-detects this: when the caller threads a `block_range:` and the
482
+ ToUnicode result has zero in-block intersection, the intrinsic
483
+ strategy is dropped and positional strategies take over. A
484
+ `force_positional_for_font_ids:` override handles the
485
+ partial-overlap case (some in-block, some out-of-block) where
486
+ auto-detection cannot tell that positional is still needed.
449
487
  - **Stream-form `/CIDToGIDMap`** — a binary lookup table. Treated as
450
488
  unsupported; the font is skipped.
451
489
  - **Bare CFF streams fontisan does not yet recognize** — a separate
452
490
  fontisan-side issue; flagged for investigation.
453
491
 
454
492
  Code Charts cells not covered by pillar 1 are exactly the cells whose
455
- character is not drawn from an embedded subsetted font with
456
- `/ToUnicode` — either a label, a glyph in a font without `/ToUnicode`,
457
- or a placeholder. **Pillar 2** (content-stream positional correlation)
458
- handles the no-`/ToUnicode` case, and **pillar 3** (Last Resort UFO)
459
- handles the placeholder case; the small remainder are correctly absent
460
- from the dataset.
493
+ character is not drawn from an embedded subsetted font with a
494
+ trustworthy `/ToUnicode` — either a label, a glyph in a font without
495
+ `/ToUnicode`, a font whose CMap encodes the wrong codepoints, or a
496
+ placeholder. **Pillar 2** (content-stream positional correlation)
497
+ handles the no-`/ToUnicode` and wrong-`/ToUnicode` cases, and
498
+ **Pillar 3** (Last Resort UFO) handles the placeholder case; the
499
+ small remainder are correctly absent from the dataset.
461
500
 
462
501
  ## System dependencies
463
502
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  unicode_version: 17.0.0
3
- ucode_version: 0.3.2
3
+ ucode_version: 0.4.0
4
4
  generated_at: '2026-06-28T00:00:00Z'
5
5
  default_sources:
6
6
  - kind: fontist
data/lib/ucode/cli.rb CHANGED
@@ -150,49 +150,44 @@ module Ucode
150
150
 
151
151
  desc "extract --block BLOCK --to DIR [VERSION]",
152
152
  "Extract per-codepoint SVG + provenance sidecars from a Code Charts PDF"
153
- option :block, type: :string, required: true,
154
- desc: "Block identifier (e.g. Sidetic)"
153
+ option :block, type: :string, required: false,
154
+ desc: "Block identifier (required unless --missing-from is given)"
155
155
  option :to, type: :string, required: true,
156
156
  desc: "Output directory (will contain <block_id>/<U+XXXX>.svg + .json)"
157
+ option :verify, type: :boolean, default: false,
158
+ desc: "Run pixel-diff verification on every extracted glyph"
159
+ option :missing_from, type: :string, default: nil,
160
+ desc: "Path to essenfont manifest; extracts only the gaps"
157
161
  def extract(version = nil)
158
162
  with_codechart_errors do
159
163
  version_str = VersionResolver.resolve(version)
160
- block = resolve_block!(options[:block], version_str)
161
- block_first_cp = block.range_first
162
-
163
- # Download (idempotent — re-runs skip when the PDF is cached).
164
- Commands::FetchCommand.new.fetch_charts(version_str, block_first_cps: [block_first_cp])
165
-
166
- pdf = Ucode::Glyphs::PdfFetcher.new(version_str)
167
- .fetch(block_first_cp: block_first_cp)
168
- unless pdf
169
- raise Ucode::CodeChartNotFoundError.new(
170
- "Code Charts PDF unavailable for block #{block.id.inspect}",
171
- context: { block_id: block.id, version: version_str },
172
- )
173
- end
174
164
 
175
- writer = Ucode::CodeChart::Writer.new(
176
- output_root: Pathname.new(options[:to]),
177
- pdf_path: pdf,
178
- ucd_version: version_str,
179
- )
180
- summary = writer.write(block)
181
- puts JSON.pretty_generate(summary.to_h.compact)
165
+ if options[:missing_from]
166
+ run_batch_extract(version: version_str,
167
+ manifest_path: options[:missing_from],
168
+ to: options[:to], verify: options[:verify])
169
+ else
170
+ raise Thor::Error, "--block is required when --missing-from is not set" unless options[:block]
171
+
172
+ run_single_block_extract(version: version_str,
173
+ block_id: options[:block],
174
+ to: options[:to], verify: options[:verify])
175
+ end
182
176
  end
183
177
  end
184
178
 
185
179
  desc "list", "List cached Code Charts PDFs under the version's cache"
180
+ option :coverage_gap_only, type: :boolean, default: false,
181
+ desc: "Only list blocks with OFL coverage gaps"
182
+ option :coverage, type: :string, default: nil,
183
+ desc: "Path to a coverage YAML (required with --coverage-gap-only)"
186
184
  def list
187
- version = VersionResolver.resolve(nil)
188
- pdfs_dir = Ucode::Cache.pdfs_dir(version)
189
- files = pdfs_dir.exist? ? pdfs_dir.children.sort : []
190
- if files.empty?
191
- puts "(no cached Code Charts PDFs)"
192
- return
193
- end
194
- files.each do |f|
195
- puts f.basename
185
+ with_codechart_errors do
186
+ if options[:coverage_gap_only]
187
+ run_coverage_gap_list
188
+ else
189
+ run_simple_list
190
+ end
196
191
  end
197
192
  end
198
193
 
@@ -209,6 +204,180 @@ module Ucode
209
204
  resolve_block!(block_id, version).range_first
210
205
  end
211
206
 
207
+ def run_single_block_extract(version:, block_id:, to:, verify:)
208
+ block = resolve_block!(block_id, version)
209
+ fetcher = Ucode::CodeChart::Fetcher.new(version: version)
210
+ pdf_path = fetcher.fetch(block: block)
211
+
212
+ writer = Ucode::CodeChart::Writer.new(
213
+ output_root: Pathname.new(to),
214
+ pdf_path: pdf_path,
215
+ ucd_version: version,
216
+ )
217
+ summary = writer.write(block)
218
+
219
+ result = { **summary.to_h.compact }
220
+ if verify
221
+ result[:verification] = verify_block(block: block, pdf_path: pdf_path,
222
+ output_root: Pathname.new(to))
223
+ end
224
+ puts JSON.pretty_generate(result)
225
+ end
226
+
227
+ def run_batch_extract(version:, manifest_path:, to:, verify:)
228
+ manifest = Ucode::CodeChart::GapAnalyzer::EssenfontManifest.new(manifest_path)
229
+ analyzer = Ucode::CodeChart::GapAnalyzer::Analyzer.new(
230
+ manifest: manifest,
231
+ blocks: load_all_blocks(version),
232
+ )
233
+ fetcher = Ucode::CodeChart::Fetcher.new(version: version)
234
+ runner = Ucode::CodeChart::BatchRunner.new(
235
+ output_root: Pathname.new(to), ucd_version: version,
236
+ fetcher: fetcher,
237
+ )
238
+ aggregate = runner.run(gap_analyzer: analyzer,
239
+ blocks: load_all_blocks(version))
240
+ result = aggregate.to_h
241
+ result[:verification] = verify_aggregate(output_root: Pathname.new(to)) if verify
242
+ puts JSON.pretty_generate(result.compact)
243
+ end
244
+
245
+ # Load all blocks from the cached Blocks.txt as a {block_id => Block} map.
246
+ def load_all_blocks(version)
247
+ blocks_txt = Ucode::Cache.ucd_dir(version).join("Blocks.txt")
248
+ Ucode::Parsers::Blocks.each_record(blocks_txt).to_h do |b|
249
+ [b.id, b]
250
+ end
251
+ end
252
+
253
+ # Pixel-diff verification after single-block extract. Returns
254
+ # a tally of Pass/Fail/Skipped counts + artifact paths for Fail.
255
+ def verify_block(block:, pdf_path:, output_root:)
256
+ verifier = Ucode::CodeChart::Verifier.new(diff_dir: output_root.join(".verify"))
257
+ return { status: "skipped", reason: "no verifier strategy" } unless verifier.available?
258
+
259
+ block_dir = output_root.join(block.id)
260
+ tallies = { passed: 0, failed: 0, skipped: 0, failures: [] }
261
+ Dir.glob(block_dir.join("U+*.svg")).each do |svg_path|
262
+ cp = parse_cp_from_path(svg_path)
263
+ next unless cp
264
+
265
+ extractor_result = build_extractor_result_for_verification(block_dir, svg_path, cp)
266
+ result = verifier.verify(extractor_result, pdf_path: pdf_path)
267
+ update_tally(tallies, result)
268
+ end
269
+ tallies
270
+ end
271
+
272
+ def verify_aggregate(output_root:, **_kwargs)
273
+ verifier = Ucode::CodeChart::Verifier.new(diff_dir: output_root.join(".verify"))
274
+ return { status: "skipped", reason: "no verifier strategy" } unless verifier.available?
275
+
276
+ tallies = { passed: 0, failed: 0, skipped: 0, failures: [] }
277
+ Dir.glob(output_root.join("*", "U+*.svg")).each do |svg_path|
278
+ cp = parse_cp_from_path(svg_path)
279
+ next unless cp
280
+
281
+ block_id = Pathname.new(svg_path).dirname.basename.to_s
282
+ sidecar = Pathname.new(svg_path.sub_ext(".json").to_s)
283
+ next unless sidecar.exist?
284
+
285
+ data = JSON.parse(sidecar.read)
286
+ pdf_path = Pathname.new(Ucode::Cache.pdfs_dir(data["ucd_version"])
287
+ .join("U#{block_first_cp_hex(block_id)}.pdf"))
288
+ next unless pdf_path.exist?
289
+
290
+ extractor_result = build_extractor_result_for_verification(
291
+ Pathname.new(svg_path).dirname, svg_path, cp
292
+ )
293
+ result = verifier.verify(extractor_result, pdf_path: pdf_path)
294
+ update_tally(tallies, result)
295
+ end
296
+ tallies
297
+ end
298
+
299
+ def parse_cp_from_path(svg_path)
300
+ basename = File.basename(svg_path, ".svg")
301
+ return nil unless basename.start_with?("U+")
302
+
303
+ basename.sub("U+", "").to_i(16)
304
+ end
305
+
306
+ def block_first_cp_hex(block_id)
307
+ # Block IDs use underscored names but PDFs are keyed by first
308
+ # codepoint hex. Resolve via Blocks.txt on disk.
309
+ blocks_txt = Ucode::Cache.ucd_dir(VersionResolver.resolve(nil)).join("Blocks.txt")
310
+ block = Ucode::Parsers::Blocks.find_by_id!(blocks_txt, block_id)
311
+ block.range_first.to_s(16).upcase.rjust(4, "0")
312
+ end
313
+
314
+ def build_extractor_result_for_verification(_block_dir, svg_path, codepoint)
315
+ sidecar = Pathname.new(svg_path.sub_ext(".json").to_s)
316
+ data = sidecar.exist? ? JSON.parse(sidecar.read) : {}
317
+ Ucode::CodeChart::Extractor::Result.new(
318
+ codepoint: codepoint,
319
+ svg: File.read(svg_path),
320
+ tier: :pillar1,
321
+ provenance: data["extractor_version"] || "verify",
322
+ base_font: data["base_font"],
323
+ gid: data["gid"],
324
+ source_page: data["source_page"],
325
+ source_cell: data["source_cell"],
326
+ )
327
+ end
328
+
329
+ def update_tally(tally, result)
330
+ case result
331
+ when Ucode::CodeChart::Verifier::Result::Pass
332
+ tally[:passed] += 1
333
+ when Ucode::CodeChart::Verifier::Result::Fail
334
+ tally[:failed] += 1
335
+ tally[:failures] << { codepoint: result.codepoint,
336
+ percent: result.percent,
337
+ diff_path: result.diff_path.to_s }
338
+ when Ucode::CodeChart::Verifier::Result::Skipped
339
+ tally[:skipped] += 1
340
+ end
341
+ end
342
+
343
+ def run_simple_list
344
+ version = VersionResolver.resolve(nil)
345
+ pdfs_dir = Ucode::Cache.pdfs_dir(version)
346
+ files = pdfs_dir.exist? ? pdfs_dir.children.sort : []
347
+ if files.empty?
348
+ puts "(no cached Code Charts PDFs)"
349
+ return
350
+ end
351
+ files.each do |f|
352
+ puts f.basename
353
+ end
354
+ end
355
+
356
+ def run_coverage_gap_list
357
+ coverage_path = options[:coverage]
358
+ raise Thor::Error, "--coverage <path.yml> is required with --coverage-gap-only" unless coverage_path
359
+
360
+ version = VersionResolver.resolve(nil)
361
+ blocks = load_all_blocks(version)
362
+ index = Ucode::CodeChart::CoverageGapIndex.from_yaml(coverage_path, blocks: blocks)
363
+ gaps = index.gap_blocks
364
+ if gaps.empty?
365
+ puts "(no coverage gaps)"
366
+ return
367
+ end
368
+ printf "%<block_id>-40s %<assigned>10s %<covered>10s %<missing>10s\n",
369
+ block_id: "block_id", assigned: "assigned",
370
+ covered: "covered", missing: "missing"
371
+ gaps.each do |gap|
372
+ block = blocks[gap.block_id]
373
+ assigned = block.range_last - block.range_first + 1
374
+ covered = assigned - gap.size
375
+ printf "%<block_id>-40s %<assigned>10d %<covered>10d %<missing>10d\n",
376
+ block_id: gap.block_id, assigned: assigned,
377
+ covered: covered, missing: gap.size
378
+ end
379
+ end
380
+
212
381
  # Convert semantic Ucode errors into Thor errors so Thor's
213
382
  # dispatch prints the message cleanly instead of a stack trace.
214
383
  # Thor's `start` rescues only `Thor::Error`; without this bridge,
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "json"
5
+
6
+ module Ucode
7
+ module CodeChart
8
+ # Multi-block orchestration: drives one {Writer} per gap block,
9
+ # enforces per-block idempotency, isolates per-block failures.
10
+ #
11
+ # Composes four orthogonal concerns:
12
+ #
13
+ # * {Fetcher} — owns PDF download + cache + integrity check
14
+ # * {Writer} — owns one block's extraction + disk write
15
+ # * {GapAnalyzer} — owns "which codepoints to extract per block"
16
+ # * {SkipChecker} — owns "should this block be skipped" (this file)
17
+ #
18
+ # Each concern is replaceable; BatchRunner itself is a pure
19
+ # iterator + aggregator. Adding a new "skip if X" rule = one
20
+ # method on {SkipChecker}, one entry in the predicate chain.
21
+ class BatchRunner
22
+ # Per-block run summary.
23
+ BlockSummary = Struct.new(
24
+ :block_id, :codepoints_extracted, :svgs_written,
25
+ :svgs_skipped, :pdf_sha256, :skipped, :error,
26
+ keyword_init: true,
27
+ )
28
+
29
+ # Aggregate across the whole batch.
30
+ Aggregate = Struct.new(
31
+ :blocks_processed, :blocks_skipped, :blocks_failed,
32
+ :svgs_written, :svgs_skipped, :total_codepoints,
33
+ keyword_init: true,
34
+ ) do
35
+ def initialize(*)
36
+ super
37
+ self.blocks_processed ||= 0
38
+ self.blocks_skipped ||= 0
39
+ self.blocks_failed ||= 0
40
+ self.svgs_written ||= 0
41
+ self.svgs_skipped ||= 0
42
+ self.total_codepoints ||= 0
43
+ end
44
+ end
45
+
46
+ # @param output_root [Pathname, String] root for per-block dirs
47
+ # @param ucd_version [String]
48
+ # @param fetcher [Fetcher]
49
+ # @param writer_class [Class] injectable for tests
50
+ # @param skip_checker_class [Class] injectable for tests
51
+ def initialize(output_root:, ucd_version:, fetcher:,
52
+ writer_class: Writer, skip_checker_class: SkipChecker)
53
+ @output_root = Pathname.new(output_root)
54
+ @ucd_version = ucd_version
55
+ @fetcher = fetcher
56
+ @writer_class = writer_class
57
+ @skip_checker_class = skip_checker_class
58
+ end
59
+
60
+ # @param gap_analyzer [GapAnalyzer::Analyzer]
61
+ # @param blocks [Hash{String=>Ucode::Models::Block}] block_id → Block
62
+ # @yieldparam summary [BlockSummary]
63
+ # @return [Aggregate]
64
+ def run(gap_analyzer:, blocks:)
65
+ summaries = []
66
+ gap_analyzer.each_block_gap do |gap|
67
+ summary = process_gap(gap, blocks.fetch(gap.block_id))
68
+ summaries << summary
69
+ yield summary if block_given?
70
+ end
71
+ build_aggregate(summaries)
72
+ end
73
+
74
+ private
75
+
76
+ def process_gap(gap, block)
77
+ pdf_path = @fetcher.fetch(block: block)
78
+
79
+ if skip?(gap, block, pdf_path)
80
+ return BlockSummary.new(
81
+ block_id: block.id, codepoints_extracted: 0,
82
+ svgs_written: 0, svgs_skipped: gap.size,
83
+ pdf_sha256: Ucode::CodeChart::Provenance.sha256_of(pdf_path),
84
+ skipped: true, error: nil,
85
+ )
86
+ end
87
+
88
+ writer = @writer_class.new(
89
+ output_root: @output_root, pdf_path: pdf_path,
90
+ ucd_version: @ucd_version, assigned_only: true,
91
+ codepoints: gap.missing_codepoints,
92
+ )
93
+ writer_summary = writer.write(block)
94
+ BlockSummary.new(
95
+ block_id: writer_summary.block,
96
+ codepoints_extracted: writer_summary.codepoints_extracted,
97
+ svgs_written: writer_summary.svgs_written,
98
+ svgs_skipped: 0,
99
+ pdf_sha256: writer_summary.pdf_sha256,
100
+ skipped: false, error: nil,
101
+ )
102
+ rescue Ucode::Error => e
103
+ BlockSummary.new(block_id: block.id, codepoints_extracted: 0,
104
+ svgs_written: 0, svgs_skipped: 0,
105
+ pdf_sha256: "", skipped: false,
106
+ error: { class: e.class.name, message: e.message })
107
+ end
108
+
109
+ def skip?(gap, block, pdf_path)
110
+ @skip_checker_class
111
+ .new(output_root: @output_root, ucd_version: @ucd_version)
112
+ .skip?(gap: gap, block: block, pdf_path: pdf_path)
113
+ end
114
+
115
+ def build_aggregate(summaries)
116
+ agg = Aggregate.new
117
+ summaries.each do |s|
118
+ if s.error
119
+ agg.blocks_failed += 1
120
+ elsif s.skipped
121
+ agg.blocks_skipped += 1
122
+ else
123
+ agg.blocks_processed += 1
124
+ end
125
+ agg.svgs_written += s.svgs_written
126
+ agg.svgs_skipped += s.svgs_skipped
127
+ agg.total_codepoints += s.codepoints_extracted
128
+ end
129
+ agg
130
+ end
131
+
132
+ # Per-block idempotency check. A block is skippable iff:
133
+ # 1. Its output dir exists.
134
+ # 2. Every codepoint in the {BlockGap} has an `.svg` AND a `.json`.
135
+ # 3. Each sidecar's `source_pdf_sha256` matches the current PDF's sha256.
136
+ # 4. Each sidecar's `ucd_version` matches the current UCD version.
137
+ #
138
+ # Adding a new predicate = one method + one entry in #skip?.
139
+ # No change to {BatchRunner}.
140
+ class SkipChecker
141
+ # @param output_root [Pathname, String]
142
+ # @param ucd_version [String]
143
+ def initialize(output_root:, ucd_version:)
144
+ @output_root = Pathname.new(output_root)
145
+ @ucd_version = ucd_version
146
+ end
147
+
148
+ def skip?(gap:, block:, pdf_path:)
149
+ return false unless block_dir(block).exist?
150
+
151
+ current_sha = Provenance.sha256_of(pdf_path)
152
+ gap.missing_codepoints.all? do |cp|
153
+ sidecar_matches?(block, cp, current_sha)
154
+ end
155
+ end
156
+
157
+ private
158
+
159
+ def block_dir(block)
160
+ @output_root.join(block.id)
161
+ end
162
+
163
+ def sidecar_path(block, codepoint)
164
+ block_dir(block).join("#{format_cp(codepoint)}.json")
165
+ end
166
+
167
+ def sidecar_matches?(block, codepoint, current_sha)
168
+ path = sidecar_path(block, codepoint)
169
+ return false unless path.exist?
170
+
171
+ data = JSON.parse(path.read)
172
+ data["source_pdf_sha256"] == current_sha &&
173
+ data["ucd_version"] == @ucd_version
174
+ rescue JSON::ParserError
175
+ false
176
+ end
177
+
178
+ def format_cp(codepoint)
179
+ "U+#{codepoint.to_s(16).upcase.rjust(4, '0')}"
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ucode
4
+ module CodeChart
5
+ # Owns assigned-codepoint membership for one Unicode block.
6
+ #
7
+ # The single place in the CodeChart namespace that knows "what
8
+ # codepoints belong to this block". {Extractor}, {GapAnalyzer},
9
+ # {BatchRunner}, and the CLI's `list --coverage-gap-only` all
10
+ # consume this API — none of them re-derive block membership.
11
+ #
12
+ # ## Definition of "assigned"
13
+ #
14
+ # Two iteration modes, both supported:
15
+ #
16
+ # * `#each_codepoint_in_range` — every Integer in
17
+ # `block.range_first..block.range_last`. Fast, no I/O.
18
+ # * `#each_assigned_codepoint` — same enumeration today; this
19
+ # is the place to swap in a precise UCD-backed filter (walk
20
+ # `UnicodeData.txt` for the block range) without changing
21
+ # callers. The approximation matches the audit pipeline's
22
+ # existing definition ({Ucode::Audit::UcdOnlyReference}).
23
+ #
24
+ # ## OCP
25
+ #
26
+ # Adding a new mode (e.g. `:reserved_only`, `:noncharacter_only`)
27
+ # = one new method. Adding a new precision level = override of
28
+ # `#each_assigned_codepoint`. Callers never change.
29
+ class BlockIndex
30
+ # @param block [Ucode::Models::Block]
31
+ def initialize(block:)
32
+ @block = block
33
+ end
34
+
35
+ # @return [Ucode::Models::Block]
36
+ attr_reader :block
37
+
38
+ # Yields every codepoint in the block's range, ascending.
39
+ # Includes reserved/unassigned slots — callers that need only
40
+ # assigned codepoints should use {#each_assigned_codepoint}
41
+ # (or filter the result through whatever resolver they
42
+ # compose).
43
+ #
44
+ # @yieldparam codepoint [Integer]
45
+ # @return [Enumerator, void]
46
+ def each_codepoint_in_range(&)
47
+ return enum_for(:each_codepoint_in_range) unless block_given?
48
+
49
+ (@block.range_first..@block.range_last).each(&)
50
+ end
51
+
52
+ # Yields assigned codepoints only. Today this is equivalent
53
+ # to {#each_codepoint_in_range} — the audit pipeline's
54
+ # existing definition of "assigned" is range-based. A future
55
+ # enhancement can swap in a UCD-backed precise filter without
56
+ # changing any caller.
57
+ #
58
+ # @yieldparam codepoint [Integer]
59
+ # @return [Enumerator, void]
60
+ def each_assigned_codepoint(&)
61
+ return enum_for(:each_assigned_codepoint) unless block_given?
62
+
63
+ each_codepoint_in_range(&)
64
+ end
65
+
66
+ # @return [Array<Integer>] materialized view of
67
+ # {#each_assigned_codepoint}, sorted ascending
68
+ def assigned_codepoints
69
+ each_assigned_codepoint.to_a
70
+ end
71
+
72
+ # @return [Set<Integer>] frozen membership set; built once
73
+ def assigned_set
74
+ @assigned_set ||= assigned_codepoints.to_set.freeze
75
+ end
76
+
77
+ # @param codepoint [Integer]
78
+ # @return [Boolean]
79
+ def assigned?(codepoint)
80
+ assigned_set.include?(codepoint)
81
+ end
82
+
83
+ # @return [Integer] count of assigned codepoints
84
+ def size
85
+ assigned_codepoints.size
86
+ end
87
+ end
88
+ end
89
+ end