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.
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 +201 -32
  5. data/lib/ucode/code_chart/batch_runner.rb +184 -0
  6. data/lib/ucode/code_chart/block_index.rb +89 -0
  7. data/lib/ucode/code_chart/coverage_gap_index.rb +115 -0
  8. data/lib/ucode/code_chart/extractor.rb +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,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Ucode
7
+ module CodeChart
8
+ # Pixel-diff verification for extracted SVG glyphs against the
9
+ # source PDF cell. REQ R4.
10
+ #
11
+ # ## Strategy chain (OCP)
12
+ #
13
+ # One renderer strategy per external tool. The strategy exposes
14
+ # three primitive operations: render an SVG to PNG, render a PDF
15
+ # page region to PNG, and compute a pixel-diff percentage between
16
+ # two PNGs. {Verifier} orchestrates these primitives; it has no
17
+ # knowledge of any specific CLI tool.
18
+ #
19
+ # Adding a new renderer (cairo, imagemagick, …) = one Strategy
20
+ # subclass + one entry in {Verifier::Builder.pick}. Verifier core
21
+ # never changes.
22
+ #
23
+ # ## Result types (see Verifier::Result)
24
+ #
25
+ # * `Pass` — diff < FAIL_THRESHOLD (default 1.0%).
26
+ # * `Fail` — diff ≥ threshold; carries the percent + diff
27
+ # artifact path for inspection.
28
+ # * `Skipped` — extractor produced no `source_page`/`source_cell`
29
+ # (e.g. ToUnicode-only path, or Last Resort placeholder), so no
30
+ # honest cell-diff is possible. Surfaces as a warning, NOT a
31
+ # pass.
32
+ class Verifier
33
+ autoload :Strategy, "ucode/code_chart/verifier/strategy"
34
+ autoload :ResvgStrategy, "ucode/code_chart/verifier/resvg_strategy"
35
+ autoload :MutoolStrategy, "ucode/code_chart/verifier/mutool_strategy"
36
+ autoload :Builder, "ucode/code_chart/verifier/builder"
37
+ autoload :PageRenderCache, "ucode/code_chart/verifier/page_render_cache"
38
+ autoload :Result, "ucode/code_chart/verifier/result"
39
+
40
+ DEFAULT_CELL_SIZE = 40.0
41
+ private_constant :DEFAULT_CELL_SIZE
42
+
43
+ # @param strategy [Strategy, nil] nil = {Builder.pick} auto.
44
+ # @param diff_dir [Pathname, String] where diff artifacts land
45
+ # @param threshold [Float, nil] override {Strategy::FAIL_THRESHOLD}
46
+ def initialize(diff_dir:, strategy: nil, threshold: nil)
47
+ @strategy = strategy || Builder.pick
48
+ @diff_dir = Pathname.new(diff_dir)
49
+ @threshold = threshold || (Strategy::FAIL_THRESHOLD if @strategy)
50
+ if @strategy
51
+ @page_cache = PageRenderCache.new(diff_dir: @diff_dir.join(".cache"),
52
+ strategy: @strategy)
53
+ end
54
+ end
55
+
56
+ # @return [Boolean] true iff a usable strategy was found
57
+ def available?
58
+ !@strategy.nil? && @strategy.available?
59
+ end
60
+
61
+ # @param result [Ucode::CodeChart::Extractor::Result]
62
+ # @param pdf_path [Pathname, String, nil] source PDF
63
+ # @return [Result::Pass, Result::Fail, Result::Skipped]
64
+ def verify(result, pdf_path:)
65
+ unless @strategy
66
+ return Result::Skipped.new(codepoint: result.codepoint,
67
+ reason: :no_strategy)
68
+ end
69
+
70
+ loc = extract_location(result)
71
+ unless loc
72
+ return Result::Skipped.new(codepoint: result.codepoint,
73
+ reason: :no_location)
74
+ end
75
+
76
+ path = Pathname.new(pdf_path)
77
+ unless path.exist?
78
+ return Result::Skipped.new(codepoint: result.codepoint,
79
+ reason: :no_pdf)
80
+ end
81
+
82
+ verify_with_location(result, path, loc)
83
+ end
84
+
85
+ private
86
+
87
+ def extract_location(result)
88
+ return nil unless result.source_page && result.source_cell
89
+
90
+ {
91
+ page: result.source_page,
92
+ x: result.source_cell[:x],
93
+ y: result.source_cell[:y],
94
+ }
95
+ end
96
+
97
+ def verify_with_location(result, pdf_path, loc)
98
+ svg_png = render_svg(result)
99
+ cell_png = render_cell(pdf_path, loc)
100
+
101
+ percent = @strategy.diff(svg_png, cell_png)
102
+ if percent < @threshold
103
+ Result::Pass.new(codepoint: result.codepoint, percent: percent)
104
+ else
105
+ artifact = write_artifact(result, svg_png, cell_png)
106
+ Result::Fail.new(codepoint: result.codepoint,
107
+ percent: percent,
108
+ diff_path: artifact)
109
+ end
110
+ ensure
111
+ [svg_png, cell_png].each { |p| FileUtils.rm_f(p.to_s) if p&.exist? }
112
+ end
113
+
114
+ def render_svg(result)
115
+ svg_path = @diff_dir.join("#{format_cp(result.codepoint)}.svg")
116
+ svg_path.dirname.mkpath
117
+ svg_path.write(result.svg)
118
+ png_path = Pathname.new("#{svg_path}.png")
119
+ @strategy.render_svg(svg_path, png_path)
120
+ png_path
121
+ ensure
122
+ FileUtils.rm_f(svg_path.to_s) if svg_path&.exist?
123
+ end
124
+
125
+ def render_cell(pdf_path, loc)
126
+ rect = cell_rect(loc)
127
+ png_path = @diff_dir.join(".cache",
128
+ "cell-#{loc[:page]}-" \
129
+ "#{format('%05.2f', loc[:x])}-" \
130
+ "#{format('%05.2f', loc[:y])}.png")
131
+ png_path.dirname.mkpath
132
+ @strategy.render_pdf_region(pdf_path, loc[:page], rect, png_path)
133
+ png_path
134
+ end
135
+
136
+ def cell_rect(loc)
137
+ half = DEFAULT_CELL_SIZE / 2
138
+ { x: loc[:x] - half, y: loc[:y] - half,
139
+ w: DEFAULT_CELL_SIZE, h: DEFAULT_CELL_SIZE }
140
+ end
141
+
142
+ def write_artifact(result, png_a, png_b)
143
+ dest = @diff_dir.join("#{format_cp(result.codepoint)}.diff.png")
144
+ @strategy.write_diff_artifact(png_a, png_b, dest) || dest
145
+ end
146
+
147
+ def format_cp(codepoint)
148
+ "U+#{codepoint.to_s(16).upcase.rjust(4, '0')}"
149
+ end
150
+ end
151
+ end
152
+ end
@@ -3,12 +3,6 @@
3
3
  require "digest"
