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
@@ -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 "ucode/version"
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
- # (R5) requires in the sidecar JSON next to each extracted SVG.
12
+ # (R6) requires in the sidecar JSON next to each extracted SVG.
12
13
  #
13
- # Single source of truth for the provenance schema: the
14
- # {Sidecar} writer reads this Struct, the Writer constructs it.
15
- # Adding a field is one place to change.
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 = Struct.new(
24
- :codepoint,
25
- :block,
26
- :source_pdf_url,
27
- :source_pdf_sha256,
28
- :ucd_version,
29
- :extracted_at,
30
- :extractor_version,
31
- keyword_init: true,
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
- # Computes the source PDF's URL from a block name and first
35
- # codepoint. Mirrors the per-block URL convention in
36
- # {Ucode::Fetch::CodeCharts}: the hex representation of the
37
- # codepoint, zero-padded to a minimum of 4 digits (e.g.
38
- # `U0000.pdf` for BMP, `U10920.pdf` for Plane 1,
39
- # `U100000.pdf` for Plane 16 SPUA-B).
40
- #
41
- # @param block_first_cp [Integer]
42
- # @return [String]
43
- def self.code_chart_url(block_first_cp)
44
- slug = block_first_cp.to_s(16).upcase.rjust(4, "0")
45
- "#{Ucode.configuration.charts_base_url}/U#{slug}.pdf"
46
- end
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
- # Builds a Provenance from the inputs the {Writer} has on hand
49
- # (block, codepoint, ucd_version, pdf_path). Computes the PDF
50
- # hash + URL once. The `extracted_at` timestamp is fixed at
51
- # call time so re-running the same block produces identical
52
- # provenance JSON for unchanged codepoints.
53
- #
54
- # @param block [Ucode::Models::Block]
55
- # @param codepoint [Integer]
56
- # @param ucd_version [String]
57
- # @param pdf_path [Pathname, String]
58
- # @param now [Time, nil] override for tests
59
- # @return [Provenance]
60
- def self.build(block:, codepoint:, ucd_version:, pdf_path:, now: nil)
61
- path = Pathname.new(pdf_path)
62
- Provenance.new(
63
- codepoint: format("U+%04X", codepoint),
64
- block: block.id,
65
- source_pdf_url: code_chart_url(block.range_first),
66
- source_pdf_sha256: sha256_of(path),
67
- ucd_version: ucd_version,
68
- extracted_at: (now || Time.now.utc).iso8601,
69
- extractor_version: Ucode::VERSION,
70
- )
71
- end
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
- # @param path [Pathname]
74
- # @return [String] hex digest, "" when the path doesn't exist
75
- # (callers can decide how to handle a missing hash)
76
- def self.sha256_of(path)
77
- return "" unless path.exist?
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
- Digest::SHA256.file(path).hexdigest
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), so the byte-equality test is sound.
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.to_h)}\n"
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