ucode 0.2.1 → 0.2.3

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +10 -2
  3. data/README.md +66 -20
  4. data/Rakefile +18 -7
  5. data/config/unicode17_universal_glyph_set.yml +1 -1
  6. data/lib/ucode/audit/reference_factory.rb +1 -1
  7. data/lib/ucode/cli.rb +10 -42
  8. data/lib/ucode/code_chart/extractor.rb +3 -5
  9. data/lib/ucode/code_chart/provenance.rb +6 -5
  10. data/lib/ucode/code_chart/sidecar.rb +2 -2
  11. data/lib/ucode/code_chart/writer.rb +1 -1
  12. data/lib/ucode/code_chart.rb +1 -1
  13. data/lib/ucode/commands/build.rb +3 -26
  14. data/lib/ucode/commands/canonical_build.rb +1 -4
  15. data/lib/ucode/commands/fetch.rb +1 -1
  16. data/lib/ucode/commands/lookup.rb +1 -1
  17. data/lib/ucode/commands/parse.rb +1 -1
  18. data/lib/ucode/commands.rb +0 -1
  19. data/lib/ucode/coordinator/indices.rb +2 -2
  20. data/lib/ucode/error.rb +0 -8
  21. data/lib/ucode/fetch/code_charts.rb +2 -3
  22. data/lib/ucode/fetch/http.rb +12 -14
  23. data/lib/ucode/glyphs/embedded_fonts/catalog.rb +81 -4
  24. data/lib/ucode/glyphs/embedded_fonts/trace_correlator.rb +230 -0
  25. data/lib/ucode/glyphs/embedded_fonts/trace_glyph.rb +27 -0
  26. data/lib/ucode/glyphs/embedded_fonts/trace_parser.rb +50 -0
  27. data/lib/ucode/glyphs/embedded_fonts/trace_runner.rb +53 -0
  28. data/lib/ucode/glyphs/embedded_fonts.rb +4 -0
  29. data/lib/ucode/glyphs/pdf_fetcher.rb +7 -50
  30. data/lib/ucode/glyphs.rb +4 -14
  31. data/lib/ucode/repo/aggregate_writer.rb +1 -1
  32. data/lib/ucode/repo/writers/blocks_writer.rb +13 -13
  33. data/lib/ucode/repo/writers/enums_writer.rb +2 -2
  34. data/lib/ucode/repo/writers/indexes_writer.rb +4 -4
  35. data/lib/ucode/repo/writers/manifest_writer.rb +4 -4
  36. data/lib/ucode/repo/writers/named_sequences_writer.rb +1 -1
  37. data/lib/ucode/repo/writers/planes_writer.rb +17 -17
  38. data/lib/ucode/repo/writers/relationships_writer.rb +1 -1
  39. data/lib/ucode/repo/writers/scripts_writer.rb +6 -6
  40. data/lib/ucode/repo/writers.rb +1 -1
  41. data/lib/ucode/version.rb +1 -1
  42. data/lib/ucode.rb +0 -2
  43. data/ucode.gemspec +6 -1
  44. metadata +10 -19
  45. data/lib/ucode/commands/glyphs.rb +0 -94
  46. data/lib/ucode/glyphs/cell_extractor.rb +0 -130
  47. data/lib/ucode/glyphs/dvisvgm_renderer.rb +0 -29
  48. data/lib/ucode/glyphs/grid.rb +0 -30
  49. data/lib/ucode/glyphs/grid_detector.rb +0 -165
  50. data/lib/ucode/glyphs/monolith_page_map.rb +0 -181
  51. data/lib/ucode/glyphs/mutool_renderer.rb +0 -28
  52. data/lib/ucode/glyphs/page_renderer.rb +0 -221
  53. data/lib/ucode/glyphs/path_bbox.rb +0 -62
  54. data/lib/ucode/glyphs/pdf2svg_renderer.rb +0 -26
  55. data/lib/ucode/glyphs/pdftocairo_renderer.rb +0 -32
  56. data/lib/ucode/glyphs/pipeline.rb +0 -106
  57. data/lib/ucode/glyphs/writer.rb +0 -250
