ucode 0.2.2 → 0.3.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/Rakefile +1 -1
- data/config/unicode17_universal_glyph_set.yml +1 -1
- data/lib/ucode/cli.rb +1 -35
- data/lib/ucode/code_chart/extractor.rb +1 -9
- data/lib/ucode/code_chart/writer.rb +1 -1
- data/lib/ucode/commands/build.rb +3 -26
- data/lib/ucode/commands/canonical_build.rb +5 -8
- data/lib/ucode/commands/universal_set.rb +5 -3
- data/lib/ucode/commands.rb +0 -1
- data/lib/ucode/coordinator/enrichment/bidi.rb +35 -0
- data/lib/ucode/coordinator/enrichment/binary.rb +38 -0
- data/lib/ucode/coordinator/enrichment/casing.rb +55 -0
- data/lib/ucode/coordinator/enrichment/cjk.rb +49 -0
- data/lib/ucode/coordinator/enrichment/display.rb +36 -0
- data/lib/ucode/coordinator/enrichment/emoji.rb +36 -0
- data/lib/ucode/coordinator/enrichment/identity.rb +42 -0
- data/lib/ucode/coordinator/enrichment/indic.rb +32 -0
- data/lib/ucode/coordinator/enrichment/names.rb +63 -0
- data/lib/ucode/coordinator/enrichment/segmentation.rb +34 -0
- data/lib/ucode/coordinator/enrichment.rb +51 -0
- data/lib/ucode/coordinator/range_lookup.rb +65 -0
- data/lib/ucode/coordinator.rb +4 -276
- data/lib/ucode/error.rb +0 -8
- data/lib/ucode/glyphs/embedded_fonts/catalog.rb +32 -299
- data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper.rb +130 -0
- data/lib/ucode/glyphs/embedded_fonts/content_stream_correlator.rb +25 -124
- data/lib/ucode/glyphs/embedded_fonts/font_entry.rb +0 -1
- data/lib/ucode/glyphs/embedded_fonts/pdf_indexer.rb +236 -0
- data/lib/ucode/glyphs/embedded_fonts/{source.rb → pdf_location.rb} +5 -5
- data/lib/ucode/glyphs/embedded_fonts/positional_matcher.rb +162 -0
- data/lib/ucode/glyphs/embedded_fonts/raw_font_descriptor.rb +24 -0
- data/lib/ucode/glyphs/embedded_fonts/renderer.rb +0 -2
- data/lib/ucode/glyphs/embedded_fonts/trace_correlator.rb +116 -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/writer.rb +0 -4
- data/lib/ucode/glyphs/embedded_fonts.rb +9 -1
- data/lib/ucode/glyphs/pdf_fetcher.rb +7 -50
- data/lib/ucode/glyphs/resolver_factory.rb +45 -0
- data/lib/ucode/glyphs/sources/pillar1_embedded_tounicode.rb +1 -1
- data/lib/ucode/glyphs.rb +5 -14
- data/lib/ucode/version.rb +1 -1
- data/lib/ucode.rb +0 -2
- metadata +24 -16
- 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 -234
- 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 -105
- data/lib/ucode/glyphs/writer.rb +0 -250
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "pathname"
|
|
4
|
-
|
|
5
|
-
require "ucode/glyphs"
|
|
6
|
-
|
|
7
|
-
module Ucode
|
|
8
|
-
module Commands
|
|
9
|
-
# `ucode glyphs` — extract per-codepoint SVGs from Code Charts PDFs.
|
|
10
|
-
# Thin Thor-facing wrapper around {Ucode::Glyphs::Pipeline}:
|
|
11
|
-
# opt-in gate + experimental warning live here; the pipeline
|
|
12
|
-
# assembly (block loading, fetcher, per-block specs) lives in
|
|
13
|
-
# {Ucode::Glyphs::Pipeline}.
|
|
14
|
-
#
|
|
15
|
-
# **Status (v0.1): EXPERIMENTAL.** The cell-extraction pipeline
|
|
16
|
-
# currently includes cell-border decorations alongside the actual
|
|
17
|
-
# character outline because the Code Charts PDFs composite the two
|
|
18
|
-
# into a single glyph definition. The output is therefore not yet
|
|
19
|
-
# suitable for end-user display. The command is retained so the
|
|
20
|
-
# pipeline can be iterated on without churning the CLI surface, but
|
|
21
|
-
# callers MUST opt in via `include_glyphs: true` (CLI: `--include-glyphs`)
|
|
22
|
-
# and will receive a printed warning. Tracked for v0.2.
|
|
23
|
-
#
|
|
24
|
-
# Takes a resolved version string; CLI callers resolve via
|
|
25
|
-
# {VersionResolver.resolve} once and thread it through. See
|
|
26
|
-
# Candidate 4 of the 2026-06-29 architecture review.
|
|
27
|
-
class GlyphsCommand
|
|
28
|
-
ExperimentalWarning = "ucode glyphs is experimental in v0.1: " \
|
|
29
|
-
"extracted SVGs include cell-border decorations " \
|
|
30
|
-
"alongside the character outline."
|
|
31
|
-
private_constant :ExperimentalWarning
|
|
32
|
-
|
|
33
|
-
class << self
|
|
34
|
-
# @return [String] the experimental-status banner. Exposed so the
|
|
35
|
-
# CLI and BuildCommand surface the same message verbatim.
|
|
36
|
-
def experimental_warning
|
|
37
|
-
ExperimentalWarning
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# @param version [String] resolved UCD version
|
|
42
|
-
# @param output_root [String, Pathname]
|
|
43
|
-
# @param block_filter [Array<String>, nil] block ids to limit to;
|
|
44
|
-
# nil = every block
|
|
45
|
-
# @param force [Boolean] re-fetch PDFs even when cached
|
|
46
|
-
# @param monolith_path [String, Pathname, nil] path to CodeCharts.pdf
|
|
47
|
-
# for fallback slicing; defaults to ./CodeCharts.pdf
|
|
48
|
-
# @param include_glyphs [Boolean] opt-in for the experimental v0.1
|
|
49
|
-
# pipeline. When false (default), the command returns a `skipped`
|
|
50
|
-
# payload without touching disk.
|
|
51
|
-
# @param warn [IO, nil] when provided, the experimental warning is
|
|
52
|
-
# written here exactly once before work begins.
|
|
53
|
-
# @return [Hash] aggregated Writer tally + version, or a `skipped`
|
|
54
|
-
# payload when opt-in is false.
|
|
55
|
-
def call(version, output_root:,
|
|
56
|
-
block_filter: nil, force: false,
|
|
57
|
-
monolith_path: Glyphs::Pipeline::DEFAULT_MONOLITH_PATH,
|
|
58
|
-
include_glyphs: false, warn: nil)
|
|
59
|
-
return skipped(version) unless include_glyphs
|
|
60
|
-
|
|
61
|
-
warn&.puts(ExperimentalWarning)
|
|
62
|
-
|
|
63
|
-
pipeline = Glyphs::Pipeline.new(
|
|
64
|
-
version: version,
|
|
65
|
-
block_filter: block_filter,
|
|
66
|
-
monolith_path: monolith_path,
|
|
67
|
-
)
|
|
68
|
-
specs = pipeline.build_specs(force: force)
|
|
69
|
-
|
|
70
|
-
writer = Glyphs::Writer.new(
|
|
71
|
-
output_root: Pathname.new(output_root),
|
|
72
|
-
parallel_workers: workers,
|
|
73
|
-
)
|
|
74
|
-
tally = writer.write_all(specs)
|
|
75
|
-
tally.merge(version: version, block_count: specs.size)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
private
|
|
79
|
-
|
|
80
|
-
def workers
|
|
81
|
-
Ucode.configuration.parallel_workers
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def skipped(version)
|
|
85
|
-
{
|
|
86
|
-
version: version,
|
|
87
|
-
skipped: true,
|
|
88
|
-
reason: :experimental_v0_1,
|
|
89
|
-
warning: ExperimentalWarning,
|
|
90
|
-
}
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
end
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "nokogiri"
|
|
4
|
-
|
|
5
|
-
require "ucode/glyphs/path_bbox"
|
|
6
|
-
|
|
7
|
-
module Ucode
|
|
8
|
-
module Glyphs
|
|
9
|
-
# Extracts a single character cell from a Code Charts SVG page and
|
|
10
|
-
# returns a normalized standalone SVG containing only that cell's
|
|
11
|
-
# vector paths.
|
|
12
|
-
#
|
|
13
|
-
# The cell is identified by codepoint. The extractor asks the Grid
|
|
14
|
-
# for the cell's anchor position, finds the `<use>` element placed
|
|
15
|
-
# at that position, resolves its glyph definition from `<defs>`,
|
|
16
|
-
# and emits a fresh `<svg>` whose viewBox is `0 0 1000 1000` and
|
|
17
|
-
# whose body is the glyph's `<path>` data translated and scaled to
|
|
18
|
-
# fit that viewBox with a small margin.
|
|
19
|
-
#
|
|
20
|
-
# Vector-only. Never rasterizes, never OCRs. If the cell is empty
|
|
21
|
-
# (no character glyph placed there, e.g. unassigned codepoint or
|
|
22
|
-
# control character), the extractor returns nil.
|
|
23
|
-
class CellExtractor
|
|
24
|
-
ViewBoxSize = 1000.0
|
|
25
|
-
MarginRatio = 0.1
|
|
26
|
-
private_constant :ViewBoxSize, :MarginRatio
|
|
27
|
-
|
|
28
|
-
# @param doc [Nokogiri::XML::Document] the rendered Code Charts page
|
|
29
|
-
def initialize(doc)
|
|
30
|
-
@doc = doc
|
|
31
|
-
@glyph_cache = {}
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# @param grid [Ucode::Glyphs::Grid]
|
|
35
|
-
# @param codepoint [Integer]
|
|
36
|
-
# @return [Nokogiri::XML::Document, nil] a standalone `<svg>` doc
|
|
37
|
-
# with viewBox `0 0 1000 1000`, or nil if the cell is empty.
|
|
38
|
-
def extract(grid, codepoint)
|
|
39
|
-
anchor = grid.cell_position(codepoint)
|
|
40
|
-
return nil unless anchor
|
|
41
|
-
|
|
42
|
-
use_node = find_use_at(anchor, grid)
|
|
43
|
-
return nil unless use_node
|
|
44
|
-
|
|
45
|
-
path_data = collect_paths(use_node["xlink:href"] || use_node["href"])
|
|
46
|
-
return nil if path_data.empty?
|
|
47
|
-
|
|
48
|
-
bbox = PathBbox.estimate(path_data.join(" "))
|
|
49
|
-
return nil if bbox.empty?
|
|
50
|
-
|
|
51
|
-
build_svg(path_data, bbox, use_node["x"].to_f, use_node["y"].to_f)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
private
|
|
55
|
-
|
|
56
|
-
def find_use_at(anchor, grid)
|
|
57
|
-
tolerance_x = grid.column_pitch / 2
|
|
58
|
-
tolerance_y = grid.row_pitch / 2
|
|
59
|
-
|
|
60
|
-
candidates = @doc.css("use").select do |node|
|
|
61
|
-
href = node["xlink:href"] || node["href"] || ""
|
|
62
|
-
href.start_with?("#glyph-") &&
|
|
63
|
-
(node["x"].to_f - anchor[0]).abs <= tolerance_x &&
|
|
64
|
-
(node["y"].to_f - anchor[1]).abs <= tolerance_y
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
candidates.min_by do |node|
|
|
68
|
-
dx = node["x"].to_f - anchor[0]
|
|
69
|
-
dy = node["y"].to_f - anchor[1]
|
|
70
|
-
(dx * dx) + (dy * dy)
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def collect_paths(href)
|
|
75
|
-
return [] unless href
|
|
76
|
-
|
|
77
|
-
glyph_id = href.sub(/\A#/, "")
|
|
78
|
-
node = glyph_definition(glyph_id)
|
|
79
|
-
return [] unless node
|
|
80
|
-
|
|
81
|
-
node.css("path").map { |p| p["d"] }.compact
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def glyph_definition(glyph_id)
|
|
85
|
-
return @glyph_cache[glyph_id] if @glyph_cache.key?(glyph_id)
|
|
86
|
-
|
|
87
|
-
@glyph_cache[glyph_id] = @doc.at_css("defs ##{glyph_id}")
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def build_svg(path_data, glyph_bbox, place_x, place_y)
|
|
91
|
-
placed = PathBbox::Result.new(
|
|
92
|
-
min_x: place_x + glyph_bbox.min_x,
|
|
93
|
-
min_y: place_y + glyph_bbox.min_y,
|
|
94
|
-
max_x: place_x + glyph_bbox.max_x,
|
|
95
|
-
max_y: place_y + glyph_bbox.max_y,
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
width = placed.width
|
|
99
|
-
height = placed.height
|
|
100
|
-
return nil if width <= 0 || height <= 0
|
|
101
|
-
|
|
102
|
-
content_size = ViewBoxSize * (1.0 - (2.0 * MarginRatio))
|
|
103
|
-
scale = [content_size / width, content_size / height].min
|
|
104
|
-
offset_x = (ViewBoxSize - (width * scale)) / 2.0
|
|
105
|
-
offset_y = (ViewBoxSize - (height * scale)) / 2.0
|
|
106
|
-
translate_x = offset_x - (placed.min_x * scale)
|
|
107
|
-
translate_y = offset_y - (placed.min_y * scale)
|
|
108
|
-
|
|
109
|
-
builder = Nokogiri::XML::Document.new
|
|
110
|
-
root = builder.create_element(
|
|
111
|
-
"svg",
|
|
112
|
-
xmlns: "http://www.w3.org/2000/svg",
|
|
113
|
-
viewBox: "0 0 #{ViewBoxSize.to_i} #{ViewBoxSize.to_i}",
|
|
114
|
-
width: ViewBoxSize.to_i,
|
|
115
|
-
height: ViewBoxSize.to_i,
|
|
116
|
-
)
|
|
117
|
-
group = builder.create_element(
|
|
118
|
-
"g",
|
|
119
|
-
transform: "scale(#{format('%.6f', scale)}) translate(#{format('%.6f', translate_x)}, #{format('%.6f', translate_y)})",
|
|
120
|
-
)
|
|
121
|
-
path_data.each do |d|
|
|
122
|
-
group.add_child(builder.create_element("path", d: d, fill: "black"))
|
|
123
|
-
end
|
|
124
|
-
root.add_child(group)
|
|
125
|
-
builder.add_child(root)
|
|
126
|
-
builder
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
end
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "ucode/glyphs/page_renderer"
|
|
4
|
-
|
|
5
|
-
module Ucode
|
|
6
|
-
module Glyphs
|
|
7
|
-
# `dvisvgm` — originally a DVI-to-SVG converter, also handles PDF.
|
|
8
|
-
# The `--no-fonts` flag forces outline-only output (no font subsetting
|
|
9
|
-
# artifacts), which is what we want for vector glyph extraction.
|
|
10
|
-
#
|
|
11
|
-
# Command: `dvisvgm --pdf --no-fonts --page=<n> <in.pdf> -o <out.svg>`
|
|
12
|
-
class DvisvgmRenderer < PageRenderer
|
|
13
|
-
class << self
|
|
14
|
-
def renderer_name
|
|
15
|
-
:dvisvgm
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def binary_name
|
|
19
|
-
:dvisvgm
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def build_command(pdf_path, page_num, out_path)
|
|
23
|
-
["dvisvgm", "--pdf", "--no-fonts", "--page=#{page_num}",
|
|
24
|
-
pdf_path.to_s, "-o", out_path.to_s]
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
data/lib/ucode/glyphs/grid.rb
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Ucode
|
|
4
|
-
module Glyphs
|
|
5
|
-
Grid = Struct.new(
|
|
6
|
-
:origin_x, :origin_y,
|
|
7
|
-
:column_pitch, :row_pitch,
|
|
8
|
-
:columns, :rows,
|
|
9
|
-
:block_first_cp,
|
|
10
|
-
keyword_init: true,
|
|
11
|
-
) do
|
|
12
|
-
def cell_position(codepoint)
|
|
13
|
-
offset = codepoint - block_first_cp
|
|
14
|
-
return nil if offset.negative?
|
|
15
|
-
|
|
16
|
-
row, col = offset.divmod(columns)
|
|
17
|
-
return nil if row >= rows
|
|
18
|
-
|
|
19
|
-
[origin_x + (col * column_pitch), origin_y + (row * row_pitch)]
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def codepoint_at(row, col)
|
|
23
|
-
return nil if row.negative? || row >= rows
|
|
24
|
-
return nil if col.negative? || col >= columns
|
|
25
|
-
|
|
26
|
-
block_first_cp + (row * columns) + col
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
@@ -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
|