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,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
|
|
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 =
|
|
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 =
|
|
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
|
data/lib/ucode/code_chart.rb
CHANGED
|
@@ -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 <
|
|
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
|
data/lib/ucode/fetch/http.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/ucode/glyphs/source.rb
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
58
|
-
|
|
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
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
|
+
version: 0.5.1
|
|
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-
|
|
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
|