4
4
  require "pathname"
5
5
 
6
- require "ucode/code_chart/extractor"
7
- require "ucode/code_chart/provenance"
8
- require "ucode/code_chart/sidecar"
9
- require "ucode/error"
10
- require "ucode/version_resolver"
11
-
12
6
  module Ucode
13
7
  module CodeChart
14
8
  # Orchestrates extraction + provenance sidecar writing for one
@@ -49,10 +43,17 @@ module Ucode
49
43
  # @param cache_dir [Pathname, String, nil] font-stream cache
50
44
  # directory for the EmbeddedFonts::PdfSource.
51
45
  # @param now [Time, nil] timestamp override (for tests).
52
- # @param pillar3_source, tier1_sources: forwarded to the Extractor.
46
+ # @param pillar3_source, tier1_sources, assigned_only, codepoints:
47
+ # forwarded to the Extractor.
48
+ # @param extractor [Ucode::CodeChart::Extractor, nil] pre-built
49
+ # Extractor instance. When nil (default), the Writer constructs
50
+ # one from the other parameters. Injecting lets callers (tests)
51
+ # bypass the mutool-dependent default Extractor.
53
52
  def initialize(output_root:, pdf_path:, ucd_version: nil,
54
53
  cache_dir: nil, now: nil,
55
- pillar3_source: nil, tier1_sources: nil)
54
+ pillar3_source: nil, tier1_sources: nil,
55
+ assigned_only: false, codepoints: nil,
56
+ extractor: nil)
56
57
  @output_root = Pathname.new(output_root)