@@ -1,165 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "nokogiri"
4
-
5
- require "ucode/glyphs/grid"
6
- require "ucode/glyphs/path_bbox"
7
-
8
- module Ucode
9
- module Glyphs
10
- # Detects the chart grid in a Code Charts PDF page rendered to SVG.
11
- #
12
- # The PDF page produced by pdftocairo / pdf2svg / dvisvgm contains
13
- # every visible element (title, block name, row labels, codepoint
14
- # digits, and the actual character glyphs) as positioned `<use>`
15
- # references into a `<defs>` block of named glyph outlines. The
16
- # character cells we want to extract correspond to glyphs whose
17
- # bounding box is larger than every label or digit font on the
18
- # page — the chart's character samples are drawn at a larger size
19
- # than any of the surrounding text.
20
- #
21
- # Algorithm:
22
- # 1. Walk `<defs>`, estimate each glyph's bbox via `PathBbox`.
23
- # 2. Classify a glyph as "character-sized" when its width and
24
- # height both exceed `CharSizeThreshold` (default 8 pt).
25
- # This excludes title, row-label, and digit glyphs while
26
- # keeping every actual character sample — including pages
27
- # where the chart mixes multiple character fonts (e.g. the
28
- # Basic Latin page uses one font for punctuation/digits and
29
- # another for letters).
30
- # 3. Collect every `<use>` that references a character-sized
31
- # glyph; these are the cell origins.
32
- # 4. Cluster the Y values of those uses into rows, and within
33
- # each row cluster the X values into columns.
34
- # 5. Drop rows whose column count diverges from the modal value
35
- # (these are footer/header artifacts, not chart rows).
36
- # 6. Return a `Grid` value object anchored at the top-left cell
37
- # with uniform column/row pitches derived from the median
38
- # spacing between adjacent clusters.
39
- #
40
- # This is pure (no I/O). The detector takes a parsed Nokogiri
41
- # document and returns a `Grid`.
42
- class GridDetector
43
- CharSizeThreshold = 8.0
44
- ClusterEpsilon = 15.0
45
- private_constant :CharSizeThreshold, :ClusterEpsilon
46
-
47
- class << self
48
- # @param doc [Nokogiri::XML::Document]
49
- # @param block_first_cp [Integer] first codepoint of the block;
50
- # stored on the Grid so callers can map codepoint ↔ cell.
51
- # @return [Ucode::Glyphs::Grid, nil] nil if no character grid
52
- # could be detected.
53
- def detect(doc, block_first_cp:)
54
- uses = collect_uses(doc)
55
- return nil if uses.empty?
56
-
57
- char_glyph_ids = char_sized_glyph_ids(doc)
58
- return nil if char_glyph_ids.empty?
59
-
60
- cell_uses = uses.select { |u| char_glyph_ids.include?(u.glyph_id) }
61
- return nil if cell_uses.empty?
62
-
63
- build_grid(cell_uses, block_first_cp)
64
- end
65
-
66
- private
67
-
68
- UsePosition = Struct.new(:x, :y, :glyph_id, :set_id, keyword_init: true)
69
-
70
- def collect_uses(doc)
71
- doc.css("use").map do |node|
72
- href = node["xlink:href"] || node["href"] || ""
73
- glyph_id = href.sub(/\A#/, "")
74
- match = glyph_id.match(/\Aglyph-(\d+)-(\d+)\z/)
75
- next nil unless match
76
-
77
- UsePosition.new(
78
- x: node["x"].to_f,
79
- y: node["y"].to_f,
80
- glyph_id: glyph_id,
81
- set_id: match[1].to_i,
82
- )
83
- end.compact
84
- end
85
-
86
- def char_sized_glyph_ids(doc)
87
- doc.css("defs g[id^='glyph-']").each_with_object({}) do |g, acc|
88
- id = g["id"]
89
- next unless id =~ /\Aglyph-\d+-\d+\z/
90
-
91
- paths = g.css("path")
92
- next if paths.empty?
93
-
94
- bbox = paths.map { |p| PathBbox.estimate(p["d"]) }.reject(&:empty?).reduce do |a, b|
95
- PathBbox::Result.new(
96
- min_x: [a.min_x, b.min_x].min,
97
- min_y: [a.min_y, b.min_y].min,
98
- max_x: [a.max_x, b.max_x].max,
99
- max_y: [a.max_y, b.max_y].max,
100
- )
101
- end
102
- next unless bbox
103
-
104
- acc[id] = true if char_sized?(bbox)
105
- end
106
- end
107
-
108
- def char_sized?(bbox)
109
- bbox.width >= CharSizeThreshold && bbox.height >= CharSizeThreshold
110
- end
111
-
112
- def median(values)
113
- return 0.0 if values.empty?
114
-
115
- sorted = values.sort
116
- mid = sorted.size / 2
117
- sorted.size.even? ? (sorted[mid - 1] + sorted[mid]) / 2.0 : sorted[mid]
118
- end
119
-
120
- def build_grid(cell_uses, block_first_cp)
121
- row_clusters = cluster_by_value(cell_uses, :y)
122
- return nil if row_clusters.empty?
123
-
124
- column_clusters = cluster_by_value(cell_uses, :x)
125
- return nil if column_clusters.empty?
126
-
127
- column_starts = column_clusters.map { |c| c.map(&:x).min }.sort
128
- row_starts = row_clusters.map { |c| c.map(&:y).min }.sort
129
-
130
- Grid.new(
131
- origin_x: column_starts.first,
132
- origin_y: row_starts.first,
133
- column_pitch: median_pitch(column_starts),
134
- row_pitch: median_pitch(row_starts),
135
- columns: column_starts.size,
136
- rows: row_starts.size,
137
- block_first_cp: block_first_cp,
138
- )
139
- end
140
-
141
- def cluster_by_value(items, attr)
142
- sorted = items.sort_by { |i| i.public_send(attr) }
143
- clusters = []
144
- sorted.each do |item|
145
- value = item.public_send(attr)
146
- if clusters.empty? || (value - clusters.last[:max]).abs > ClusterEpsilon
147
- clusters << { max: value, items: [item] }
148
- else
149
- clusters.last[:max] = value
150
- clusters.last[:items] << item
151
- end
152
- end
153
- clusters.map { |c| c[:items] }
154
- end
155
-
156
- def median_pitch(sorted_values)
157
- return 0.0 if sorted_values.size < 2
158
-
159
- pitches = sorted_values.each_cons(2).map { |a, b| b - a }
160
- median(pitches)
161
- end
162
- end
163
- end
164
- end
165
- end
@@ -1,181 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "pathname"
4
- require "json"
5
- require "open3"
6
-
7
- module Ucode
8
- module Glyphs
9
- # Maps a Unicode block's first codepoint to its page range inside the
10
- # monolith `CodeCharts.pdf` by parsing the PDF's bookmark outline and
11
- # matching each bookmark title to a Block.name from `Blocks.txt`.
12
- #
13
- # Each chart cluster printed by the Unicode Consortium is a single
14
- # bookmark entry:
15
- #
16
- # BookmarkTitle: Greek and Coptic
17
- # BookmarkLevel: 1
18
- # BookmarkPageNumber: 415
19
- #
20
- # The cluster title usually equals a Block.name verbatim, but a few
21
- # clusters carry a heading that prepends "C0 Controls and " /
22
- # "C1 Controls and " to the block name. We resolve both forms.
23
- #
24
- # End-page of a cluster is one page before the next cluster's start
25
- # page (last cluster's end-page is the PDF's last page).
26
- #
27
- # The map is cached as JSON at `data/codecharts_page_map.json` so
28
- # we don't re-scan the 3,156-page monolith on every run.
29
- class MonolithPageMap
30
- BookmarkTitleRegex = /BookmarkTitle:\s*(.+)/.freeze
31
- BookmarkPageRegex = /BookmarkPageNumber:\s*(\d+)/.freeze
32
- private_constant :BookmarkTitleRegex, :BookmarkPageRegex
33
-
34
- # The Unicode charts print these multi-block clusters as a single
35
- # chart page (the C0/C1 control chars are drawn alongside their
36
- # block's other characters). Each cluster title maps to the single
37
- # block it belongs to.
38
- ClusterPrefixes = [
39
- "C0 Controls and ",
40
- "C1 Controls and ",
41
- ].freeze
42
- private_constant :ClusterPrefixes
43
-
44
- MapEntry = Struct.new(:first_cp, :start_page, :end_page, keyword_init: true)
45
-
46
- class << self
47
- # Build the map by parsing the monolith's outline and matching
48
- # each bookmark title to a Block.
49
- #
50
- # @param monolith_path [String, Pathname]
51
- # @param blocks [Array<Ucode::Models::Block>] the parsed Blocks table
52
- # @return [Hash{Integer => MapEntry}] keyed by block.range_first
53
- def build(monolith_path:, blocks:)
54
- name_to_first_cp = blocks.each_with_object({}) do |b, h|
55
- h[b.name] = b.range_first
56
- end
57
- total_pages = page_count(monolith_path)
58
- entries = parse_bookmarks(dump_bookmarks(monolith_path), name_to_first_cp)
59
- attach_end_pages(entries, total_pages)
60
- entries.each_with_object({}) do |e, h|
61
- h[e.first_cp] = e
62
- end
63
- end
64
-
65
- # Pure: parse a `pdftk dump_data` string into a list of
66
- # MapEntry rows (without end_pages). Exposed for unit tests
67
- # and any caller that already has the dump cached.
68
- #
69
- # @param dump [String] the raw `pdftk dump_data` output
70
- # @param name_to_first_cp [Hash{String => Integer}]
71
- # @return [Array<MapEntry>]
72
- def parse_bookmarks(dump, name_to_first_cp)
73
- entries = []
74
- current_title = nil
75
- dump.each_line do |line|
76
- case line
77
- when BookmarkTitleRegex
78
- current_title = Regexp.last_match(1).strip
79
- when BookmarkPageRegex
80
- page = Regexp.last_match(1).to_i
81
- cp = resolve_first_cp(current_title, name_to_first_cp)
82
- entries << MapEntry.new(first_cp: cp, start_page: page) if cp
83
- current_title = nil
84
- end
85
- end
86
- entries.sort_by(&:start_page)
87
- end
88
-
89
- # Pure: attach end_pages by sorting entries and assigning each
90
- # entry's end to one page before the next entry's start.
91
- #
92
- # @param entries [Array<MapEntry>]
93
- # @param total_pages [Integer, nil] page count of the source PDF;
94
- # the last entry's end_page falls back to this when present.
95
- # @return [Array<MapEntry>] the same entries, mutated with end_pages.
96
- def attach_end_pages(entries, total_pages = nil)
97
- sorted = entries.sort_by(&:start_page)
98
- sorted.each_with_index do |entry, i|
99
- next_entry = sorted[i + 1]
100
- entry.end_page = next_entry ? next_entry.start_page - 1 : total_pages
101
- end
102
- sorted
103
- end
104
-
105
- # Load from cache, or build and cache.
106
- # @param monolith_path [String, Pathname]
107
- # @param blocks [Array<Ucode::Models::Block>]
108
- # @param cache_path [String, Pathname, nil]
109
- # @return [Hash{Integer => MapEntry}]
110
- def load(monolith_path:, blocks:, cache_path: nil)
111
- cache = cache_path && Pathname.new(cache_path)
112
- if cache&.exist?
113
- return load_from_json(cache.read)
114
- end
115
-
116
- map = build(monolith_path: monolith_path, blocks: blocks)
117
- write_cache(map, cache) if cache
118
- map
119
- end
120
-
121
- # Look up a block's page range by its first cp.
122
- # @param map [Hash{Integer => MapEntry}]
123
- # @param block_first_cp [Integer]
124
- # @return [MapEntry, nil]
125
- def range_for(map, block_first_cp)
126
- map[block_first_cp]
127
- end
128
-
129
- # ---- I/O helpers (impure) --------------------------------------
130
-
131
- def dump_bookmarks(monolith_path)
132
- out, status = Open3.capture2e("pdftk", monolith_path.to_s, "dump_data")
133
- return "" unless status.success?
134
-
135
- out
136
- end
137
-
138
- def page_count(monolith_path)
139
- out, status = Open3.capture2e("pdfinfo", monolith_path.to_s)
140
- return nil unless status.success?
141
-
142
- match = out.match(/^Pages:\s+(\d+)/)
143
- match ? match[1].to_i : nil
144
- end
145
-
146
- private
147
-
148
- def resolve_first_cp(title, name_to_first_cp)
149
- return nil unless title
150
-
151
- return name_to_first_cp[title] if name_to_first_cp.key?(title)
152
-
153
- ClusterPrefixes.each do |prefix|
154
- stripped = title.sub(/\A#{Regexp.escape(prefix)}/, "")
155
- return name_to_first_cp[stripped] if name_to_first_cp.key?(stripped)
156
- end
157
-
158
- nil
159
- end
160
-
161
- def write_cache(map, cache_path)
162
- payload = map.values.map { |e| { "first_cp" => e.first_cp,
163
- "start_page" => e.start_page,
164
- "end_page" => e.end_page } }
165
- cache_path.dirname.mkpath
166
- cache_path.write(JSON.pretty_generate(payload))
167
- end
168
-
169
- def load_from_json(json)
170
- payload = JSON.parse(json)
171
- payload.each_with_object({}) do |row, h|
172
- entry = MapEntry.new(first_cp: row["first_cp"],
173
- start_page: row["start_page"],
174
- end_page: row["end_page"])
175
- h[entry.first_cp] = entry
176
- end
177
- end
178
- end
179
- end
180
- end
181
- end
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "ucode/glyphs/page_renderer"
4
-
5
- module Ucode
6
- module Glyphs
7
- # `mutool draw` from MuPDF — typically the fastest and cleanest.
8
- # Emits one `<svg>` per page with `<path>` vector data.
9
- #
10
- # Command: `mutool draw -F svg -o <out.svg> <in.pdf> <page>`
11
- class MutoolRenderer < PageRenderer
12
- class << self
13
- def renderer_name
14
- :mutool
15
- end
16
-
17
- def binary_name
18
- :mutool
19
- end
20
-
21
- def build_command(pdf_path, page_num, out_path)
22
- ["mutool", "draw", "-F", "svg", "-o", out_path.to_s,
23
- pdf_path.to_s, page_num.to_s]
24
- end
25
- end
26
- end
27
- end
28
- end
@@ -1,221 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "open3"
4
- require "pathname"
5
- require "tmpdir"
6
-
7
- require "ucode/error"
8
-
9
- module Ucode
10
- module Glyphs
11
- # Strategy interface for PDF-page-to-SVG rendering.
12
- #
13
- # Subclasses implement `renderer_name`, `binary_name`, and
14
- # `build_command`. The base class handles availability check,
15
- # command execution, error handling, and the renderer registry.
16
- #
17
- # **OCP**: a new renderer is a new subclass file + one entry in
18
- # `KNOWN_RENDERERS`. The base class and existing renderers are not
19
- # modified.
20
- #
21
- # **Vector-only requirement**: every renderer here must emit SVG
22
- # `<path>` elements (vector data) for the Code Charts PDFs, not
23
- # raster images. Callers verify this via `path_count` on the output.
24
- class PageRenderer
25
- OUTPUT_FORMAT = :svg
26
-
27
- # Fixture used by `works?` to smoke-test renderers. Resolved lazily
28
- # so missing-fixture environments (installed gem without spec assets)
29
- # don't fail at load time.
30
- DEFAULT_SMOKE_FIXTURE =
31
- File.expand_path("../../../spec/fixtures/pdfs/basic_latin.pdf", __dir__)
32
-
33
- # Ordered list of known concrete renderer class names (as symbols),
34
- # most-preferred first. Resolved lazily via `const_get` so that
35
- # loading any one renderer does not eagerly load all of them — this
36
- # avoids a circular require (each renderer file requires this file
37
- # to inherit from PageRenderer).
38
- KNOWN_RENDERERS = %i[
39
- MutoolRenderer
40
- Pdf2svgRenderer
41
- DvisvgmRenderer
42
- PdftocairoRenderer
43
- ].freeze
44
- private_constant :KNOWN_RENDERERS
45
-
46
- class << self
47
- # @return [Symbol] short identifier (e.g. :mutool)
48
- def renderer_name
49
- raise NotImplementedError
50
- end
51
-
52
- # @return [String, Symbol] the binary looked up on PATH
53
- def binary_name
54
- raise NotImplementedError
55
- end
56
-
57
- # @return [Symbol] always :svg for now; future formats (png, etc.)
58
- # would warrant a separate renderer family.
59
- def output_format
60
- OUTPUT_FORMAT
61
- end
62
-
63
- # Build the argv for the renderer. Subclasses return an Array
64
- # suitable for `Open3.capture2e` (no shell interpolation).
65
- # @param pdf_path [Pathname, String]
66
- # @param page_num [Integer] 1-indexed
67
- # @param out_path [Pathname, String]
68
- # @return [Array<String>]
69
- def build_command(pdf_path, page_num, out_path)
70
- raise NotImplementedError
71
- end
72
-
73
- # @return [Boolean] true if the binary is on PATH
74
- def available?
75
- system("which", binary_name.to_s, out: "/dev/null", err: "/dev/null")
76
- end
77
-
78
- # Smoke-test the binary by actually rendering one page of the
79
- # fixture PDF AND verifying the output format is consumable by
80
- # the downstream `GridDetector` / `CellExtractor` pipeline.
81
- #
82
- # Three things can make a renderer unusable for this codebase:
83
- # 1. Binary not on PATH (`available?` catches this).
84
- # 2. Binary on PATH but silently broken (e.g. Ubuntu's
85
- # `mupdf-tools` is built without LCMS, so `mutool` warns
86
- # "ICC support is not available" and emits zero bytes for
87
- # ICC-profiled PDFs).
88
- # 3. Binary works but emits a flat-path SVG that GridDetector
89
- # can't parse (mutool's format: `<path id="font_X_Y">`
90
- # directly in `<defs>`, no `<use>` references). The grid
91
- # detector requires the `<g id="glyph-N-M">` + `<use>` form
92
- # produced by pdftocairo / pdf2svg.
93
- #
94
- # The result is memoized per-renderer for the process lifetime —
95
- # the binary's capabilities don't change mid-run.
96
- #
97
- # When no fixture PDF is available (e.g. installed gem without
98
- # spec assets), degrades to `available?` — we can't smoke-test
99
- # without input, so we trust the binary's presence on PATH.
100
- #
101
- # @param fixture_pdf [String, Pathname] small one-page PDF used
102
- # for the smoke render. Defaults to the project's
103
- # `basic_latin.pdf` spec fixture.
104
- # @return [Boolean]
105
- def works?(fixture_pdf: DEFAULT_SMOKE_FIXTURE)
106
- if !available?
107
- false
108
- elsif !File.exist?(fixture_pdf.to_s)
109
- true # no fixture to verify against; trust PATH
110
- else
111
- smoke_render_ok?(fixture_pdf)
112
- end
113
- end
114
-
115
- # Render one page of `pdf_path` to `out_path` as SVG.
116
- # @param pdf_path [Pathname, String]
117
- # @param page_num [Integer] 1-indexed
118
- # @param out_path [Pathname, String]
119
- # @return [Symbol] :ok on success
120
- # @raise [Ucode::PdfRenderError] on failure (non-zero exit,
121
- # output file missing, or binary unavailable)
122
- def render(pdf_path, page_num, out_path)
123
- unless available?
124
- raise PdfRenderError.new(
125
- "binary '#{binary_name}' not available on PATH",
126
- context: { renderer: name, binary: binary_name },
127
- )
128
- end
129
-
130
- out = Pathname.new(out_path)
131
- out.dirname.mkpath
132
-
133
- cmd = build_command(Pathname.new(pdf_path), page_num, out)
134
- output, status = Open3.capture2e(*cmd)
135
-
136
- unless status.success? && out.exist? && out.size.positive?
137
- raise PdfRenderError.new(
138
- "render failed for page #{page_num} of #{pdf_path} via '#{binary_name}'",
139
- context: {
140
- renderer: name,
141
- binary: binary_name,
142
- exit_status: status.exitstatus,
143
- output: output,
144
- },
145
- )
146
- end
147
-
148
- :ok
149
- end
150
-
151
- # ---- Registry ----
152
-
153
- # @return [Array<Class>] every known concrete renderer
154
- def all
155
- @all ||= KNOWN_RENDERERS.map { |n| Ucode::Glyphs.const_get(n) }.freeze
156
- end
157
-
158
- # @return [Array<Class>] renderers whose binary is installed
159
- def available
160
- all.select(&:available?)
161
- end
162
-
163
- # @return [Array<Class>] renderers that actually produce SVG in
164
- # the format `GridDetector` consumes (smoke-tested once per
165
- # process via `works?`, then cached). Subset of `available`.
166
- def working
167
- return @working if @working
168
-
169
- @working = all.select(&:works?).freeze
170
- end
171
-
172
- # Clear the cached `working` list. Useful when the environment
173
- # changes (e.g. a binary is installed mid-process) or in tests.
174
- def reset_working_cache!
175
- @working = nil
176
- end
177
-
178
- # @param name [Symbol, String]
179
- # @return [Class, nil]
180
- def find(name)
181
- all.find { |r| r.renderer_name == name.to_sym }
182
- end
183
-
184
- # @return [Class, nil] the first working renderer; falls back to
185
- # the first available renderer if none have been smoke-tested
186
- # yet (preserves eager-init paths). nil if nothing is installed.
187
- def default
188
- working.first || available.first
189
- end
190
-
191
- private
192
-
193
- # @param fixture_pdf [String] path to an existing PDF
194
- # @return [Boolean] true iff rendering page 1 produces an SVG
195
- # with the `<g id="glyph-N-M">` + `<use>` form that
196
- # `GridDetector` requires.
197
- def smoke_render_ok?(fixture_pdf)
198
- Dir.mktmpdir("renderer-smoke-") do |dir|
199
- out = File.join(dir, "smoke.svg")
200
- begin
201
- render(fixture_pdf, 1, out)
202
- rescue PdfRenderError
203
- break false
204
- end
205
- svg_has_pipeline_format?(out)
206
- end
207
- end
208
-
209
- def svg_has_pipeline_format?(out_path)
210
- return false unless File.exist?(out_path)
211
- return false unless File.size(out_path).positive?
212
-
213
- body = File.read(out_path)
214
- body.include?("<svg") &&
215
- body.include?("<use") &&
216
- body.match?("id=\"glyph-\\d+-\\d+\"")
217
- end
218
- end
219
- end
220
- end
221
- end
@@ -1,62 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ucode
4
- module Glyphs
5
- # Estimates the axis-aligned bounding box of an SVG `<path>` `d`
6
- # attribute by scanning every numeric coordinate pair in the path
7
- # data. This is a conservative over-estimate: control points and
8
- # implicit vertices are included, so the true curve bbox is always
9
- # contained within the estimate. For grid detection and cell
10
- # membership tests, the over-estimate is sufficient and avoids the
11
- # cost of a Bezier solver.
12
- #
13
- # Only absolute coordinates are returned. Relative commands (lowercase
14
- # `m`, `l`, `c`, …) are NOT supported — Code Charts SVGs from every
15
- # supported renderer (pdftocairo, pdf2svg, dvisvgm, mutool) emit
16
- # absolute commands. If relative commands appear, parse them via a
17
- # proper SVG path parser before calling this.
18
- module PathBbox
19
- NUMBER = /-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?/.freeze
20
-
21
- Result = Struct.new(:min_x, :min_y, :max_x, :max_y, keyword_init: true) do
22
- def width
23
- return nil if empty?
24
-
25
- max_x - min_x
26
- end
27
-
28
- def height
29
- return nil if empty?
30
-
31
- max_y - min_y
32
- end
33
-
34
- def empty?
35
- min_x.nil? || min_y.nil? || max_x.nil? || max_y.nil?
36
- end
37
- end
38
-
39
- class << self
40
- def estimate(path_d)
41
- return Result.new if path_d.nil? || path_d.empty?
42
-
43
- numbers = path_d.scan(NUMBER).map(&:to_f)
44
- return Result.new if numbers.empty?
45
-
46
- xs = []
47
- ys = []
48
- numbers.each_slice(2) do |x, y|
49
- xs << x
50
- ys << y
51
- end
52
- Result.new(
53
- min_x: xs.min,
54
- min_y: ys.min,
55
- max_x: xs.max,
56
- max_y: ys.max,
57
- )
58
- end
59
- end
60
- end
61
- end
62
- end
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "ucode/glyphs/page_renderer"
4
-
5
- module Ucode
6
- module Glyphs
7
- # `pdf2svg` — simple, widely available. One SVG per page.
8
- #
9
- # Command: `pdf2svg <in.pdf> <out.svg> <page>`
10
- class Pdf2svgRenderer < PageRenderer
11
- class << self
12
- def renderer_name
13
- :pdf2svg
14
- end
15
-
16
- def binary_name
17
- :pdf2svg
18
- end
19
-
20
- def build_command(pdf_path, page_num, out_path)
21
- ["pdf2svg", pdf_path.to_s, out_path.to_s, page_num.to_s]
22
- end
23
- end
24
- end
25
- end
26
- end