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.
- checksums.yaml +4 -4
- data/README.md +45 -6
- data/config/unicode17_universal_glyph_set.yml +1 -1
- data/lib/ucode/cli.rb +204 -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
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Ucode
|
|
7
|
+
module CodeChart
|
|
8
|
+
# Lists Unicode blocks that have OFL coverage gaps — assigned
|
|
9
|
+
# codepoints no Tier 1 donor font covers. REQ R5's
|
|
10
|
+
# `--coverage-gap-only` flag.
|
|
11
|
+
#
|
|
12
|
+
# ## Inputs
|
|
13
|
+
#
|
|
14
|
+
# The index takes a {coverage_by_block} map
|
|
15
|
+
# (`{block_id => [covered_codepoints]}`) and a {blocks} lookup.
|
|
16
|
+
# For each block, it computes the gap (assigned − covered) and
|
|
17
|
+
# emits a {GapAnalyzer::BlockGap} when the gap is non-empty.
|
|
18
|
+
#
|
|
19
|
+
# ## Reuse
|
|
20
|
+
#
|
|
21
|
+
# Internally composes {GapAnalyzer::Analyzer} with a synthetic
|
|
22
|
+
# manifest (covering exactly the donor-supplied codepoints). No
|
|
23
|
+
# duplication of the gap-math; the OCP boundary is at
|
|
24
|
+
# {GapAnalyzer}.
|
|
25
|
+
#
|
|
26
|
+
# ## Coverage source
|
|
27
|
+
#
|
|
28
|
+
# The actual "is this codepoint covered by any OFL font?" lookup
|
|
29
|
+
# lives outside this class — callers pass in the coverage map.
|
|
30
|
+
# Future work: a Tier 1 CoverageCollector that walks fontist's
|
|
31
|
+
# known OFL sources and emits the coverage_by_block input. Until
|
|
32
|
+
# then, the caller supplies it (typically from a YAML file).
|
|
33
|
+
class CoverageGapIndex
|
|
34
|
+
# Wraps the coverage map as a {GapAnalyzer::Manifest}-shaped
|
|
35
|
+
# object so the analyzer can be reused unchanged. Pure
|
|
36
|
+
# adapter — no parsing, no I/O.
|
|
37
|
+
SyntheticManifest = Struct.new(:coverage, :ucd_version, keyword_init: true) do
|
|
38
|
+
def coverage_by_block
|
|
39
|
+
coverage
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
private_constant :SyntheticManifest
|
|
43
|
+
|
|
44
|
+
# @param coverage_by_block [Hash{String=>Array<Integer>}]
|
|
45
|
+
# @param blocks [Hash{String=>Ucode::Models::Block}]
|
|
46
|
+
# @param ucd_version [String]
|
|
47
|
+
def initialize(coverage_by_block:, blocks:, ucd_version:)
|
|
48
|
+
@coverage_by_block = coverage_by_block
|
|
49
|
+
@blocks = blocks
|
|
50
|
+
@ucd_version = ucd_version
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @yieldparam block_gap [Ucode::CodeChart::GapAnalyzer::BlockGap]
|
|
54
|
+
# @return [Enumerator, void]
|
|
55
|
+
def each_gap_block(&)
|
|
56
|
+
return enum_for(:each_gap_block) unless block_given?
|
|
57
|
+
|
|
58
|
+
analyzer.each_block_gap(&)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Array<Ucode::CodeChart::GapAnalyzer::BlockGap>]
|
|
62
|
+
def gap_blocks
|
|
63
|
+
analyzer.block_gaps
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Integer]
|
|
67
|
+
def total_missing_codepoints
|
|
68
|
+
analyzer.total_missing_codepoints
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def analyzer
|
|
74
|
+
@analyzer ||= GapAnalyzer::Analyzer.new(
|
|
75
|
+
manifest: synthetic_manifest,
|
|
76
|
+
blocks: @blocks,
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Keys every block in {blocks} so the analyzer sees
|
|
81
|
+
# unmentioned blocks as fully uncovered (the analyzer only
|
|
82
|
+
# iterates manifest keys).
|
|
83
|
+
def synthetic_manifest
|
|
84
|
+
full_coverage = {}
|
|
85
|
+
@blocks.each_key { |id| full_coverage[id] = @coverage_by_block[id] || [] }
|
|
86
|
+
SyntheticManifest.new(coverage: full_coverage,
|
|
87
|
+
ucd_version: @ucd_version)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Build a {CoverageGapIndex} from a coverage YAML file:
|
|
91
|
+
#
|
|
92
|
+
# ucd_version: "17.0.0"
|
|
93
|
+
# coverage:
|
|
94
|
+
# Sidetic: ["U+10920", "U+10921"]
|
|
95
|
+
# Beria_Erfe: ["U+10940"]
|
|
96
|
+
#
|
|
97
|
+
# Missing blocks in the YAML are treated as "no donor coverage"
|
|
98
|
+
# → the entire assigned set is the gap. Useful for the
|
|
99
|
+
# `--coverage-gap-only` CLI flow.
|
|
100
|
+
class << self
|
|
101
|
+
# @param path [Pathname, String]
|
|
102
|
+
# @param blocks [Hash{String=>Ucode::Models::Block}]
|
|
103
|
+
# @return [CoverageGapIndex]
|
|
104
|
+
def from_yaml(path, blocks:)
|
|
105
|
+
data = YAML.safe_load(Pathname.new(path).read) || {}
|
|
106
|
+
new(
|
|
107
|
+
coverage_by_block: data.fetch("coverage", {}),
|
|
108
|
+
blocks: blocks,
|
|
109
|
+
ucd_version: data.fetch("ucd_version"),
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -30,8 +30,29 @@ module Ucode
|
|
|
30
30
|
# not checked out.
|
|
31
31
|
class Extractor
|
|
32
32
|
# Result of extracting one codepoint.
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
#
|
|
34
|
+
# Carries the SVG payload plus everything downstream concerns
|
|
35
|
+
# need without re-derivation:
|
|
36
|
+
#
|
|
37
|
+
# * `base_font` — the PDF-embedded BaseFont name (e.g.
|
|
38
|
+
# "GPJAHB+WolofGaraySansSerif"). Nil for non-PDF sources.
|
|
39
|
+
# * `gid` — the GID inside that font. Nil for non-PDF sources.
|
|
40
|
+
# * `source_page` — 1-based PDF page number where the glyph
|
|
41
|
+
# appears. Nil when the Catalog didn't compute a location
|
|
42
|
+
# (ToUnicode-only path) — populated by TODO 04's
|
|
43
|
+
# `Catalog#location_for` integration.
|
|
44
|
+
# * `source_cell` — `{x: Float, y: Float}` (PDF user space,
|
|
45
|
+
# origin bottom-left) for the specimen. Same nil rule.
|
|
46
|
+
# * `extractor_version` — `Ucode::VERSION` at extraction time.
|
|
47
|
+
#
|
|
48
|
+
# All optional fields default nil so existing call sites that
|
|
49
|
+
# only read `codepoint, svg, tier, provenance` keep working.
|
|
50
|
+
Result = Struct.new(
|
|
51
|
+
:codepoint, :svg, :tier, :provenance,
|
|
52
|
+
:base_font, :gid, :source_page, :source_cell,
|
|
53
|
+
:extractor_version,
|
|
54
|
+
keyword_init: true,
|
|
55
|
+
)
|
|
35
56
|
|
|
36
57
|
# @param block [Ucode::Models::Block] block whose assigned
|
|
37
58
|
# codepoints will be extracted
|
|
@@ -47,13 +68,26 @@ module Ucode
|
|
|
47
68
|
# Pillar 3 (Last Resort) source. nil = no Pillar 3 fallback.
|
|
48
69
|
# Callers that want Last Resort placeholders inject the
|
|
49
70
|
# pre-built source here.
|
|
71
|
+
# @param assigned_only [Boolean] when true, iterate only
|
|
72
|
+
# assigned codepoints (via {BlockIndex}). Default false:
|
|
73
|
+
# iterate the full block range, matching the legacy
|
|
74
|
+
# behavior (Pillar 3 fills unassigned slots when injected;
|
|
75
|
+
# otherwise the Resolver returns nil for them and they're
|
|
76
|
+
# silently skipped).
|
|
77
|
+
# @param codepoints [Array<Integer>, nil] explicit codepoint
|
|
78
|
+
# list — overrides {BlockIndex} iteration. Used by
|
|
79
|
+
# {BatchRunner} to extract only the gap set. nil = use
|
|
80
|
+
# BlockIndex.
|
|
50
81
|
def initialize(block:, pdf_path:, cache_dir: nil,
|
|
51
|
-
tier1_sources: nil, pillar3_source: nil
|
|
82
|
+
tier1_sources: nil, pillar3_source: nil,
|
|
83
|
+
assigned_only: false, codepoints: nil)
|
|
52
84
|
@block = block
|
|
53
85
|
@pdf_path = Pathname.new(pdf_path)
|
|
54
86
|
@cache_dir = cache_dir && Pathname.new(cache_dir)
|
|
55
87
|
@tier1_sources = tier1_sources || []
|
|
56
88
|
@pillar3_source = pillar3_source
|
|
89
|
+
@assigned_only = assigned_only
|
|
90
|
+
@codepoints = codepoints
|
|
57
91
|
end
|
|
58
92
|
|
|
59
93
|
# @return [Array<Result>] one Result per codepoint that any
|
|
@@ -71,6 +105,11 @@ module Ucode
|
|
|
71
105
|
svg: resolver_result.svg,
|
|
72
106
|
tier: resolver_result.tier,
|
|
73
107
|
provenance: resolver_result.provenance,
|
|
108
|
+
base_font: resolver_result.base_font,
|
|
109
|
+
gid: resolver_result.gid,
|
|
110
|
+
source_page: resolver_result.source_page,
|
|
111
|
+
source_cell: resolver_result.source_cell,
|
|
112
|
+
extractor_version: Ucode::VERSION,
|
|
74
113
|
)
|
|
75
114
|
end
|
|
76
115
|
results
|
|
@@ -78,18 +117,27 @@ module Ucode
|
|
|
78
117
|
|
|
79
118
|
private
|
|
80
119
|
|
|
81
|
-
# Yields every codepoint
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
# skipped, satisfying the REQ's "skip unassigned codepoints".
|
|
120
|
+
# Yields every codepoint the {BlockIndex} exposes. With
|
|
121
|
+
# `assigned_only: false` (default), iterates the full block
|
|
122
|
+
# range so Pillar 3 (when configured) can map every codepoint
|
|
123
|
+
# via its Format 13 cmap, giving unassigned slots a placeholder.
|
|
124
|
+
# With `assigned_only: true`, iterates only assigned codepoints
|
|
125
|
+
# — useful for {GapAnalyzer}-driven extraction where unassigned
|
|
126
|
+
# slots have no chart specimen and no placeholder is desired.
|
|
89
127
|
def each_codepoint(&)
|
|
90
128
|
return enum_for(:each_codepoint) unless block_given?
|
|
91
129
|
|
|
92
|
-
|
|
130
|
+
if @codepoints
|
|
131
|
+
@codepoints.each(&)
|
|
132
|
+
elsif @assigned_only
|
|
133
|
+
block_index.each_assigned_codepoint(&)
|
|
134
|
+
else
|
|
135
|
+
block_index.each_codepoint_in_range(&)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def block_index
|
|
140
|
+
@block_index ||= BlockIndex.new(block: @block)
|
|
93
141
|
end
|
|
94
142
|
|
|
95
143
|
def build_resolver
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Ucode
|
|
7
|
+
module CodeChart
|
|
8
|
+
# Feature-facing PDF fetch + cache + integrity check for one
|
|
9
|
+
# Unicode block's Code Charts PDF. Wraps {Ucode::Fetch::Http} for
|
|
10
|
+
# the network I/O and adds:
|
|
11
|
+
#
|
|
12
|
+
# * sha256 sidecar — recomputed on every cache hit; mismatch
|
|
13
|
+
# raises {Ucode::CodeChartChecksumError} so tampering is
|
|
14
|
+
# detected before extraction consumes a corrupt PDF.
|
|
15
|
+
# * typed errors — HTTP 4xx raises {Ucode::CodeChartNotFoundError}
|
|
16
|
+
# without retry; 5xx still retried by {Fetch::Http}.
|
|
17
|
+
# * idempotency — cache hit returns the existing path without a
|
|
18
|
+
# network call when both the PDF and the sha256 sidecar exist.
|
|
19
|
+
#
|
|
20
|
+
# Single CodeChart-feature-facing API: `Fetcher#fetch(block:)`.
|
|
21
|
+
# The HTTP layer stays a private collaborator; new transports
|
|
22
|
+
# (mirror, S3) subclass + register, no caller change.
|
|
23
|
+
class Fetcher
|
|
24
|
+
# @param version [String] UCD version, used as the cache namespace.
|
|
25
|
+
# @param http [Module<Ucode::Fetch::Http>, nil] injectable for
|
|
26
|
+
# tests. nil = the real {Ucode::Fetch::Http}.
|
|
27
|
+
def initialize(version:, http: nil)
|
|
28
|
+
@version = version
|
|
29
|
+
@http = http || Fetch::Http
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param block [Ucode::Models::Block]
|
|
33
|
+
# @return [Pathname] the cached PDF path. Downloads when missing.
|
|
34
|
+
# @raise [Ucode::CodeChartNotFoundError] HTTP 4xx or non-PDF body.
|
|
35
|
+
# @raise [Ucode::NetworkError] HTTP 5xx after all retries.
|
|
36
|
+
# @raise [Ucode::CodeChartChecksumError] cached PDF's sha256
|
|
37
|
+
# doesn't match the sidecar.
|
|
38
|
+
def fetch(block:)
|
|
39
|
+
fetch_by_first_cp(block_first_cp: block.range_first, block_id: block.id)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Alternative entrypoint when the caller has only the first
|
|
43
|
+
# codepoint. Same semantics as {#fetch}.
|
|
44
|
+
#
|
|
45
|
+
# @param block_first_cp [Integer]
|
|
46
|
+
# @param block_id [String, nil] for error context only.
|
|
47
|
+
# @return [Pathname]
|
|
48
|
+
def fetch_by_first_cp(block_first_cp:, **_kwargs)
|
|
49
|
+
path = per_block_path(block_first_cp)
|
|
50
|
+
return path if cache_valid?(path)
|
|
51
|
+
|
|
52
|
+
download(block_first_cp)
|
|
53
|
+
path
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def per_block_path(block_first_cp)
|
|
59
|
+
Cache.pdfs_dir(@version).join("U#{hex_slug(block_first_cp)}.pdf")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def sidecar_path(pdf_path)
|
|
63
|
+
Pathname.new("#{pdf_path}.sha256")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def cache_valid?(pdf_path)
|
|
67
|
+
return false unless pdf_path.exist?
|
|
68
|
+
|
|
69
|
+
sidecar = sidecar_path(pdf_path)
|
|
70
|
+
return false unless sidecar.exist?
|
|
71
|
+
|
|
72
|
+
verify_sha256!(pdf_path, sidecar.read.strip)
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Raises {CodeChartChecksumError} when the on-disk PDF's hash
|
|
77
|
+
# doesn't match the recorded sidecar. Otherwise returns void.
|
|
78
|
+
def verify_sha256!(pdf_path, expected)
|
|
79
|
+
actual = Digest::SHA256.file(pdf_path).hexdigest
|
|
80
|
+
return if actual == expected
|
|
81
|
+
|
|
82
|
+
raise Ucode::CodeChartChecksumError.new(
|
|
83
|
+
"Code Charts PDF sha256 mismatch",
|
|
84
|
+
context: { pdf: pdf_path.to_s, expected: expected, actual: actual },
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def download(block_first_cp, **_kwargs)
|
|
89
|
+
path = per_block_path(block_first_cp)
|
|
90
|
+
url = "#{Ucode.configuration.charts_base_url}/U#{hex_slug(block_first_cp)}.pdf"
|
|
91
|
+
|
|
92
|
+
@http.get(
|
|
93
|
+
url,
|
|
94
|
+
dest: path,
|
|
95
|
+
validate: :pdf,
|
|
96
|
+
not_found_class: Ucode::CodeChartNotFoundError,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
write_sidecar(path)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def write_sidecar(pdf_path)
|
|
103
|
+
sha = Digest::SHA256.file(pdf_path).hexdigest
|
|
104
|
+
sidecar_path(pdf_path).write("#{sha}\n")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def hex_slug(codepoint)
|
|
108
|
+
codepoint.to_s(16).upcase.rjust(4, "0")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ucode
|
|
4
|
+
module CodeChart
|
|
5
|
+
module GapAnalyzer
|
|
6
|
+
# Typed per-block result: which block, which codepoints the
|
|
7
|
+
# manifest's donor sources DON'T cover, and the UCD version
|
|
8
|
+
# the manifest declared.
|
|
9
|
+
#
|
|
10
|
+
# The unit of work {BatchRunner} iterates. Frozen on
|
|
11
|
+
# construction so callers can pass it around without worry.
|
|
12
|
+
BlockGap = Struct.new(
|
|
13
|
+
:block_id,
|
|
14
|
+
:missing_codepoints,
|
|
15
|
+
:ucd_version,
|
|
16
|
+
keyword_init: true,
|
|
17
|
+
) do
|
|
18
|
+
def initialize(*)
|
|
19
|
+
super
|
|
20
|
+
self.missing_codepoints = Array(missing_codepoints).sort.freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Integer]
|
|
24
|
+
def size
|
|
25
|
+
missing_codepoints.size
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Boolean]
|
|
29
|
+
def empty?
|
|
30
|
+
missing_codepoints.empty?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
require "ucode/code_chart/gap_analyzer/manifest"
|
|
6
|
+
|
|
7
|
+
module Ucode
|
|
8
|
+
module CodeChart
|
|
9
|
+
module GapAnalyzer
|
|
10
|
+
# Parses the essenfont-style manifest YAML:
|
|
11
|
+
#
|
|
12
|
+
# ucd_version: "17.0.0"
|
|
13
|
+
# sources:
|
|
14
|
+
# - name: noto-sans-sidetic
|
|
15
|
+
# block: Sidetic
|
|
16
|
+
# covered_codepoints: ["U+10920", "U+10921"]
|
|
17
|
+
# - name: lentariso
|
|
18
|
+
# block: Beria_Erfe
|
|
19
|
+
# covered_codepoints: ["U+10940"]
|
|
20
|
+
#
|
|
21
|
+
# Coverage is unioned per block (a block with multiple donor
|
|
22
|
+
# sources has all their codepoints merged before the gap is
|
|
23
|
+
# computed).
|
|
24
|
+
class EssenfontManifest < Manifest
|
|
25
|
+
# @return [String]
|
|
26
|
+
def ucd_version
|
|
27
|
+
parsed.fetch("ucd_version")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Hash{String=>Array<Integer>}]
|
|
31
|
+
def coverage_by_block
|
|
32
|
+
sources = parsed.fetch("sources", [])
|
|
33
|
+
sources.each_with_object({}) do |src, acc|
|
|
34
|
+
block = src["block"] || (raise ArgumentError, "source missing 'block': #{src.inspect}")
|
|
35
|
+
cps = Array(src["covered_codepoints"]).map { |s| parse_cp(s) }
|
|
36
|
+
acc[block] ||= []
|
|
37
|
+
acc[block].concat(cps)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def parsed
|
|
44
|
+
@parsed ||= YAML.safe_load(read_text) || {}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Accepts "U+10920" or "0x10920". Returns Integer. Bare
|
|
48
|
+
# decimal is rejected — too ambiguous (is "10920" hex or
|
|
49
|
+
# decimal?) and the manifest convention is to use one of the
|
|
50
|
+
# explicit prefixes.
|
|
51
|
+
def parse_cp(s)
|
|
52
|
+
m = s.match(/\A(?:U\+|0x)([0-9A-Fa-f]+)\z/)
|
|
53
|
+
return m[1].to_i(16) if m
|
|
54
|
+
|
|
55
|
+
raise ArgumentError, "unparseable codepoint (use U+XXXX or 0xXXXX): #{s.inspect}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Ucode
|
|
6
|
+
module CodeChart
|
|
7
|
+
module GapAnalyzer
|
|
8
|
+
# Abstract manifest parser. Subclasses own ONE manifest schema
|
|
9
|
+
# and expose a uniform `{block_id => covered_codepoints}` shape
|
|
10
|
+
# to {GapAnalyzer}.
|
|
11
|
+
#
|
|
12
|
+
# Subclasses must implement {#parse}.
|
|
13
|
+
class Manifest
|
|
14
|
+
# @param path [Pathname, String] manifest file path
|
|
15
|
+
def initialize(path)
|
|
16
|
+
@path = Pathname.new(path)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [String] the UCD version declared in the manifest
|
|
20
|
+
def ucd_version
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Hash{String=>Array<Integer>}] block_id → array of
|
|
25
|
+
# codepoints the manifest's donor sources cover
|
|
26
|
+
def coverage_by_block
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @raise [ArgumentError] when the manifest file doesn't exist
|
|
31
|
+
def read_text
|
|
32
|
+
return @path.read unless @path.exist?
|
|
33
|
+
|
|
34
|
+
@path.read
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ucode
|
|
4
|
+
module CodeChart
|
|
5
|
+
# Derives per-block "missing codepoint" sets from a donor manifest.
|
|
6
|
+
# REQ R5/R7. Pure computation — no I/O beyond reading the manifest
|
|
7
|
+
# file (handled by the {Manifest} subclass).
|
|
8
|
+
#
|
|
9
|
+
# ## OCP
|
|
10
|
+
#
|
|
11
|
+
# Adding a new manifest format (fontisan JSON, raw codepoint list)
|
|
12
|
+
# = one Manifest subclass + one constructor entry. GapAnalyzer
|
|
13
|
+
# core stays shape-agnostic.
|
|
14
|
+
#
|
|
15
|
+
# ## BlockGap
|
|
16
|
+
#
|
|
17
|
+
# Typed handoff. Callers ({BatchRunner}, CLI) don't grep manifest
|
|
18
|
+
# internals — they iterate {#each_block_gap} and receive a
|
|
19
|
+
# {BlockGap} per block.
|
|
20
|
+
module GapAnalyzer
|
|
21
|
+
autoload :Manifest, "ucode/code_chart/gap_analyzer/manifest"
|
|
22
|
+
autoload :EssenfontManifest, "ucode/code_chart/gap_analyzer/essenfont_manifest"
|
|
23
|
+
autoload :BlockGap, "ucode/code_chart/gap_analyzer/block_gap"
|
|
24
|
+
|
|
25
|
+
# Composes a {Manifest} parser with the {BlockIndex} factory
|
|
26
|
+
# to produce {BlockGap}s.
|
|
27
|
+
class Analyzer
|
|
28
|
+
# @param manifest [Manifest] parsed manifest
|
|
29
|
+
# @param blocks [Hash{String=>Ucode::Models::Block}] block_id →
|
|
30
|
+
# Block model lookup. Used to construct BlockIndex per gap
|
|
31
|
+
# block.
|
|
32
|
+
# @param block_index_class [Class] injectable BlockIndex class
|
|
33
|
+
# for tests
|
|
34
|
+
def initialize(manifest:, blocks:, block_index_class: BlockIndex)
|
|
35
|
+
@manifest = manifest
|
|
36
|
+
@blocks = blocks
|
|
37
|
+
@block_index_class = block_index_class
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @yieldparam block_gap [BlockGap]
|
|
41
|
+
# @return [Enumerator, void]
|
|
42
|
+
def each_block_gap
|
|
43
|
+
return enum_for(:each_block_gap) unless block_given?
|
|
44
|
+
|
|
45
|
+
@manifest.coverage_by_block.each_key do |block_id|
|
|
46
|
+
gap = build_gap(block_id)
|
|
47
|
+
yield gap unless gap.empty?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Array<BlockGap>] one per block with at least one
|
|
52
|
+
# missing codepoint. Blocks with full coverage are excluded.
|
|
53
|
+
def block_gaps
|
|
54
|
+
each_block_gap.to_a
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Integer] sum of missing codepoints across blocks
|
|
58
|
+
def total_missing_codepoints
|
|
59
|
+
block_gaps.sum(&:size)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def build_gap(block_id)
|
|
65
|
+
block = @blocks[block_id] ||
|
|
66
|
+
raise(Ucode::UnknownBlockError.new(
|
|
67
|
+
"manifest references unknown block",
|
|
68
|
+
context: { block_id: block_id },
|
|
69
|
+
))
|
|
70
|
+
|
|
71
|
+
index = @block_index_class.new(block: block)
|
|
72
|
+
covered = Set.new(@manifest.coverage_by_block.fetch(block_id, []))
|
|
73
|
+
missing = index.assigned_codepoints.reject { |cp| covered.include?(cp) }
|
|
74
|
+
BlockGap.new(
|
|
75
|
+
block_id: block_id,
|
|
76
|
+
missing_codepoints: missing,
|
|
77
|
+
ucd_version: @manifest.ucd_version,
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|