57
58
  @pdf_path = Pathname.new(pdf_path)
58
59
  @ucd_version = ucd_version || VersionResolver.resolve(nil)
@@ -60,6 +61,9 @@ module Ucode
60
61
  @now = now
61
62
  @pillar3_source = pillar3_source
62
63
  @tier1_sources = tier1_sources
64
+ @assigned_only = assigned_only
65
+ @codepoints = codepoints
66
+ @extractor = extractor
63
67
  end
64
68
 
65
69
  # Extracts every codepoint in `block` and writes `<block_id>/<cp>.svg`
@@ -72,27 +76,30 @@ module Ucode
72
76
  block_dir = @output_root.join(block.id)
73
77
  block_dir.mkpath
74
78
 
75
- pdf_sha = CodeChart.sha256_of(@pdf_path)
79
+ pdf_sha = Provenance.sha256_of(@pdf_path)
76
80
 
77
81
  sidecar = Sidecar.new(output_root: block_dir)
78
- extractor = Extractor.new(
82
+ extractor = @extractor || Extractor.new(
79
83
  block: block,
80
84
  pdf_path: @pdf_path,
81
85
  cache_dir: @cache_dir,
82
86
  pillar3_source: @pillar3_source,
83
87
  tier1_sources: @tier1_sources,
88
+ assigned_only: @assigned_only,
89
+ codepoints: @codepoints,
84
90
  )
85
-
86
91
  results = extractor.extract
87
92
  svgs = 0
88
93
  sidecars = 0
89
94
  results.each do |result|
90
95
  write_svg(block_dir, result)
91
96
  svgs += 1
92
- provenance = CodeChart.build(
97
+ provenance = Provenance.build(
93
98
  block: block, codepoint: result.codepoint,
94
99
  ucd_version: @ucd_version, pdf_path: @pdf_path,
95
- now: @now,
100
+ pdf_sha: pdf_sha, now: @now,
101
+ base_font: result.base_font, gid: result.gid,
102
+ source_page: result.source_page, source_cell: result.source_cell,
96
103
  )
97
104
  sidecar.write(provenance)
98
105
  sidecars += 1
@@ -31,9 +31,15 @@ module Ucode
31
31
  # (e.g. a future pure-Ruby PDF parser per ADR-0001) does not
32
32
  # change the public API.
33
33
  module CodeChart
34
+ autoload :BatchRunner, "ucode/code_chart/batch_runner"
35
+ autoload :BlockIndex, "ucode/code_chart/block_index"
36
+ autoload :CoverageGapIndex, "ucode/code_chart/coverage_gap_index"
34
37
  autoload :Extractor, "ucode/code_chart/extractor"
38
+ autoload :Fetcher, "ucode/code_chart/fetcher"
39
+ autoload :GapAnalyzer, "ucode/code_chart/gap_analyzer"
35
40
  autoload :Provenance, "ucode/code_chart/provenance"
36
41
  autoload :Sidecar, "ucode/code_chart/sidecar"
42
+ autoload :Verifier, "ucode/code_chart/verifier"
37
43
  autoload :Writer, "ucode/code_chart/writer"
38
44
  end
39
45
  end
data/lib/ucode/error.rb CHANGED
@@ -115,12 +115,22 @@ module Ucode
115
115
  # (corrupt PDF, bad object ref, etc.). The context carries the argv.
116
116
  class MutoolError < GlyphError; end
117
117
 
118
+ # CodeChart feature errors — concerns under `Ucode::CodeChart::*`.
119
+ # Distinct from the generic {GlyphError} hierarchy so callers can
120
+ # scope rescue clauses to feature failures.
121
+ class CodeChartError < GlyphError; end
122
+
118
123
  # The Code Charts PDF for a requested block cannot be obtained: the
119
124
  # network returned 4xx/5xx, the response wasn't application/pdf, or
120
125
  # the body didn't start with the `%PDF` magic. Distinct from
121
126
  # {EmbeddedFontsMissingError} (which fires when the file is already
122
127
  # on disk and we just can't open it): this fires at fetch time.
123
- class CodeChartNotFoundError < GlyphError; end
128
+ class CodeChartNotFoundError < CodeChartError; end
129
+
130
+ # A cached Code Charts PDF failed sha256 verification — the file on
131
+ # disk doesn't match the sidecar hash from the previous successful
132
+ # download. Implies external tampering or a filesystem fault.
133
+ class CodeChartChecksumError < CodeChartError; end
124
134
 
125
135
  # Pre-build validation failed for a universal-set build. The
126
136
  # context carries the failing checks so the CLI can render a
@@ -29,11 +29,18 @@ module Ucode
29
29
  # {Ucode::CodeChartNotFoundError} with the offending header
30
30
  # value in `context:` on failure. nil = no validation (the
31
31
  # default for non-PDF callers like UcdZip and UnihanZip).
32
+ # @param not_found_class [Class, nil] when set, HTTP 4xx
33
+ # responses raise this class (instantiated with message +
34
+ # context) instead of being treated as retriable transport
35
+ # errors. nil = 4xx is retriable like any other non-success.
32
36
  # @return [Pathname] destination path on success.
33
37
  # @raise [Ucode::NetworkError] if all retries fail.
34
38
  # @raise [Ucode::CodeChartNotFoundError] when `validate: :pdf`
35
39
  # and the response fails content validation.
36
- def get(url, dest:, retries: nil, timeout: nil, validate: nil)
40
+ # @raise [<not_found_class>] when `not_found_class:` is set
41
+ # and the server returns 4xx.
42
+ def get(url, dest:, retries: nil, timeout: nil, validate: nil,
43
+ not_found_class: nil)
37
44
  uri = url.is_a?(URI) ? url : URI(url)
38
45
  destination = Pathname.new(dest)
39
46
  destination.dirname.mkpath
@@ -44,7 +51,8 @@ module Ucode
44
51
 
45
52
  last_error = nil
46
53
  (attempts + 1).times do |attempt|
47
- response = stream_to(uri, destination, read_timeout)
54
+ response = stream_to(uri, destination, read_timeout,
55
+ not_found_class: not_found_class)
48
56
  validate_response!(validate, response, destination) if validate
49
57
  return destination
50
58
  rescue ValidationFailure => e
@@ -80,13 +88,15 @@ module Ucode
80
88
  end
81
89
  end
82
90
 
83
- def stream_to(uri, destination, read_timeout)
91
+ def stream_to(uri, destination, read_timeout, not_found_class: nil)
84
92
  response = nil
85
93
  Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
86
94
  read_timeout: read_timeout) do |http|
87
95
  request = Net::HTTP::Get.new(uri)
88
96
  http.request(request) do |r|
89
97
  unless r.is_a?(Net::HTTPSuccess)
98
+ raise ValidationFailure.new(not_found_error(not_found_class, uri, r)) if not_found_class && r.is_a?(Net::HTTPClientError)
99
+
90
100
  raise "HTTP #{r.code} #{r.message}"
91
101
  end
92
102
 
@@ -97,6 +107,16 @@ module Ucode
97
107
  response or raise "no response received"
98
108
  end
99
109
 
110
+ # Builds the not-found error (e.g. CodeChartNotFoundError)
111
+ # for a 4xx response, fed through ValidationFailure so the
112
+ # retry loop in `get` doesn't re-attempt a permanent miss.
113
+ def not_found_error(klass, uri, response)
114
+ klass.new(
115
+ "HTTP #{response.code} #{response.message}",
116
+ context: { url: uri.to_s, status: response.code.to_i },
117
+ )
118
+ end
119
+
100
120
  def write_body(response, destination)
101
121
  partial = destination.sub_ext("#{destination.extname}.part")
102
122
  File.open(partial, "wb") do |file|
@@ -53,6 +53,38 @@ module Ucode
53
53
  index[codepoint]
54
54
  end
55
55
 
56
+ # Locate where in the PDF a codepoint's specimen was rendered.
57
+ # Returns `{page:, x:, y:}` (PDF user space, origin
58
+ # bottom-left) by joining `#lookup` with the trace cache's
59
+ # `(font, gid)` search. Nil when:
60
+ # * the codepoint isn't in this PDF, OR
61
+ # * the embedded font has no traced specimen (ToUnicode-only
62
+ # path with no positional correlation data), OR
63
+ # * the trace cache wasn't populated (lazy + never asked
64
+ # for any positional strategy).
65
+ #
66
+ # Memoized per-Catalog; the second call for the same
67
+ # codepoint is O(1).
68
+ #
69
+ # @param codepoint [Integer]
70
+ # @return [Hash{Symbol=>Integer, Float}, nil]
71
+ def location_for(codepoint)
72
+ @locations ||= {}
73
+ return @locations[codepoint] if @locations.key?(codepoint)
74
+
75
+ entry = lookup(codepoint)
76
+ result = if entry.nil?
77
+ nil
78
+ else
79
+ trace_cache.find_glyph(
80
+ base_font: entry.base_font,
81
+ gid: entry.gid_for(codepoint),
82
+ )
83
+ end
84
+ @locations[codepoint] = result
85
+ result
86
+ end
87
+
56
88
  # @return [Array<Integer>] every codepoint this PDF covers
57
89
  def codepoints
58
90
  index.keys
@@ -114,6 +146,20 @@ module Ucode
114
146
  force_positional_for_font_ids: @force_positional_for_font_ids,
115
147
  correlator_configs: @correlator_configs,
116
148
  indexer: indexer,
149
+ trace_cache: trace_cache,
150
+ )
151
+ end
152
+
153
+ # The PageTraceCache shared with the CodepointMapper. Lazily
154
+ # constructed on first reference; #location_for uses the same
155
+ # cache, so the per-page `mutool trace` cost is paid exactly
156
+ # once across the catalog + location lookups for a PDF.
157
+ #
158
+ # @return [PageTraceCache]
159
+ def trace_cache
160
+ @trace_cache ||= PageTraceCache.new(
161
+ pdf: @source.pdf_path,
162
+ page_count: indexer.page_count,
117
163
  )
