ucode 0.4.0 → 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.
- checksums.yaml +4 -4
- data/README.md +45 -6
- data/config/unicode17_universal_glyph_set.yml +1 -1
- data/lib/ucode/cli.rb +201 -32
- data/lib/ucode/code_chart/batch_runner.rb +184 -0
- data/lib/ucode/code_chart/block_index.rb +89 -0
- data/lib/ucode/code_chart/coverage_gap_index.rb +115 -0
- data/lib/ucode/code_chart/extractor.rb +60 -12
- data/lib/ucode/code_chart/fetcher.rb +112 -0
- data/lib/ucode/code_chart/gap_analyzer/block_gap.rb +35 -0
- data/lib/ucode/code_chart/gap_analyzer/essenfont_manifest.rb +60 -0
- data/lib/ucode/code_chart/gap_analyzer/manifest.rb +39 -0
- data/lib/ucode/code_chart/gap_analyzer.rb +83 -0
- data/lib/ucode/code_chart/provenance.rb +99 -58
- data/lib/ucode/code_chart/sidecar.rb +3 -4
- data/lib/ucode/code_chart/verifier/builder.rb +49 -0
- data/lib/ucode/code_chart/verifier/mutool_strategy.rb +80 -0
- data/lib/ucode/code_chart/verifier/page_render_cache.rb +63 -0
- data/lib/ucode/code_chart/verifier/result.rb +27 -0
- data/lib/ucode/code_chart/verifier/resvg_strategy.rb +71 -0
- data/lib/ucode/code_chart/verifier/strategy.rb +73 -0
- data/lib/ucode/code_chart/verifier.rb +152 -0
- data/lib/ucode/code_chart/writer.rb +20 -13
- data/lib/ucode/code_chart.rb +6 -0
- data/lib/ucode/error.rb +11 -1
- data/lib/ucode/fetch/http.rb +23 -3
- data/lib/ucode/glyphs/embedded_fonts/catalog.rb +46 -0
- data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper.rb +10 -3
- data/lib/ucode/glyphs/embedded_fonts/page_trace_cache.rb +22 -0
- data/lib/ucode/glyphs/embedded_fonts/renderer.rb +19 -2
- data/lib/ucode/glyphs/source.rb +11 -1
- data/lib/ucode/glyphs/sources/pillar1_embedded_tounicode.rb +10 -5
- data/lib/ucode/version.rb +1 -1
- data/lib/ucode.rb +2 -0
- metadata +17 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0d2232b7f9bb393115f100e6eeb7e81757743ba215953149071a6a38d46b9ddd
|
|
4
|
+
data.tar.gz: a57ee93b28d31266b8f6e53595d68066b724b735aa50ddfa551ff87f896bf15b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
handles the
|
|
460
|
-
|
|
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
|
|
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:
|
|
154
|
-
desc: "Block identifier (
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|