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.
- checksums.yaml +4 -4
- data/Gemfile +10 -2
- data/README.md +66 -20
- data/Rakefile +18 -7
- data/config/unicode17_universal_glyph_set.yml +1 -1
- data/lib/ucode/audit/reference_factory.rb +1 -1
- data/lib/ucode/cli.rb +10 -42
- data/lib/ucode/code_chart/extractor.rb +3 -5
- data/lib/ucode/code_chart/provenance.rb +6 -5
- data/lib/ucode/code_chart/sidecar.rb +2 -2
- data/lib/ucode/code_chart/writer.rb +1 -1
- data/lib/ucode/code_chart.rb +1 -1
- data/lib/ucode/commands/build.rb +3 -26
- data/lib/ucode/commands/canonical_build.rb +1 -4
- data/lib/ucode/commands/fetch.rb +1 -1
- data/lib/ucode/commands/lookup.rb +1 -1
- data/lib/ucode/commands/parse.rb +1 -1
- data/lib/ucode/commands.rb +0 -1
- data/lib/ucode/coordinator/indices.rb +2 -2
- data/lib/ucode/error.rb +0 -8
- data/lib/ucode/fetch/code_charts.rb +2 -3
- data/lib/ucode/fetch/http.rb +12 -14
- data/lib/ucode/glyphs/embedded_fonts/catalog.rb +81 -4
- data/lib/ucode/glyphs/embedded_fonts/trace_correlator.rb +230 -0
- data/lib/ucode/glyphs/embedded_fonts/trace_glyph.rb +27 -0
- data/lib/ucode/glyphs/embedded_fonts/trace_parser.rb +50 -0
- data/lib/ucode/glyphs/embedded_fonts/trace_runner.rb +53 -0
- data/lib/ucode/glyphs/embedded_fonts.rb +4 -0
- data/lib/ucode/glyphs/pdf_fetcher.rb +7 -50
- data/lib/ucode/glyphs.rb +4 -14
- data/lib/ucode/repo/aggregate_writer.rb +1 -1
- data/lib/ucode/repo/writers/blocks_writer.rb +13 -13
- data/lib/ucode/repo/writers/enums_writer.rb +2 -2
- data/lib/ucode/repo/writers/indexes_writer.rb +4 -4
- data/lib/ucode/repo/writers/manifest_writer.rb +4 -4
- data/lib/ucode/repo/writers/named_sequences_writer.rb +1 -1
- data/lib/ucode/repo/writers/planes_writer.rb +17 -17
- data/lib/ucode/repo/writers/relationships_writer.rb +1 -1
- data/lib/ucode/repo/writers/scripts_writer.rb +6 -6
- data/lib/ucode/repo/writers.rb +1 -1
- data/lib/ucode/version.rb +1 -1
- data/lib/ucode.rb +0 -2
- data/ucode.gemspec +6 -1
- metadata +10 -19
- data/lib/ucode/commands/glyphs.rb +0 -94
- data/lib/ucode/glyphs/cell_extractor.rb +0 -130
- data/lib/ucode/glyphs/dvisvgm_renderer.rb +0 -29
- data/lib/ucode/glyphs/grid.rb +0 -30
- data/lib/ucode/glyphs/grid_detector.rb +0 -165
- data/lib/ucode/glyphs/monolith_page_map.rb +0 -181
- data/lib/ucode/glyphs/mutool_renderer.rb +0 -28
- data/lib/ucode/glyphs/page_renderer.rb +0 -221
- data/lib/ucode/glyphs/path_bbox.rb +0 -62
- data/lib/ucode/glyphs/pdf2svg_renderer.rb +0 -26
- data/lib/ucode/glyphs/pdftocairo_renderer.rb +0 -32
- data/lib/ucode/glyphs/pipeline.rb +0 -106
- 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
|