ucode 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) 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 +204 -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 +60 -12
  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 +20 -13
  24. data/lib/ucode/code_chart.rb +6 -0
  25. data/lib/ucode/error.rb +11 -1
  26. data/lib/ucode/fetch/http.rb +23 -3
  27. data/lib/ucode/glyphs/embedded_fonts/catalog.rb +46 -0
  28. data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper.rb +10 -3
  29. data/lib/ucode/glyphs/embedded_fonts/page_trace_cache.rb +22 -0
  30. data/lib/ucode/glyphs/embedded_fonts/renderer.rb +19 -2
  31. data/lib/ucode/glyphs/source.rb +11 -1
  32. data/lib/ucode/glyphs/sources/pillar1_embedded_tounicode.rb +10 -5
  33. data/lib/ucode/version.rb +1 -1
  34. data/lib/ucode.rb +2 -0
  35. metadata +17 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebf9b5b2d2f0443697ac2cfe6f19ad8c5e1af310943ae4332eadc2df56e68c48
4
- data.tar.gz: 2f14bc10e5d5012e4b17cd544443c59e1feadef979bec2144f11dbf53e14c32f
3
+ metadata.gz: c7dbe8b2ddc06a807988316b3800fd3aa886a15f3d6b9249f62770728aa3441c
4
+ data.tar.gz: 396ac3e45668194220bc26ff3d6e002c4942feb6d6c9a63ce0003a22ccfc1c8e
5
5
  SHA512:
6
- metadata.gz: f2db668d6889a95d79ecaff540791d438044d2f9d066198b6255b6586d1f09b1433cf9d359ffa65e8b803e28c61b579bd9d08e3cfb35014ac3f49b0a0a48d351
7
- data.tar.gz: 96a98950b3cfdca7fe6301647a997c9ca9fc96960a5af99ca934ca7e0066d19d7fd8c463910d50bb24f4b956e98f572cec9ef83dc30abbd03466baf1248c748b
6
+ metadata.gz: 6f28010777955f1a11d03ed10c8edefc83ded96a0a307080b360c1a0918ca01a410e6571644e9cb4f924bbb1a67b676906f4252742b5cdcf1bc25c7df587e730
7
+ data.tar.gz: 764bd2ff3296fb44d26eabe2003d7e739a5b7baebafd3df80dc3521000958d5ad535f6d73d1eab63ebc7c039f0f8dda9c06d10851d6a8fda1d1a430ab060ea5c
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.3
3
+ ucode_version: 0.5.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,183 @@ 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")
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")
316
+ data = sidecar.exist? ? JSON.parse(sidecar.read) : {}
317
+ # sidecar JSON has string-keyed source_cell; restore symbol
318
+ # keys so the Verifier's [:x] / [:y] lookup works.
319
+ source_cell = (data["source_cell"] || {}).transform_keys(&:to_sym)
320
+ Ucode::CodeChart::Extractor::Result.new(
321
+ codepoint: codepoint,
322
+ svg: File.read(svg_path),
323
+ tier: :pillar1,
324
+ provenance: data["extractor_version"] || "verify",
325
+ base_font: data["base_font"],
326
+ gid: data["gid"],
327
+ source_page: data["source_page"],
328
+ source_cell: source_cell,
329
+ )
330
+ end
331
+
332
+ def update_tally(tally, result)
333
+ case result
334
+ when Ucode::CodeChart::Verifier::Result::Pass
335
+ tally[:passed] += 1
336
+ when Ucode::CodeChart::Verifier::Result::Fail
337
+ tally[:failed] += 1
338
+ tally[:failures] << { codepoint: result.codepoint,
339
+ percent: result.percent,
340
+ diff_path: result.diff_path.to_s }
341
+ when Ucode::CodeChart::Verifier::Result::Skipped
342
+ tally[:skipped] += 1
343
+ end
344
+ end
345
+
346
+ def run_simple_list
347
+ version = VersionResolver.resolve(nil)
348
+ pdfs_dir = Ucode::Cache.pdfs_dir(version)
349
+ files = pdfs_dir.exist? ? pdfs_dir.children.sort : []
350
+ if files.empty?
351
+ puts "(no cached Code Charts PDFs)"
352
+ return
353
+ end
354
+ files.each do |f|
355
+ puts f.basename
356
+ end
357
+ end
358
+
359
+ def run_coverage_gap_list
360
+ coverage_path = options[:coverage]
361
+ raise Thor::Error, "--coverage <path.yml> is required with --coverage-gap-only" unless coverage_path
362
+
363
+ version = VersionResolver.resolve(nil)
364
+ blocks = load_all_blocks(version)
365
+ index = Ucode::CodeChart::CoverageGapIndex.from_yaml(coverage_path, blocks: blocks)
366
+ gaps = index.gap_blocks
367
+ if gaps.empty?
368
+ puts "(no coverage gaps)"
369
+ return
370
+ end
371
+ printf "%<block_id>-40s %<assigned>10s %<covered>10s %<missing>10s\n",
372
+ block_id: "block_id", assigned: "assigned",
373
+ covered: "covered", missing: "missing"
374
+ gaps.each do |gap|
375
+ block = blocks[gap.block_id]
376
+ assigned = block.range_last - block.range_first + 1
377
+ covered = assigned - gap.size
378
+ printf "%<block_id>-40s %<assigned>10d %<covered>10d %<missing>10d\n",
379
+ block_id: gap.block_id, assigned: assigned,
380
+ covered: covered, missing: gap.size
381
+ end
382
+ end
383
+
212
384
  # Convert semantic Ucode errors into Thor errors so Thor's
213
385
  # dispatch prints the message cleanly instead of a stack trace.
214
386
  # 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