ucode 0.3.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +45 -6
- data/config/unicode17_universal_glyph_set.yml +1 -1
- data/lib/ucode/cli.rb +201 -32
- data/lib/ucode/code_chart/batch_runner.rb +184 -0
- data/lib/ucode/code_chart/block_index.rb +89 -0
- data/lib/ucode/code_chart/coverage_gap_index.rb +115 -0
- data/lib/ucode/code_chart/extractor.rb +65 -13
- 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 +21 -14
- data/lib/ucode/code_chart.rb +6 -0
- data/lib/ucode/error.rb +16 -1
- data/lib/ucode/fetch/http.rb +23 -3
- data/lib/ucode/glyphs/embedded_fonts/catalog.rb +66 -3
- data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper/correlator_strategy.rb +46 -0
- data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper/strategy.rb +54 -0
- data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper/tounicode_strategy.rb +41 -0
- data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper/trace_strategy.rb +64 -0
- data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper.rb +150 -90
- data/lib/ucode/glyphs/embedded_fonts/mutool/draw.rb +26 -0
- data/lib/ucode/glyphs/embedded_fonts/mutool/info.rb +23 -0
- data/lib/ucode/glyphs/embedded_fonts/mutool/show.rb +36 -0
- data/lib/ucode/glyphs/embedded_fonts/mutool/trace.rb +26 -0
- data/lib/ucode/glyphs/embedded_fonts/mutool.rb +30 -0
- data/lib/ucode/glyphs/embedded_fonts/page_trace_cache.rb +112 -0
- data/lib/ucode/glyphs/embedded_fonts/pdf_indexer.rb +50 -56
- data/lib/ucode/glyphs/embedded_fonts/{pdf_location.rb → pdf_source.rb} +1 -1
- data/lib/ucode/glyphs/embedded_fonts/renderer.rb +19 -2
- data/lib/ucode/glyphs/embedded_fonts/trace_runner.rb +12 -28
- data/lib/ucode/glyphs/embedded_fonts.rb +3 -1
- data/lib/ucode/glyphs/real_fonts/coverage_auditor.rb +0 -4
- data/lib/ucode/glyphs/real_fonts/font_coverage_report.rb +0 -2
- data/lib/ucode/glyphs/real_fonts/writer.rb +0 -2
- data/lib/ucode/glyphs/source.rb +11 -1
- data/lib/ucode/glyphs/sources/pillar1_embedded_tounicode.rb +11 -6
- data/lib/ucode/version.rb +1 -1
- data/lib/ucode.rb +2 -0
- metadata +28 -74
- data/TODO.extract-code-chart/01-pdf-fetch-validation.md +0 -80
- data/TODO.extract-code-chart/02-block-name-resolver.md +0 -68
- data/TODO.extract-code-chart/03-codechart-namespace.md +0 -82
- data/TODO.extract-code-chart/04-codechart-extractor.md +0 -154
- data/TODO.extract-code-chart/05-provenance-and-sidecar.md +0 -147
- data/TODO.extract-code-chart/06-codechart-writer.md +0 -134
- data/TODO.extract-code-chart/07-codechart-cli.md +0 -135
- data/TODO.extract-code-chart/08-specs.md +0 -87
- data/TODO.full/00-README.md +0 -116
- data/TODO.full/01-panglyph-vision.md +0 -112
- data/TODO.full/02-panglyph-repo-bootstrap.md +0 -184
- data/TODO.full/03-panglyph-font-builder.md +0 -201
- data/TODO.full/04-panglyph-publish-pipeline.md +0 -126
- data/TODO.full/05-ucode-0-1-1-release.md +0 -139
- data/TODO.full/06-fontisan-remove-audit.md +0 -142
- data/TODO.full/07-fontisan-remove-ucd.md +0 -125
- data/TODO.full/08-archive-private-bin-build.md +0 -143
- data/TODO.full/09-archive-public-structure.md +0 -164
- data/TODO.full/10-fontist-org-woff-glyphs.md +0 -131
- data/TODO.full/11-fontist-org-audit-coverage.md +0 -140
- data/TODO.full/12-implementation-order.md +0 -216
- data/TODO.full/13-fontisan-font-writer-api.md +0 -189
- data/TODO.full/14-fontisan-table-writers.md +0 -66
- data/TODO.full/15-panglyph-builder-real.md +0 -82
- data/TODO.full/16-archive-public-sync-workflows.md +0 -167
- data/TODO.full/17-fontist-org-font-picker.md +0 -73
- data/TODO.full/18-comprehensive-spec-coverage.md +0 -64
- data/TODO.full/19-ucode-0-1-2-patch.md +0 -32
- data/TODO.full/20-fontisan-0-2-23-release.md +0 -52
- data/TODO.new/00-README.md +0 -96
- data/TODO.new/01-pillar-terminology-alignment.md +0 -69
- data/TODO.new/02-audit-schema-design.md +0 -255
- data/TODO.new/03-directory-output-spec.md +0 -203
- data/TODO.new/04-fontist-org-contract.md +0 -173
- data/TODO.new/05-baseline-unicode17-coverage-audit.md +0 -144
- data/TODO.new/06-audit-namespace-skeleton.md +0 -105
- data/TODO.new/07-audit-models-port.md +0 -132
- data/TODO.new/08-extractors-cheap-port.md +0 -113
- data/TODO.new/09-extractors-expensive-port.md +0 -99
- data/TODO.new/10-aggregations-ucd-rewrite.md +0 -168
- data/TODO.new/11-differ-and-library-auditor-port.md +0 -102
- data/TODO.new/12-formatters-port.md +0 -115
- data/TODO.new/13-directory-emitter.md +0 -147
- data/TODO.new/14-html-face-browser.md +0 -144
- data/TODO.new/15-html-library-browser.md +0 -102
- data/TODO.new/16-cli-audit-subcommands.md +0 -142
- data/TODO.new/17-fontisan-cleanup-audit.md +0 -147
- data/TODO.new/18-fontisan-cleanup-ucd.md +0 -156
- data/TODO.new/19-fontisan-docs-update.md +0 -155
- data/TODO.new/20-canonical-resolver-4-tier.md +0 -182
- data/TODO.new/21-canonical-unicode17-build.md +0 -148
- data/TODO.new/22-implementation-order.md +0 -176
- data/TODO.new/23-universal-glyph-set-source-map.md +0 -312
- data/TODO.new/24-universal-glyph-set-build.md +0 -189
- data/TODO.new/25-font-audit-against-universal-set.md +0 -195
- data/TODO.new/26-missing-glyph-reporter.md +0 -189
- data/TODO.new/27-fontist-org-consumer-integration.md +0 -200
- data/TODO.new/28-implementation-order-update.md +0 -187
- data/TODO.new/29-universal-set-curation-uc17.md +0 -312
- data/TODO.new/30-tier1-font-acquisition.md +0 -241
- data/TODO.new/31-universal-set-production-build.md +0 -205
- data/TODO.new/32-uc17-coverage-matrix.md +0 -165
- data/TODO.new/33-specialist-font-acquisition-refresh.md +0 -138
- data/TODO.new/34-pillar2-content-stream-correlator.md +0 -147
- data/TODO.new/35-universal-set-production-run.md +0 -160
- data/TODO.new/36-per-font-coverage-audit.md +0 -145
- data/TODO.new/37-coverage-highlight-reporter.md +0 -125
- data/TODO.new/38-fontist-org-glyph-consumer.md +0 -141
- data/TODO.new/39-implementation-order-update-32-38.md +0 -258
- data/TODO.new/40-archive-private-uses-ucode-audit.md +0 -124
- data/TODO.new/41-ucode-unicode-archive-bridge.md +0 -160
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
|
+
require "pathname"
|
|
4
5
|
require "time"
|
|
5
6
|
|
|
6
|
-
require "
|
|
7
|
+
require "lutaml/model"
|
|
7
8
|
|
|
8
9
|
module Ucode
|
|
9
10
|
module CodeChart
|
|
10
11
|
# Per-codepoint provenance value object — every field the REQ
|
|
11
|
-
# (
|
|
12
|
+
# (R6) requires in the sidecar JSON next to each extracted SVG.
|
|
12
13
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# Adding a field
|
|
14
|
+
# The single source of truth for the sidecar schema: a
|
|
15
|
+
# lutaml-model class with an explicit `key_value` mapping block.
|
|
16
|
+
# Adding a field = one `attribute` declaration + one `map` line
|
|
17
|
+
# in the mapping block. The {Sidecar} writer calls `to_hash`
|
|
18
|
+
# (framework-provided); no hand-rolled `to_h` anywhere.
|
|
16
19
|
#
|
|
17
20
|
# `extractor_version` reads from `Ucode::VERSION` at construction
|
|
18
21
|
# so the field stays in sync with the gem's version bump — single
|
|
@@ -20,63 +23,101 @@ module Ucode
|
|
|
20
23
|
#
|
|
21
24
|
# `extracted_at` is the extraction event timestamp (UTC ISO8601),
|
|
22
25
|
# not the file-write timestamp.
|
|
23
|
-
Provenance
|
|
24
|
-
:codepoint,
|
|
25
|
-
:block,
|
|
26
|
-
:source_pdf_url,
|
|
27
|
-
:source_pdf_sha256,
|
|
28
|
-
:ucd_version,
|
|
29
|
-
:extracted_at,
|
|
30
|
-
:extractor_version,
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
class Provenance < Lutaml::Model::Serializable
|
|
27
|
+
attribute :codepoint, :string
|
|
28
|
+
attribute :block, :string
|
|
29
|
+
attribute :source_pdf_url, :string
|
|
30
|
+
attribute :source_pdf_sha256, :string
|
|
31
|
+
attribute :ucd_version, :string
|
|
32
|
+
attribute :extracted_at, :string
|
|
33
|
+
attribute :extractor_version, :string
|
|
34
|
+
attribute :base_font, :string
|
|
35
|
+
attribute :gid, :integer
|
|
36
|
+
attribute :source_page, :integer
|
|
37
|
+
attribute :source_cell, :hash
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
key_value do
|
|
40
|
+
map "codepoint", to: :codepoint
|
|
41
|
+
map "block", to: :block
|
|
42
|
+
map "source_pdf_url", to: :source_pdf_url
|
|
43
|
+
map "source_pdf_sha256", to: :source_pdf_sha256
|
|
44
|
+
map "ucd_version", to: :ucd_version
|
|
45
|
+
map "extracted_at", to: :extracted_at
|
|
46
|
+
map "extractor_version", to: :extractor_version
|
|
47
|
+
map "base_font", to: :base_font
|
|
48
|
+
map "gid", to: :gid
|
|
49
|
+
map "source_page", to: :source_page
|
|
50
|
+
map "source_cell", to: :source_cell
|
|
51
|
+
end
|
|
47
52
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
Provenance
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
53
|
+
# Computes the source PDF's URL from a block name and first
|
|
54
|
+
# codepoint. Mirrors the per-block URL convention in
|
|
55
|
+
# {Ucode::Fetch::CodeCharts}: the hex representation of the
|
|
56
|
+
# codepoint, zero-padded to a minimum of 4 digits (e.g.
|
|
57
|
+
# `U0000.pdf` for BMP, `U10920.pdf` for Plane 1,
|
|
58
|
+
# `U100000.pdf` for Plane 16 SPUA-B).
|
|
59
|
+
#
|
|
60
|
+
# @param block_first_cp [Integer]
|
|
61
|
+
# @return [String]
|
|
62
|
+
def self.code_chart_url(block_first_cp)
|
|
63
|
+
slug = block_first_cp.to_s(16).upcase.rjust(4, "0")
|
|
64
|
+
"#{Ucode.configuration.charts_base_url}/U#{slug}.pdf"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Builds a Provenance from the inputs the {Writer} has on hand
|
|
68
|
+
# (block, codepoint, ucd_version, pdf_path). Computes the PDF
|
|
69
|
+
# hash + URL once. The `extracted_at` timestamp is fixed at
|
|
70
|
+
# call time so re-running the same block produces identical
|
|
71
|
+
# provenance JSON for unchanged codepoints.
|
|
72
|
+
#
|
|
73
|
+
# The optional `base_font`, `gid`, `source_page`, `source_cell`
|
|
74
|
+
# come from the {Extractor::Result} when available. Nil values
|
|
75
|
+
# are emitted as JSON `null` — preserves the audit trail's
|
|
76
|
+
# honesty about what the extractor actually knew.
|
|
77
|
+
#
|
|
78
|
+
# @param block [Ucode::Models::Block]
|
|
79
|
+
# @param codepoint [Integer]
|
|
80
|
+
# @param ucd_version [String]
|
|
81
|
+
# @param pdf_path [Pathname, String]
|
|
82
|
+
# @param pdf_sha [String, nil] pre-computed sha256 hex digest.
|
|
83
|
+
# When nil, computed from `pdf_path`. The {Writer} passes its
|
|
84
|
+
# already-computed summary hash to avoid re-reading the PDF
|
|
85
|
+
# per codepoint.
|
|
86
|
+
# @param now [Time, nil] override for tests
|
|
87
|
+
# @param base_font [String, nil] PDF BaseFont name
|
|
88
|
+
# @param gid [Integer, nil] GID inside that font
|
|
89
|
+
# @param source_page [Integer, nil] 1-based PDF page number
|
|
90
|
+
# @param source_cell [Hash{Symbol=>Float}, nil] `{x:, y:}` PDF
|
|
91
|
+
# user space coordinates of the specimen
|
|
92
|
+
# @return [Provenance]
|
|
93
|
+
def self.build(block:, codepoint:, ucd_version:, pdf_path:,
|
|
94
|
+
pdf_sha: nil, now: nil,
|
|
95
|
+
base_font: nil, gid: nil,
|
|
96
|
+
source_page: nil, source_cell: nil)
|
|
97
|
+
path = Pathname.new(pdf_path)
|
|
98
|
+
new(
|
|
99
|
+
codepoint: format("U+%04X", codepoint),
|
|
100
|
+
block: block.id,
|
|
101
|
+
source_pdf_url: code_chart_url(block.range_first),
|
|
102
|
+
source_pdf_sha256: pdf_sha || sha256_of(path),
|
|
103
|
+
ucd_version: ucd_version,
|
|
104
|
+
extracted_at: (now || Time.now.utc).iso8601,
|
|
105
|
+
extractor_version: Ucode::VERSION,
|
|
106
|
+
base_font: base_font,
|
|
107
|
+
gid: gid,
|
|
108
|
+
source_page: source_page,
|
|
109
|
+
source_cell: source_cell,
|
|
110
|
+
)
|
|
111
|
+
end
|
|
72
112
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
113
|
+
# @param path [Pathname]
|
|
114
|
+
# @return [String] hex digest, "" when the path doesn't exist
|
|
115
|
+
# (callers can decide how to handle a missing hash)
|
|
116
|
+
def self.sha256_of(path)
|
|
117
|
+
return "" unless path.exist?
|
|
78
118
|
|
|
79
|
-
|
|
119
|
+
Digest::SHA256.file(path).hexdigest
|
|
120
|
+
end
|
|
80
121
|
end
|
|
81
122
|
end
|
|
82
123
|
end
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "pathname"
|
|
5
5
|
|
|
6
|
-
require "ucode/repo/atomic_writes"
|
|
7
|
-
|
|
8
6
|
module Ucode
|
|
9
7
|
module CodeChart
|
|
10
8
|
# Writes a {Provenance} to disk as the sidecar JSON next to its
|
|
@@ -17,7 +15,8 @@ module Ucode
|
|
|
17
15
|
# Idempotent via {Ucode::Repo::AtomicWrites#write_atomic}: a
|
|
18
16
|
# re-write of byte-identical content is a no-op (no temp-file
|
|
19
17
|
# rename). Provenance JSON is canonical (sorted keys via Ruby's
|
|
20
|
-
# stdlib JSON
|
|
18
|
+
# stdlib JSON over the lutaml-model `to_hash` output), so the
|
|
19
|
+
# byte-equality test is sound.
|
|
21
20
|
class Sidecar
|
|
22
21
|
include Ucode::Repo::AtomicWrites
|
|
23
22
|
|
|
@@ -31,7 +30,7 @@ module Ucode
|
|
|
31
30
|
# @return [Pathname] the written sidecar path
|
|
32
31
|
def write(provenance)
|
|
33
32
|
path = path_for(provenance)
|
|
34
|
-
payload = "#{JSON.pretty_generate(provenance.
|
|
33
|
+
payload = "#{JSON.pretty_generate(provenance.to_hash)}\n"
|
|
35
34
|
write_atomic(path, payload)
|
|
36
35
|
path
|
|
37
36
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ucode
|
|
4
|
+
module CodeChart
|
|
5
|
+
class Verifier
|
|
6
|
+
# Selects a {Strategy} based on tool availability. Preference
|
|
7
|
+
# order:
|
|
8
|
+
#
|
|
9
|
+
# 1. `UCODE_VERIFIER_STRATEGY` env var override — explicit
|
|
10
|
+
# user choice. Value is the strategy class name (e.g.
|
|
11
|
+
# `"resvg"`, `"mutool"`).
|
|
12
|
+
# 2. {ResvgStrategy} — preferred for accuracy and speed.
|
|
13
|
+
# 3. {MutoolStrategy} — fallback (always installed if the
|
|
14
|
+
# trace pipeline works).
|
|
15
|
+
#
|
|
16
|
+
# Returns nil when no strategy is available; {Verifier} then
|
|
17
|
+
# emits `Skipped` for every glyph.
|
|
18
|
+
module Builder
|
|
19
|
+
ORDER = %w[resvg mutool].freeze
|
|
20
|
+
private_constant :ORDER
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# @param strategies [Array<Strategy>, nil] injectable list
|
|
24
|
+
# for tests. nil = construct the default chain.
|
|
25
|
+
# @return [Strategy, nil]
|
|
26
|
+
def pick(strategies: nil)
|
|
27
|
+
strategies ||= default_strategies
|
|
28
|
+
env_choice = ENV["UCODE_VERIFIER_STRATEGY"]
|
|
29
|
+
if env_choice
|
|
30
|
+
found = strategies.find { |s| name_of(s) == env_choice }
|
|
31
|
+
return found if found&.available?
|
|
32
|
+
end
|
|
33
|
+
strategies.find(&:available?)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def default_strategies
|
|
39
|
+
[ResvgStrategy.new, MutoolStrategy.new]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def name_of(strategy)
|
|
43
|
+
strategy.class.name.split("::").last.sub("Strategy", "").downcase
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Ucode
|
|
7
|
+
module CodeChart
|
|
8
|
+
class Verifier
|
|
9
|
+
# {Strategy} backed by `mutool draw`. Slower than resvg but
|
|
10
|
+
# always available on a system that has the trace pipeline
|
|
11
|
+
# working (mutool is already required elsewhere).
|
|
12
|
+
#
|
|
13
|
+
# Pixel diff: byte-wise comparison of the two rendered PNGs.
|
|
14
|
+
# Crude (real pixel diff needs ChunkyPNG or vips), but
|
|
15
|
+
# sufficient to catch the regression classes the REQ lists:
|
|
16
|
+
# clipped glyphs, missing paths, wrong-cell content,
|
|
17
|
+
# coordinate-flip errors. Returns the percentage of differing
|
|
18
|
+
# bytes — a useful relative signal even when not a true
|
|
19
|
+
# pixel-perfect diff.
|
|
20
|
+
class MutoolStrategy < Strategy
|
|
21
|
+
# @param runner [Ucode::Glyphs::EmbeddedFonts::Mutool::SystemRunner]
|
|
22
|
+
# injectable for tests
|
|
23
|
+
def initialize(runner: Ucode::Glyphs::EmbeddedFonts::Mutool::SystemRunner.new)
|
|
24
|
+
super()
|
|
25
|
+
@runner = runner
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def available?
|
|
29
|
+
system("which mutool >/dev/null 2>&1")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def render_svg(svg_path, png_path, scale: 2.0)
|
|
33
|
+
dpi = (72 * scale).round
|
|
34
|
+
@runner.run("mutool", "draw", "-o", png_path.to_s,
|
|
35
|
+
"-r", dpi.to_s, svg_path.to_s)
|
|
36
|
+
Pathname.new(png_path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def render_pdf_region(pdf_path, page, rect, png_path, scale: 2.0)
|
|
40
|
+
dpi = (72 * scale).round
|
|
41
|
+
@runner.run("mutool", "draw", "-o", png_path.to_s,
|
|
42
|
+
"-r", dpi.to_s,
|
|
43
|
+
"-R", format_rect(rect, scale),
|
|
44
|
+
pdf_path.to_s, page.to_s)
|
|
45
|
+
Pathname.new(png_path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def diff(png_a, png_b)
|
|
49
|
+
bytes_a = File.binread(png_a)
|
|
50
|
+
bytes_b = File.binread(png_b)
|
|
51
|
+
return 100.0 if bytes_a.bytesize != bytes_b.bytesize
|
|
52
|
+
|
|
53
|
+
same = bytes_a.each_byte.with_index.count do |byte, idx|
|
|
54
|
+
byte == bytes_b.getbyte(idx)
|
|
55
|
+
end
|
|
56
|
+
(100.0 * (1.0 - same.to_f / bytes_a.bytesize)).round(2)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def write_diff_artifact(png_a, png_b, dest)
|
|
60
|
+
data_a = File.binread(png_a)
|
|
61
|
+
data_b = File.binread(png_b)
|
|
62
|
+
Pathname.new(dest).binwrite(data_a + data_b)
|
|
63
|
+
Pathname.new(dest)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# mutool's -R flag takes a bbox in page coordinates
|
|
69
|
+
# pre-scaled by the DPI factor: `<x0,y0,x1,y1>`.
|
|
70
|
+
def format_rect(rect, scale)
|
|
71
|
+
x0 = (rect[:x] * scale).round
|
|
72
|
+
y0 = (rect[:y] * scale).round
|
|
73
|
+
x1 = x0 + (rect[:w] * scale).round
|
|
74
|
+
y1 = y0 + (rect[:h] * scale).round
|
|
75
|
+
"#{x0},#{y0},#{x1},#{y1}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Ucode
|
|
6
|
+
module CodeChart
|
|
7
|
+
class Verifier
|
|
8
|
+
# Caches per-page PDF→PNG renders so multiple glyphs on the
|
|
9
|
+
# same page share one `mutool draw` invocation. The Verifier
|
|
10
|
+
# asks for the rendered page; the cache either reuses the
|
|
11
|
+
# existing PNG or renders it on demand.
|
|
12
|
+
#
|
|
13
|
+
# PNG path layout: `<diff_dir>/.cache/page-<N>-<sha>.png`
|
|
14
|
+
# where `<sha>` is a short hash of the PDF path (so two
|
|
15
|
+
# different PDFs with page 1 don't collide).
|
|
16
|
+
class PageRenderCache
|
|
17
|
+
# @param diff_dir [Pathname, String]
|
|
18
|
+
# @param strategy [Strategy]
|
|
19
|
+
def initialize(diff_dir:, strategy:)
|
|
20
|
+
@diff_dir = Pathname.new(diff_dir)
|
|
21
|
+
@strategy = strategy
|
|
22
|
+
@cache = {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param pdf_path [Pathname, String]
|
|
26
|
+
# @param page [Integer] 1-based page number
|
|
27
|
+
# @param scale [Float]
|
|
28
|
+
# @return [Pathname] the rendered page PNG path
|
|
29
|
+
def render_page(pdf_path, page, scale: 2.0)
|
|
30
|
+
key = [pdf_path.to_s, page, scale]
|
|
31
|
+
return @cache[key] if @cache.key?(key)
|
|
32
|
+
|
|
33
|
+
@diff_dir.mkpath
|
|
34
|
+
png = @diff_dir.join("page-#{page}-#{short_hash(pdf_path)}.png")
|
|
35
|
+
unless png.exist?
|
|
36
|
+
@strategy.render_pdf_region(
|
|
37
|
+
pdf_path, page,
|
|
38
|
+
full_page_rect, png,
|
|
39
|
+
scale: scale,
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
@cache[key] = png
|
|
43
|
+
png
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Naive "full page" rect. Per-cell cropping is the
|
|
49
|
+
# strategy's responsibility when the caller passes a
|
|
50
|
+
# specific rect; the cache only handles "give me the whole
|
|
51
|
+
# page so I can derive cell rects from it".
|
|
52
|
+
def full_page_rect
|
|
53
|
+
{ x: 0, y: 0, w: 612, h: 792 }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def short_hash(path)
|
|
57
|
+
require "digest"
|
|
58
|
+
Digest::SHA256.file(path).hexdigest[0, 8]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ucode
|
|
4
|
+
module CodeChart
|
|
5
|
+
class Verifier
|
|
6
|
+
# Typed result of verifying one {Extractor::Result}. One of
|
|
7
|
+
# three concrete classes; pattern-match on the class to read
|
|
8
|
+
# type-specific fields.
|
|
9
|
+
module Result
|
|
10
|
+
# Diff < {Verifier::Strategy::FAIL_THRESHOLD}. The extracted
|
|
11
|
+
# SVG matches the source PDF cell within tolerance.
|
|
12
|
+
Pass = Struct.new(:codepoint, :percent, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
# Diff ≥ threshold. Carries the diff artifact path so a human
|
|
15
|
+
# can inspect the failure.
|
|
16
|
+
Fail = Struct.new(:codepoint, :percent, :diff_path,
|
|
17
|
+
keyword_init: true)
|
|
18
|
+
|
|
19
|
+
# No positional data on the {Extractor::Result}; no honest
|
|
20
|
+
# cell-diff is possible. `reason` carries one of:
|
|
21
|
+
# * `:no_location` — source_page/source_cell were nil.
|
|
22
|
+
# * `:no_pdf` — the PDF path is missing/unreadable.
|
|
23
|
+
Skipped = Struct.new(:codepoint, :reason, keyword_init: true)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Ucode
|
|
7
|
+
module CodeChart
|
|
8
|
+
class Verifier
|
|
9
|
+
# {Strategy} backed by the `resvg` CLI (`resvg --help`). Preferred
|
|
10
|
+
# over {MutoolStrategy} for accuracy and speed when available —
|
|
11
|
+
# resvg is purpose-built for SVG rendering and produces
|
|
12
|
+
# pixel-stable output across platforms.
|
|
13
|
+
#
|
|
14
|
+
# Same byte-wise diff fallback as {MutoolStrategy}. PNG output
|
|
15
|
+
# is normalized via resvg's `--quantize 8` to reduce byte-level
|
|
16
|
+
# noise across renderer versions.
|
|
17
|
+
class ResvgStrategy < Strategy
|
|
18
|
+
# @param runner [Ucode::Glyphs::EmbeddedFonts::Mutool::SystemRunner]
|
|
19
|
+
# injectable for tests
|
|
20
|
+
def initialize(runner: Ucode::Glyphs::EmbeddedFonts::Mutool::SystemRunner.new)
|
|
21
|
+
super()
|
|
22
|
+
@runner = runner
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def available?
|
|
26
|
+
system("which resvg >/dev/null 2>&1")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render_svg(svg_path, png_path, scale: 2.0)
|
|
30
|
+
width = (1000 * scale).round
|
|
31
|
+
@runner.run("resvg", "-w", width.to_s,
|
|
32
|
+
"--quantize", "8",
|
|
33
|
+
svg_path.to_s, png_path.to_s)
|
|
34
|
+
Pathname.new(png_path)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def render_pdf_region(pdf_path, page, _rect, png_path, scale: 2.0)
|
|
38
|
+
# resvg doesn't render PDFs; we rely on mutool to first
|
|
39
|
+
# convert the page region to SVG, then resvg to PNG. Two
|
|
40
|
+
# subprocess hops, but reuses each tool's strength.
|
|
41
|
+
intermediate = Pathname.new("#{png_path}.svg")
|
|
42
|
+
dpi = (72 * scale).round
|
|
43
|
+
@runner.run("mutool", "draw", "-F", "svg", "-o", intermediate.to_s,
|
|
44
|
+
"-r", dpi.to_s, pdf_path.to_s, page.to_s)
|
|
45
|
+
@runner.run("resvg", "--quantize", "8",
|
|
46
|
+
intermediate.to_s, png_path.to_s)
|
|
47
|
+
FileUtils.rm_f(intermediate.to_s)
|
|
48
|
+
Pathname.new(png_path)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def diff(png_a, png_b)
|
|
52
|
+
bytes_a = File.binread(png_a)
|
|
53
|
+
bytes_b = File.binread(png_b)
|
|
54
|
+
return 100.0 if bytes_a.bytesize != bytes_b.bytesize
|
|
55
|
+
|
|
56
|
+
same = bytes_a.each_byte.with_index.count do |byte, idx|
|
|
57
|
+
byte == bytes_b.getbyte(idx)
|
|
58
|
+
end
|
|
59
|
+
(100.0 * (1.0 - same.to_f / bytes_a.bytesize)).round(2)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def write_diff_artifact(png_a, png_b, dest)
|
|
63
|
+
data_a = File.binread(png_a)
|
|
64
|
+
data_b = File.binread(png_b)
|
|
65
|
+
Pathname.new(dest).binwrite(data_a + data_b)
|
|
66
|
+
Pathname.new(dest)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Ucode
|
|
6
|
+
module CodeChart
|
|
7
|
+
class Verifier
|
|
8
|
+
# Abstract base for one verification-renderer strategy. Each
|
|
9
|
+
# subclass owns ONE external CLI tool (resvg, mutool, etc.)
|
|
10
|
+
# and implements the three primitives the {Verifier} needs.
|
|
11
|
+
#
|
|
12
|
+
# Subclasses must implement:
|
|
13
|
+
# * {#available?} — is the underlying tool installed?
|
|
14
|
+
# * {#render_svg} — render an SVG to a PNG at the given scale.
|
|
15
|
+
# * {#render_pdf_region} — render a rectangular region of one
|
|
16
|
+
# PDF page to a PNG at the given scale.
|
|
17
|
+
# * {#diff} — pixel-diff percentage between two PNGs.
|
|
18
|
+
#
|
|
19
|
+
# Adding a new renderer = one subclass + one entry in {Builder}.
|
|
20
|
+
# No edit to {Verifier}.
|
|
21
|
+
class Strategy
|
|
22
|
+
# Diff percentage at or above which a glyph is considered
|
|
23
|
+
# visually different from the source. Tuned to absorb
|
|
24
|
+
# anti-aliasing differences across renderers; callers can
|
|
25
|
+
# override via the {Verifier} constructor.
|
|
26
|
+
FAIL_THRESHOLD = 1.0
|
|
27
|
+
|
|
28
|
+
# @return [Boolean] true iff the underlying tool is on PATH
|
|
29
|
+
def available?
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param svg_path [Pathname, String] source SVG
|
|
34
|
+
# @param png_path [Pathname, String] destination PNG
|
|
35
|
+
# @param scale [Float] zoom factor (2.0 = 200%)
|
|
36
|
+
# @return [Pathname] the written PNG path
|
|
37
|
+
def render_svg(_svg_path, _png_path, scale: 2.0)
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param pdf_path [Pathname, String] source PDF
|
|
42
|
+
# @param page [Integer] 1-based page number
|
|
43
|
+
# @param rect [Hash{Symbol=>Float}] `{x:, y:, w:, h:}` in PDF
|
|
44
|
+
# user space (origin bottom-left) of the region to render.
|
|
45
|
+
# @param png_path [Pathname, String] destination PNG
|
|
46
|
+
# @param scale [Float] zoom factor
|
|
47
|
+
# @return [Pathname]
|
|
48
|
+
def render_pdf_region(_pdf_path, _page, _rect, _png_path, scale: 2.0)
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param png_a [Pathname, String]
|
|
53
|
+
# @param png_b [Pathname, String]
|
|
54
|
+
# @return [Float] percentage of differing pixels (0.0–100.0)
|
|
55
|
+
def diff(_png_a, _png_b)
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Optional: produce a visual diff artifact (e.g. side-by-side
|
|
60
|
+
# or red/blue overlay). When unsupported, returns nil and the
|
|
61
|
+
# {Verifier} skips writing the artifact.
|
|
62
|
+
#
|
|
63
|
+
# @param png_a [Pathname, String]
|
|
64
|
+
# @param png_b [Pathname, String]
|
|
65
|
+
# @param dest [Pathname, String]
|
|
66
|
+
# @return [Pathname, nil]
|
|
67
|
+
def write_diff_artifact(_png_a, _png_b, _dest)
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|