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,234 +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. Returns
74
- # false on hosts without `which`/`where` or where the
75
- # binary isn't installed — the next renderer in
76
- # KNOWN_RENDERERS is tried.
77
- def available?
78
- if Gem.win_platform?
79
- # `where` returns the first match path; exit status 0
80
- # means the binary is found. Suppress stdout/stderr to
81
- # avoid polluting test output.
82
- system("where #{binary_name} >NUL 2>NUL")
83
- else
84
- system("which", binary_name.to_s,
85
- out: "/dev/null", err: "/dev/null")
86
- end
87
- rescue Errno::ENOENT, Errno::EINVAL
88
- false
89
- end
90
-
91
- # Smoke-test the binary by actually rendering one page of the
92
- # fixture PDF AND verifying the output format is consumable by
93
- # the downstream `GridDetector` / `CellExtractor` pipeline.
94
- #
95
- # Three things can make a renderer unusable for this codebase:
96
- # 1. Binary not on PATH (`available?` catches this).
97
- # 2. Binary on PATH but silently broken (e.g. Ubuntu's
98
- # `mupdf-tools` is built without LCMS, so `mutool` warns
99
- # "ICC support is not available" and emits zero bytes for
100
- # ICC-profiled PDFs).
101
- # 3. Binary works but emits a flat-path SVG that GridDetector
102
- # can't parse (mutool's format: `<path id="font_X_Y">`
103
- # directly in `<defs>`, no `<use>` references). The grid
104
- # detector requires the `<g id="glyph-N-M">` + `<use>` form
105
- # produced by pdftocairo / pdf2svg.
106
- #
107
- # The result is memoized per-renderer for the process lifetime —
108
- # the binary's capabilities don't change mid-run.
109
- #
110
- # When no fixture PDF is available (e.g. installed gem without
111
- # spec assets), degrades to `available?` — we can't smoke-test
112
- # without input, so we trust the binary's presence on PATH.
113
- #
114
- # @param fixture_pdf [String, Pathname] small one-page PDF used
115
- # for the smoke render. Defaults to the project's
116
- # `basic_latin.pdf` spec fixture.
117
- # @return [Boolean]
118
- def works?(fixture_pdf: DEFAULT_SMOKE_FIXTURE)
119
- if !available?
120
- false
121
- elsif !File.exist?(fixture_pdf.to_s)
122
- true # no fixture to verify against; trust PATH
123
- else
124
- smoke_render_ok?(fixture_pdf)
125
- end
126
- end
127
-
128
- # Render one page of `pdf_path` to `out_path` as SVG.
129
- # @param pdf_path [Pathname, String]
130
- # @param page_num [Integer] 1-indexed
131
- # @param out_path [Pathname, String]
132
- # @return [Symbol] :ok on success
133
- # @raise [Ucode::PdfRenderError] on failure (non-zero exit,
134
- # output file missing, or binary unavailable)
135
- def render(pdf_path, page_num, out_path)
136
- unless available?
137
- raise PdfRenderError.new(
138
- "binary '#{binary_name}' not available on PATH",
139
- context: { renderer: name, binary: binary_name },
140
- )
141
- end
142
-
143
- out = Pathname.new(out_path)
144
- out.dirname.mkpath
145
-
146
- cmd = build_command(Pathname.new(pdf_path), page_num, out)
147
- output, status = Open3.capture2e(*cmd)
148
-
149
- unless status.success? && out.exist? && out.size.positive?
150
- raise PdfRenderError.new(
151
- "render failed for page #{page_num} of #{pdf_path} via '#{binary_name}'",
152
- context: {
153
- renderer: name,
154
- binary: binary_name,
155
- exit_status: status.exitstatus,
156
- output: output,
157
- },
158
- )
159
- end
160
-
161
- :ok
162
- end
163
-
164
- # ---- Registry ----
165
-
166
- # @return [Array<Class>] every known concrete renderer
167
- def all
168
- @all ||= KNOWN_RENDERERS.map { |n| Ucode::Glyphs.const_get(n) }.freeze
169
- end
170
-
171
- # @return [Array<Class>] renderers whose binary is installed
172
- def available
173
- all.select(&:available?)
174
- end
175
-
176
- # @return [Array<Class>] renderers that actually produce SVG in
177
- # the format `GridDetector` consumes (smoke-tested once per
178
- # process via `works?`, then cached). Subset of `available`.
179
- def working
180
- return @working if @working
181
-
182
- @working = all.select(&:works?).freeze
183
- end
184
-
185
- # Clear the cached `working` list. Useful when the environment
186
- # changes (e.g. a binary is installed mid-process) or in tests.
187
- def reset_working_cache!
188
- @working = nil
189
- end
190
-
191
- # @param name [Symbol, String]
192
- # @return [Class, nil]
193
- def find(name)
194
- all.find { |r| r.renderer_name == name.to_sym }
195
- end
196
-
197
- # @return [Class, nil] the first working renderer; falls back to
198
- # the first available renderer if none have been smoke-tested
199
- # yet (preserves eager-init paths). nil if nothing is installed.
200
- def default
201
- working.first || available.first
202
- end
203
-
204
- private
205
-
206
- # @param fixture_pdf [String] path to an existing PDF
207
- # @return [Boolean] true iff rendering page 1 produces an SVG
208
- # with the `<g id="glyph-N-M">` + `<use>` form that
209
- # `GridDetector` requires.
210
- def smoke_render_ok?(fixture_pdf)
211
- Dir.mktmpdir("renderer-smoke-") do |dir|
212
- out = File.join(dir, "smoke.svg")
213
- begin
214
- render(fixture_pdf, 1, out)
215
- rescue PdfRenderError
216
- break false
217
- end
218
- svg_has_pipeline_format?(out)
219
- end
220
- end
221
-
222
- def svg_has_pipeline_format?(out_path)
223
- return false unless File.exist?(out_path)
224
- return false unless File.size(out_path).positive?
225
-
226
- body = File.read(out_path)
227
- body.include?("<svg") &&
228
- body.include?("<use") &&
229
- body.match?("id=\"glyph-\\d+-\\d+\"")
230
- end
231
- end
232
- end
233
- end
234
- 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
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "ucode/glyphs/page_renderer"
4
-
5
- module Ucode
6
- module Glyphs
7
- # `pdftocairo -svg` from the Poppler suite. Available on macOS via
8
- # `brew install poppler`. Slower than `mutool` but widely available.
9
- #
10
- # Command: `pdftocairo -svg -f <n> -l <n> <in.pdf> <out.svg>`
11
- #
12
- # The `-f`/`-l` pair restricts rendering to one page (first/last).
13
- class PdftocairoRenderer < PageRenderer
14
- class << self
15
- def renderer_name
16
- :pdftocairo
17
- end
18
-
19
- def binary_name
20
- :pdftocairo
21
- end
22
-
23
- def build_command(pdf_path, page_num, out_path)
24
- ["pdftocairo", "-svg",
25
- "-f", page_num.to_s,
26
- "-l", page_num.to_s,
27
- pdf_path.to_s, out_path.to_s]
28
- end
29
- end
30
- end
31
- end
32
- end
@@ -1,105 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "pathname"
4
-
5
- require "ucode/cache"
6
- require "ucode/glyphs/pdf_fetcher"
7
- require "ucode/glyphs/writer"
8
- require "ucode/parsers"
9
-
10
- module Ucode
11
- module Glyphs
12
- # Assembles the per-block specs that {Glyphs::Writer#write_all} drains.
13
- #
14
- # Owns three pieces of orchestration that {Commands::GlyphsCommand}
15
- # used to carry inline:
16
- #
17
- # - block loading from {Cache.ucd_dir}/Blocks.txt (with an optional
18
- # block filter)
19
- # - PDF fetcher construction (with monolith fallback)
20
- # - the per-block page-map heuristic (per-block PDFs are page 1 =
21
- # title, page 2 = first chart page starting at the block's first
22
- # codepoint; true for most BMP blocks; multi-page blocks need a
23
- # richer resolver — mismatches yield placeholder SVGs only, never
24
- # wrong glyphs)
25
- #
26
- # The Command stays a thin wrapper that prints the experimental
27
- # warning and wires the writer. See Candidate 3 of the 2026-06-29
28
- # architecture review.
29
- class Pipeline
30
- # Path to the monolith fallback file when no per-block PDF is on
31
- # disk yet. Overridable for tests.
32
- DEFAULT_MONOLITH_PATH = "CodeCharts.pdf"
33
- # Cache path for the page-map corpus. Overridable for tests.
34
- DEFAULT_PAGE_MAP_CACHE = "data/codecharts_page_map.json"
35
-
36
- Spec = Struct.new(:block, :pdf_path, :page_map, keyword_init: true)
37
-
38
- # @param version [String] resolved UCD version (callers must
39
- # resolve via {VersionResolver.resolve} first)
40
- # @param block_filter [Array<String>, nil] block ids to limit to;
41
- # nil = every block
42
- # @param monolith_path [String, Pathname, nil] fallback monolith
43
- # @param page_map_cache [String, Pathname] cache for the page map
44
- def initialize(version:, block_filter: nil,
45
- monolith_path: DEFAULT_MONOLITH_PATH,
46
- page_map_cache: DEFAULT_PAGE_MAP_CACHE)
47
- @version = version
48
- @block_filter = block_filter
49
- @monolith_path = monolith_path
50
- @page_map_cache = page_map_cache
51
- end
52
-
53
- # Load every block from the cached Blocks.txt (filtered by
54
- # `@block_filter` when set) and pair each one with a fetched PDF
55
- # path and a page map. Blocks whose PDF cannot be fetched are
56
- # silently dropped — the placeholder pass downstream covers them.
57
- #
58
- # @param force [Boolean] re-fetch PDFs even when cached
59
- # @return [Array<Spec>]
60
- def build_specs(force: false)
61
- blocks = load_blocks
62
- fetcher = build_fetcher(blocks)
63
- blocks.map { |block| spec_for(block, fetcher, force) }.compact
64
- end
65
-
66
- private
67
-
68
- def load_blocks
69
- path = Cache.ucd_dir(@version).join("Blocks.txt")
70
- return [] unless path.exist?
71
-
72
- all = Parsers::Blocks.each_record(path).to_a
73
- return all unless @block_filter && !@block_filter.empty?
74
-
75
- filter_set = @block_filter.to_set
76
- all.select { |block| filter_set.include?(block.id) }
77
- end
78
-
79
- def build_fetcher(blocks)
80
- monolith = @monolith_path ? Pathname.new(@monolith_path) : nil
81
- monolith = monolith.exist? ? monolith : nil
82
- PdfFetcher.new(
83
- @version,
84
- monolith_path: monolith,
85
- blocks: blocks,
86
- page_map_cache: @page_map_cache,
87
- )
88
- end
89
-
90
- def spec_for(block, fetcher, force)
91
- pdf_path = fetcher.fetch(block_first_cp: block.range_first, force: force)
92
- return nil unless pdf_path
93
-
94
- Spec.new(block: block, pdf_path: pdf_path, page_map: page_map_for(block))
95
- end
96
-
97
- # Per-block PDFs are page 1 = title, page 2 = first chart page
98
- # starting at the block's first codepoint. True for most BMP
99
- # blocks; multi-page blocks (CJK) need a richer resolver.
100
- def page_map_for(block)
101
- { 2 => block.range_first }
102
- end
103
- end
104
- end
105
- end