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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +1 -1
  3. data/config/unicode17_universal_glyph_set.yml +1 -1
  4. data/lib/ucode/cli.rb +1 -35
  5. data/lib/ucode/code_chart/extractor.rb +1 -9
  6. data/lib/ucode/code_chart/writer.rb +1 -1
  7. data/lib/ucode/commands/build.rb +3 -26
  8. data/lib/ucode/commands/canonical_build.rb +5 -8
  9. data/lib/ucode/commands/universal_set.rb +5 -3
  10. data/lib/ucode/commands.rb +0 -1
  11. data/lib/ucode/coordinator/enrichment/bidi.rb +35 -0
  12. data/lib/ucode/coordinator/enrichment/binary.rb +38 -0
  13. data/lib/ucode/coordinator/enrichment/casing.rb +55 -0
  14. data/lib/ucode/coordinator/enrichment/cjk.rb +49 -0
  15. data/lib/ucode/coordinator/enrichment/display.rb +36 -0
  16. data/lib/ucode/coordinator/enrichment/emoji.rb +36 -0
  17. data/lib/ucode/coordinator/enrichment/identity.rb +42 -0
  18. data/lib/ucode/coordinator/enrichment/indic.rb +32 -0
  19. data/lib/ucode/coordinator/enrichment/names.rb +63 -0
  20. data/lib/ucode/coordinator/enrichment/segmentation.rb +34 -0
  21. data/lib/ucode/coordinator/enrichment.rb +51 -0
  22. data/lib/ucode/coordinator/range_lookup.rb +65 -0
  23. data/lib/ucode/coordinator.rb +4 -276
  24. data/lib/ucode/error.rb +0 -8
  25. data/lib/ucode/glyphs/embedded_fonts/catalog.rb +32 -299
  26. data/lib/ucode/glyphs/embedded_fonts/codepoint_mapper.rb +130 -0
  27. data/lib/ucode/glyphs/embedded_fonts/content_stream_correlator.rb +25 -124
  28. data/lib/ucode/glyphs/embedded_fonts/font_entry.rb +0 -1
  29. data/lib/ucode/glyphs/embedded_fonts/pdf_indexer.rb +236 -0
  30. data/lib/ucode/glyphs/embedded_fonts/{source.rb → pdf_location.rb} +5 -5
  31. data/lib/ucode/glyphs/embedded_fonts/positional_matcher.rb +162 -0
  32. data/lib/ucode/glyphs/embedded_fonts/raw_font_descriptor.rb +24 -0
  33. data/lib/ucode/glyphs/embedded_fonts/renderer.rb +0 -2
  34. data/lib/ucode/glyphs/embedded_fonts/trace_correlator.rb +116 -0
  35. data/lib/ucode/glyphs/embedded_fonts/trace_glyph.rb +27 -0
  36. data/lib/ucode/glyphs/embedded_fonts/trace_parser.rb +50 -0
  37. data/lib/ucode/glyphs/embedded_fonts/trace_runner.rb +53 -0
  38. data/lib/ucode/glyphs/embedded_fonts/writer.rb +0 -4
  39. data/lib/ucode/glyphs/embedded_fonts.rb +9 -1
  40. data/lib/ucode/glyphs/pdf_fetcher.rb +7 -50
  41. data/lib/ucode/glyphs/resolver_factory.rb +45 -0
  42. data/lib/ucode/glyphs/sources/pillar1_embedded_tounicode.rb +1 -1
  43. data/lib/ucode/glyphs.rb +5 -14
  44. data/lib/ucode/version.rb +1 -1
  45. data/lib/ucode.rb +0 -2
  46. metadata +24 -16
  47. data/lib/ucode/commands/glyphs.rb +0 -94
  48. data/lib/ucode/glyphs/cell_extractor.rb +0 -130
  49. data/lib/ucode/glyphs/dvisvgm_renderer.rb +0 -29
  50. data/lib/ucode/glyphs/grid.rb +0 -30
  51. data/lib/ucode/glyphs/grid_detector.rb +0 -165
  52. data/lib/ucode/glyphs/monolith_page_map.rb +0 -181
  53. data/lib/ucode/glyphs/mutool_renderer.rb +0 -28
  54. data/lib/ucode/glyphs/page_renderer.rb +0 -234
  55. data/lib/ucode/glyphs/path_bbox.rb +0 -62
  56. data/lib/ucode/glyphs/pdf2svg_renderer.rb +0 -26
  57. data/lib/ucode/glyphs/pdftocairo_renderer.rb +0 -32
  58. data/lib/ucode/glyphs/pipeline.rb +0 -105
  59. 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
@@ -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