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
@@ -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
- Result = Struct.new(:codepoint, :svg, :tier, :provenance,
34
- keyword_init: true)
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 in the block's range in ascending
82
- # order. We yield the whole range because the Resolver's
83
- # tiers handle unassigned codepoints — Pillar 3 (when
84
- # configured) maps every codepoint via its Format 13 cmap,
85
- # so unassigned slots get a placeholder. With no Pillar 3
86
- # injected, only assigned codepoints (those the embedded
87
- # font actually covers) yield Results; the rest are silently
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
- (@block.range_first..@block.range_last).each(&)
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