118
164
  end
119
165
  end
@@ -68,13 +68,18 @@ module Ucode
68
68
  # stubs for tests should construct strategies directly and pass
69
69
  # them to +#initialize+.
70
70
  #
71
+ # @param trace_cache [PageTraceCache, nil] when provided, the
72
+ # TraceStrategy shares this cache (lets the caller reuse the
73
+ # traced pages for downstream concerns like Catalog's
74
+ # location lookup). nil = construct internally.
71
75
  # @return [CodepointMapper]
72
76
  def self.build(source:, correlator_configs:, indexer:,
73
77
  block_range: nil, force_positional_for_font_ids: Set.new,
74
78
  mutool_show: Mutool::Show.new,
75
79
  mutool_draw: Mutool::Draw.new,
76
- mutool_trace: Mutool::Trace.new)
77
- trace_cache = PageTraceCache.new(
80
+ mutool_trace: Mutool::Trace.new,
81
+ trace_cache: nil)
82
+ trace_cache ||= PageTraceCache.new(
78
83
  pdf: source.pdf_path,
79
84
  page_count: indexer.page_count,
80
85
  mutool: mutool_trace,
@@ -152,7 +157,9 @@ module Ucode
152
157
  def intrinsic_out_of_scope?(intrinsic_result)
153
158
  return false unless @block_range
154
159
 
155
- intrinsic_result.keys.none? { |cp| @block_range.include?(cp) }
160
+ # block_range is a Range, not an Array; Array#intersect? would
161
+ # force an eager .to_a conversion on potentially huge CJK ranges.
162
+ intrinsic_result.keys.all? { |cp| !@block_range.include?(cp) }
156
163
  end
157
164
 
158
165
  # Positional chain: union of all positional strategies' results.
@@ -72,6 +72,28 @@ module Ucode
72
72
  end
73
73
  end
74
74
 
75
+ # Locate the first occurrence of a specific (font, gid) pair
76
+ # across all traced pages. Returns nil when no match. Used by
77
+ # {Catalog#location_for} to attribute a codepoint's source
78
+ # page + (x, y) without exposing the cache's internal layout
79
+ # to callers.
80
+ #
81
+ # @param base_font [String] specimen font BaseFont name
82
+ # @param gid [Integer] glyph id inside that font
83
+ # @return [Hash{Symbol=>Integer, Float}, nil]
84
+ # `{ page: Integer, x: Float, y: Float }` or nil
85
+ def find_glyph(base_font:, gid:)
86
+ glyphs_by_page.each_with_index do |page_glyphs, idx|
87
+ next if idx.zero?
88
+
89
+ match = page_glyphs.find do |g|
90
+ g.font_name == base_font && g.gid == gid
91
+ end
92
+ return { page: idx, x: match.x, y: match.y } if match
93
+ end
94
+ nil
95
+ end
96
+
75
97
  private
76
98
 
77
99
  def fetch_glyphs_by_page
@@ -12,7 +12,16 @@ module Ucode
12
12
  # renderer.
13
13
  class Renderer
14
14
  # Result of rendering one codepoint.
15
- Result = Struct.new(:codepoint, :base_font, :gid, :svg, keyword_init: true) do
15
+ #
16
+ # `source_page` + `source_cell` come from the Catalog's
17
+ # location index when available. Nil when the catalog hasn't
18
+ # traced the PDF (ToUnicode-only path) — downstream concerns
19
+ # treat that as "no positional data; skip cell-based diff".
20
+ Result = Struct.new(
21
+ :codepoint, :base_font, :gid, :svg,
22
+ :source_page, :source_cell,
23
+ keyword_init: true,
24
+ ) do
16
25
  def ok?
17
26
  !svg.nil?
18
27
  end
@@ -37,7 +46,15 @@ module Ucode
37
46
  return nil if outline.nil? || outline.empty?
38
47
 
39
48
  svg = Svg.new(outline, codepoint: codepoint, base_font: entry.base_font).to_s
40
- Result.new(codepoint: codepoint, base_font: entry.base_font, gid: gid, svg: svg)
49
+ location = @catalog.location_for(codepoint)
50
+ Result.new(
51
+ codepoint: codepoint,
52
+ base_font: entry.base_font,
53
+ gid: gid,
54
+ svg: svg,
55
+ source_page: location&.fetch(:page),
56
+ source_cell: location && { x: location[:x], y: location[:y] },
57
+ )
41
58
  end
42
59
  end
43
60
  end
@@ -25,7 +25,17 @@ module Ucode
25
25
  # One resolved glyph. Carries the SVG payload and enough
26
26
  # provenance to debug "where did this glyph come from?" without
27
27
  # holding a reference back to the source.
28
- Result = Struct.new(:tier, :codepoint, :svg, :provenance, keyword_init: true)
28
+ #
29
+ # The optional `base_font`, `gid`, `source_page`, `source_cell`
30
+ # fields carry the renderer's localization data so downstream
31
+ # concerns (Verifier pixel-diff against the source PDF cell,
32
+ # Provenance audit trail) don't need to re-derive it. Sources
33
+ # that don't know this data (e.g. LastResort) leave them nil.
34
+ Result = Struct.new(
35
+ :tier, :codepoint, :svg, :provenance,
36
+ :base_font, :gid, :source_page, :source_cell,
37
+ keyword_init: true,
38
+ )
29
39
 
30
40
  # @return [Symbol] one of :tier1, :pillar1, :pillar2, :pillar3
31
41
  def tier
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ucode/glyphs/source"
4
- require "ucode/glyphs/embedded_fonts/renderer"
5
-
6
3
  module Ucode
7
4
  module Glyphs
8
5
  module Sources
@@ -54,8 +51,16 @@ module Ucode
54
51
  result = @renderer.render(codepoint)
55
52
  return nil unless result
56
53
 
57
- Result.new(tier: tier, codepoint: codepoint,
58
- svg: result.svg, provenance: provenance)
54
+ Result.new(
55
+ tier: tier,
56
+ codepoint: codepoint,
57
+ svg: result.svg,
58
+ provenance: provenance,
59
+ base_font: result.base_font,
60
+ gid: result.gid,
61
+ source_page: result.source_page,
62
+ source_cell: result.source_cell,
63
+ )
59
64
  end
60
65
  end
61
66
  end
data/lib/ucode/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ucode
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/ucode.rb CHANGED
@@ -34,7 +34,9 @@ module Ucode
34
34
  autoload :GlyphError, "ucode/error"
35
35
  autoload :LastResortMissingError, "ucode/error"
36
36
  autoload :EmbeddedFontsMissingError, "ucode/error"
37
+ autoload :CodeChartError, "ucode/error"
37
38
  autoload :CodeChartNotFoundError, "ucode/error"
39
+ autoload :CodeChartChecksumError, "ucode/error"
38
40
  autoload :UnknownUnicodeVersionError, "ucode/error"
39
41
 
40
42
  # Infrastructure
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ucode
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-08 00:00:00.000000000 Z
11
+ date: 2026-07-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -239,9 +239,24 @@ files:
239
239
  - lib/ucode/cache.rb
240
240
  - lib/ucode/cli.rb
241
241
  - lib/ucode/code_chart.rb
242
+ - lib/ucode/code_chart/batch_runner.rb
243
+ - lib/ucode/code_chart/block_index.rb
244
+ - lib/ucode/code_chart/coverage_gap_index.rb
242
245
  - lib/ucode/code_chart/extractor.rb
246
+ - lib/ucode/code_chart/fetcher.rb
247
+ - lib/ucode/code_chart/gap_analyzer.rb
248
+ - lib/ucode/code_chart/gap_analyzer/block_gap.rb
249
+ - lib/ucode/code_chart/gap_analyzer/essenfont_manifest.rb
250
+ - lib/ucode/code_chart/gap_analyzer/manifest.rb
243
251
  - lib/ucode/code_chart/provenance.rb
244
252
  - lib/ucode/code_chart/sidecar.rb
253
+ - lib/ucode/code_chart/verifier.rb
254
+ - lib/ucode/code_chart/verifier/builder.rb
255
+ - lib/ucode/code_chart/verifier/mutool_strategy.rb
256
+ - lib/ucode/code_chart/verifier/page_render_cache.rb
257
+ - lib/ucode/code_chart/verifier/result.rb
258
+ - lib/ucode/code_chart/verifier/resvg_strategy.rb
259
+ - lib/ucode/code_chart/verifier/strategy.rb
245
260
  - lib/ucode/code_chart/writer.rb
246
261
  - lib/ucode/commands.rb
247
262
  - lib/ucode/commands/audit.rb