ucode 0.1.1 → 0.2.1

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/TODO.extract-code-chart/01-pdf-fetch-validation.md +80 -0
  3. data/TODO.extract-code-chart/02-block-name-resolver.md +68 -0
  4. data/TODO.extract-code-chart/03-codechart-namespace.md +82 -0
  5. data/TODO.extract-code-chart/04-codechart-extractor.md +154 -0
  6. data/TODO.extract-code-chart/05-provenance-and-sidecar.md +147 -0
  7. data/TODO.extract-code-chart/06-codechart-writer.md +134 -0
  8. data/TODO.extract-code-chart/07-codechart-cli.md +135 -0
  9. data/TODO.extract-code-chart/08-specs.md +87 -0
  10. data/lib/ucode/audit/reference_factory.rb +66 -0
  11. data/lib/ucode/audit.rb +1 -0
  12. data/lib/ucode/cli.rb +134 -16
  13. data/lib/ucode/code_chart/extractor.rb +122 -0
  14. data/lib/ucode/code_chart/provenance.rb +81 -0
  15. data/lib/ucode/code_chart/sidecar.rb +52 -0
  16. data/lib/ucode/code_chart/writer.rb +128 -0
  17. data/lib/ucode/code_chart.rb +39 -0
  18. data/lib/ucode/commands/audit.rb +0 -1
  19. data/lib/ucode/commands/build.rb +4 -0
  20. data/lib/ucode/commands/canonical_build.rb +2 -3
  21. data/lib/ucode/commands/fetch.rb +12 -14
  22. data/lib/ucode/commands/glyphs.rb +25 -67
  23. data/lib/ucode/commands/lookup.rb +11 -11
  24. data/lib/ucode/commands/parse.rb +7 -5
  25. data/lib/ucode/commands/release.rb +0 -1
  26. data/lib/ucode/commands/universal_set.rb +10 -14
  27. data/lib/ucode/coordinator/indices.rb +38 -2
  28. data/lib/ucode/error.rb +11 -0
  29. data/lib/ucode/fetch/code_charts.rb +1 -1
  30. data/lib/ucode/fetch/http.rb +84 -14
  31. data/lib/ucode/glyphs/pipeline.rb +106 -0
  32. data/lib/ucode/glyphs.rb +1 -0
  33. data/lib/ucode/parsers/blocks.rb +34 -0
  34. data/lib/ucode/repo/aggregate_writer.rb +60 -298
  35. data/lib/ucode/repo/writers/blocks_writer.rb +73 -0
  36. data/lib/ucode/repo/writers/enums_writer.rb +38 -0
  37. data/lib/ucode/repo/writers/indexes_writer.rb +53 -0
  38. data/lib/ucode/repo/writers/manifest_writer.rb +78 -0
  39. data/lib/ucode/repo/writers/named_sequences_writer.rb +47 -0
  40. data/lib/ucode/repo/writers/planes_writer.rb +82 -0
  41. data/lib/ucode/repo/writers/relationships_writer.rb +71 -0
  42. data/lib/ucode/repo/writers/scripts_writer.rb +54 -0
  43. data/lib/ucode/repo/writers.rb +20 -0
  44. data/lib/ucode/repo.rb +1 -0
  45. data/lib/ucode/version.rb +1 -1
  46. data/lib/ucode.rb +3 -0
  47. data/ucode.gemspec +56 -0
  48. metadata +31 -5
  49. data/Gemfile.lock +0 -406
  50. data/lib/ucode/commands/audit/reference_builder.rb +0 -64
@@ -5,42 +5,42 @@ require "pathname"
5
5
  require "ucode/cache"
6
6
  require "ucode/database"
7
7
  require "ucode/repo"
8
- require "ucode/version_resolver"
9
8
 
10
9
  module Ucode
11
10
  module Commands
12
11
  # `ucode lookup` — read-only lookups against the SQLite cache and
13
12
  # the output JSON tree. Three subactions: block, script, char.
13
+ #
14
+ # Takes a resolved version string; CLI callers resolve via
15
+ # {VersionResolver.resolve} once and thread it through. See
16
+ # Candidate 4 of the 2026-06-29 architecture review.
14
17
  class LookupCommand
15
18
  BlockResult = Struct.new(:codepoint, :block, keyword_init: true)
16
19
  ScriptResult = Struct.new(:codepoint, :script, keyword_init: true)
17
20
  CharResult = Struct.new(:codepoint, :block_id, :glyph_path, keyword_init: true)
18
21
  private_constant :BlockResult, :ScriptResult, :CharResult
19
22
 
20
- # @param version_intent [nil, :default, :latest, String]
23
+ # @param version [String] resolved UCD version
21
24
  # @param codepoint [Integer]
22
25
  # @return [BlockResult]
23
- def lookup_block(version_intent, codepoint:)
24
- version = VersionResolver.resolve(version_intent)
26
+ def lookup_block(version, codepoint:)
25
27
  with_db(version) { |db| db.lookup_block(codepoint) }
26
28
  .then { |block| BlockResult.new(codepoint: codepoint, block: block) }
27
29
  end
28
30
 
29
- # @param version_intent [nil, :default, :latest, String]
31
+ # @param version [String] resolved UCD version
30
32
  # @param codepoint [Integer]
31
33
  # @return [ScriptResult]
32
- def lookup_script(version_intent, codepoint:)
33
- version = VersionResolver.resolve(version_intent)
34
+ def lookup_script(version, codepoint:)
34
35
  with_db(version) { |db| db.lookup_script(codepoint) }
35
36
  .then { |script| ScriptResult.new(codepoint: codepoint, script: script) }
36
37
  end
37
38
 
38
- # @param version_intent [nil, :default, :latest, String]
39
+ # @param version [String] resolved UCD version
39
40
  # @param codepoint [Integer]
40
41
  # @param output_root [String, Pathname]
41
42
  # @return [CharResult]
42
- def lookup_char(version_intent, codepoint:, output_root:)
43
- version = VersionResolver.resolve(version_intent)
43
+ def lookup_char(version, codepoint:, output_root:)
44
44
  block_id = with_db(version) { |db| db.lookup_block(codepoint) }
45
45
  glyph = block_id ? glyph_path(output_root, block_id, codepoint) : nil
46
46
  CharResult.new(codepoint: codepoint, block_id: block_id, glyph_path: glyph)
@@ -62,4 +62,4 @@ module Ucode
62
62
  end
63
63
  end
64
64
  end
65
- end
65
+ end
@@ -6,19 +6,21 @@ require "ucode/cache"
6
6
  require "ucode/coordinator"
7
7
  require "ucode/parsers"
8
8
  require "ucode/repo"
9
- require "ucode/version_resolver"
10
9
 
11
10
  module Ucode
12
11
  module Commands
13
12
  # `ucode parse` — streams the Coordinator output into the on-disk
14
13
  # JSON tree at `output/`. Single pass: enrich + write per-cp JSON +
15
14
  # accumulate aggregates + final flush.
15
+ #
16
+ # Takes a resolved version string; CLI callers resolve via
17
+ # {VersionResolver.resolve} once and thread it through. See
18
+ # Candidate 4 of the 2026-06-29 architecture review.
16
19
  class ParseCommand
17
- # @param version_intent [nil, :default, :latest, String]
20
+ # @param version [String] resolved UCD version
18
21
  # @param output_root [String, Pathname]
19
22
  # @return [Hash] { version:, codepoint_count: }
20
- def call(version_intent, output_root:)
21
- version = VersionResolver.resolve(version_intent)
23
+ def call(version, output_root:)
22
24
  root = Pathname.new(output_root)
23
25
  ucd_dir = Cache.ucd_dir(version)
24
26
  unihan_dir = Cache.unihan_dir(version)
@@ -59,4 +61,4 @@ module Ucode
59
61
  end
60
62
  end
61
63
  end
62
- end
64
+ end
@@ -7,7 +7,6 @@ require "ucode/audit"
7
7
  require "ucode/audit/library_auditor"
8
8
  require "ucode/audit/release"
9
9
  require "ucode/audit/emitter/paths"
10
- require "ucode/commands/audit/reference_builder"
11
10
 
12
11
  module Ucode
13
12
  module Commands
@@ -39,7 +39,7 @@ module Ucode
39
39
  # (in `lib/ucode/cli.rb`) is responsible only for argument
40
40
  # parsing and dispatch.
41
41
  class BuildCommand
42
- # @param version_intent [nil, :default, :latest, String]
42
+ # @param version [String] resolved UCD version
43
43
  # @param output_root [String, Pathname] directory that will
44
44
  # hold `manifest.json`, `glyphs/`, `reports/`.
45
45
  # @param source_config_path [String, Pathname, nil] override
@@ -58,10 +58,9 @@ module Ucode
58
58
  # resolver and don't have a real source config on disk.
59
59
  # @return [Hash] { version:, manifest_path:, totals:,
60
60
  # by_tier:, coverage:, validation: }
61
- def call(version_intent, output_root:, source_config_path: nil,
61
+ def call(version, output_root:, source_config_path: nil,
62
62
  resolver: nil, block_filter: nil,
63
63
  parallel_workers: default_workers, skip_pre_check: false)
64
- version = VersionResolver.resolve(version_intent)
65
64
  root = Pathname.new(output_root)
66
65
 
67
66
  config_path = source_config_path_or_default(source_config_path)
@@ -144,7 +143,7 @@ module Ucode
144
143
  # checks (config loads, fonts present, coverage assertion runs)
145
144
  # without starting the 4-hour build.
146
145
  class PreCheckCommand
147
- # @param version_intent [nil, :default, :latest, String]
146
+ # @param version [String] resolved UCD version
148
147
  # @param source_config_path [String, Pathname, nil]
149
148
  # @param cmaps [#covers?] injectable; defaults to
150
149
  # RealFonts::CmapCache.
@@ -153,9 +152,8 @@ module Ucode
153
152
  # @return [Ucode::Glyphs::UniversalSet::PreBuildReport]
154
153
  # @raise [Ucode::UniversalSetPreBuildError] when missing_fonts
155
154
  # is non-empty or the config fails to load.
156
- def call(version_intent, source_config_path: nil, cmaps: nil,
155
+ def call(version, source_config_path: nil, cmaps: nil,
157
156
  font_locator: nil)
158
- version = VersionResolver.resolve(version_intent)
159
157
  database = Database.open(version)
160
158
  config_path = source_config_path || Glyphs::SourceConfig::DEFAULT_PATH
161
159
 
@@ -171,12 +169,11 @@ module Ucode
171
169
  # shape (or regenerating reports after a model change) without
172
170
  # re-running the build.
173
171
  class ReportCommand
174
- # @param version_intent [nil, :default, :latest, String]
172
+ # @param version [String] resolved UCD version
175
173
  # @param output_root [String, Pathname] directory holding
176
174
  # `manifest.json`.
177
175
  # @return [Hash] the {CoverageReport#emit} payload.
178
- def call(version_intent, output_root:)
179
- version = VersionResolver.resolve(version_intent)
176
+ def call(version, output_root:)
180
177
  root = Pathname.new(output_root)
181
178
  manifest_path = root.join("manifest.json")
182
179
  raise Ucode::Error, "manifest not found at #{manifest_path}" unless manifest_path.exist?
@@ -196,12 +193,11 @@ module Ucode
196
193
  # totals_reconcile, provenance_complete).
197
194
  class ValidateCommand
198
195
  # @param output_root [String, Pathname]
199
- # @param version_intent [nil, :default, :latest, String]
200
- # used only to stamp the report's unicode_version when the
201
- # manifest's recorded value is missing.
196
+ # @param version [String, nil] resolved UCD version, used only
197
+ # to stamp the report's unicode_version when the manifest's
198
+ # recorded value is missing.
202
199
  # @return [Hash] the {Validator#validate} payload.
203
- def call(output_root, version_intent: nil)
204
- version = version_intent && VersionResolver.resolve(version_intent)
200
+ def call(output_root, version: nil)
205
201
  Glyphs::UniversalSet::Validator
206
202
  .new(output_root, unicode_version: version).validate
207
203
  end
@@ -2,6 +2,22 @@
2
2
 
3
3
  module Ucode
4
4
  class Coordinator
5
+ # Pairs of (output-file-slug, indices-field) for every per-property
6
+ # relationship table the Repo writes. Each field holds a Hash whose
7
+ # values are Records (or Arrays of Records). The Repo iterates
8
+ # `Indices#each_relationship` instead of reaching into the Struct by
9
+ # field name (see Candidate 1 of the 2026-06-29 architecture review).
10
+ RELATIONSHIPS = [
11
+ ["special_casing", :special_casing],
12
+ ["case_folding", :case_folding],
13
+ ["bidi_mirroring", :bidi_mirroring],
14
+ ["bidi_brackets", :bidi_brackets],
15
+ ["cjk_radicals", :cjk_radicals],
16
+ ["standardized_variants", :standardized_variants],
17
+ ["name_aliases", :name_aliases],
18
+ ].freeze
19
+ private_constant :RELATIONSHIPS
20
+
5
21
  # Bag of pre-built indices consumed by the per-codepoint enrichment
6
22
  # pass. Every field is a frozen-shaped collection that is read-only
7
23
  # after `build_indices` returns: range files land in sorted Arrays
@@ -12,6 +28,10 @@ module Ucode
12
28
  # call reads as a self-documenting catalogue of every parsed file —
13
29
  # adding a new index is one keyword arg here, one builder call in
14
30
  # `Coordinator#build_indices`, and one assignment in `#enrich`.
31
+ #
32
+ # The relationship enumerator (`#each_relationship`) is the seam the
33
+ # Repo uses to write per-property relationship tables without knowing
34
+ # which Struct field carries which data.
15
35
  Indices = Struct.new(
16
36
  :blocks,
17
37
  :scripts,
@@ -40,6 +60,22 @@ module Ucode
40
60
  :emoji_properties,
41
61
  :extra_binary_properties,
42
62
  keyword_init: true,
43
- )
63
+ ) do
64
+ # Yield (slug, records) for each relationship table. The seam
65
+ # between "what the Coordinator indexed" and "what the Repo writes"
66
+ # lives here — Repo never names a Struct field directly.
67
+ #
68
+ # @yieldparam slug [String] output file slug under
69
+ # `output/relationships/`
70
+ # @yieldparam records [Hash<Integer|String, Record|Array<Record>>]
71
+ # @return [Enumerator] when no block is given
72
+ def each_relationship(&block)
73
+ return enum_for(:each_relationship) unless block_given?
74
+
75
+ RELATIONSHIPS.each do |slug, field|
76
+ yield(slug, public_send(field))
77
+ end
78
+ end
79
+ end
44
80
  end
45
- end
81
+ end
data/lib/ucode/error.rb CHANGED
@@ -97,6 +97,10 @@ module Ucode
97
97
  # Version string not in Config.known_versions.
98
98
  class UnknownVersionError < LookupError; end
99
99
 
100
+ # Block identifier not present in the cached Blocks.txt. Carries the
101
+ # offending id and the path searched in `context:`.
102
+ class UnknownBlockError < LookupError; end
103
+
100
104
  # Glyph pipeline failures.
101
105
  class GlyphError < Error; end
102
106
 
@@ -114,6 +118,13 @@ module Ucode
114
118
  # `mutool` is not installed on the PATH.
115
119
  class EmbeddedFontsMissingError < GlyphError; end
116
120
 
121
+ # The Code Charts PDF for a requested block cannot be obtained: the
122
+ # network returned 4xx/5xx, the response wasn't application/pdf, or
123
+ # the body didn't start with the `%PDF` magic. Distinct from
124
+ # {EmbeddedFontsMissingError} (which fires when the file is already
125
+ # on disk and we just can't open it): this fires at fetch time.
126
+ class CodeChartNotFoundError < GlyphError; end
127
+
117
128
  # Pre-build validation failed for a universal-set build. The
118
129
  # context carries the failing checks so the CLI can render a
119
130
  # useful diagnostic without re-running them. Distinct from
@@ -29,7 +29,7 @@ module Ucode
29
29
  next if dest.exist? && !force
30
30
 
31
31
  url = "#{Ucode.configuration.charts_base_url}/#{filename}"
32
- Http.get(url, dest: dest)
32
+ Http.get(url, dest: dest, validate: :pdf)
33
33
  downloaded += 1
34
34
  end
35
35
  downloaded
@@ -23,9 +23,17 @@ module Ucode
23
23
  # directory is created if absent.
24
24
  # @param retries [Integer, nil] override Config.http_retries.
25
25
  # @param timeout [Integer, nil] override Config.http_timeout.
26
+ # @param validate [Symbol, nil] when `:pdf`, after a successful
27
+ # download verify (a) Content-Type starts with `application/pdf`
28
+ # and (b) the first 4 bytes of the body are `%PDF`. Raises
29
+ # {Ucode::CodeChartNotFoundError} with the offending header
30
+ # value in `context:` on failure. nil = no validation (the
31
+ # default for non-PDF callers like UcdZip and UnihanZip).
26
32
  # @return [Pathname] destination path on success.
27
33
  # @raise [Ucode::NetworkError] if all retries fail.
28
- def get(url, dest:, retries: nil, timeout: nil)
34
+ # @raise [Ucode::CodeChartNotFoundError] when `validate: :pdf`
35
+ # and the response fails content validation.
36
+ def get(url, dest:, retries: nil, timeout: nil, validate: nil)
29
37
  uri = url.is_a?(URI) ? url : URI(url)
30
38
  destination = Pathname.new(dest)
31
39
  destination.dirname.mkpath
@@ -36,15 +44,21 @@ module Ucode
36
44
 
37
45
  last_error = nil
38
46
  (attempts + 1).times do |attempt|
39
- return stream_to(uri, destination, read_timeout)
40
- rescue StandardError => e
41
- last_error = e
42
- sleep_for = backoff_sequence[attempt] || backoff_sequence.last
43
- Ucode.configuration.logger&.warn do
44
- "Http GET #{uri} failed (attempt #{attempt + 1}/#{attempts + 1}): " \
45
- "#{e.class}: #{e.message}; retrying in #{sleep_for}s"
47
+ begin
48
+ response = stream_to(uri, destination, read_timeout)
49
+ validate_response!(validate, response, destination) if validate
50
+ return destination
51
+ rescue ValidationFailure => e
52
+ raise e.cause
53
+ rescue StandardError => e
54
+ last_error = e
55
+ sleep_for = backoff_sequence[attempt] || backoff_sequence.last
56
+ Ucode.configuration.logger&.warn do
57
+ "Http GET #{uri} failed (attempt #{attempt + 1}/#{attempts + 1}): " \
58
+ "#{e.class}: #{e.message}; retrying in #{sleep_for}s"
59
+ end
60
+ sleep(sleep_for)
46
61
  end
47
- sleep(sleep_for)
48
62
  end
49
63
 
50
64
  raise Ucode::NetworkError.new(
@@ -55,19 +69,34 @@ module Ucode
55
69
 
56
70
  private
57
71
 
72
+ # Internal carrier for a validation failure inside a retry
73
+ # attempt. Re-raised from the loop so the response body (which
74
+ # is partial on retries) isn't double-validated against
75
+ # truncated bytes.
76
+ class ValidationFailure < StandardError
77
+ attr_reader :cause
78
+
79
+ def initialize(cause)
80
+ @cause = cause
81
+ super(cause.message)
82
+ end
83
+ end
84
+
58
85
  def stream_to(uri, destination, read_timeout)
86
+ response = nil
59
87
  Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
60
88
  read_timeout: read_timeout) do |http|
61
89
  request = Net::HTTP::Get.new(uri)
62
- http.request(request) do |response|
63
- unless response.is_a?(Net::HTTPSuccess)
64
- raise "HTTP #{response.code} #{response.message}"
90
+ http.request(request) do |r|
91
+ unless r.is_a?(Net::HTTPSuccess)
92
+ raise "HTTP #{r.code} #{r.message}"
65
93
  end
66
94
 
67
- write_body(response, destination)
95
+ write_body(r, destination)
96
+ response = r
68
97
  end
69
98
  end
70
- destination
99
+ response or raise "no response received"
71
100
  end
72
101
 
73
102
  def write_body(response, destination)
@@ -77,6 +106,47 @@ module Ucode
77
106
  end
78
107
  File.rename(partial.to_s, destination.to_s)
79
108
  end
109
+
110
+ # Verifies Content-Type and magic bytes for a downloaded file.
111
+ # Raises ValidationFailure carrying a CodeChartNotFoundError so
112
+ # the retry loop in `get` doesn't re-attempt a download that's
113
+ # structurally invalid (only the transport is retriable).
114
+ def validate_response!(mode, response, destination)
115
+ case mode
116
+ when :pdf then validate_pdf!(response, destination)
117
+ else raise ArgumentError, "unknown validate mode: #{mode.inspect}"
118
+ end
119
+ end
120
+
121
+ PDF_CONTENT_TYPE_PREFIX = "application/pdf"
122
+ PDF_MAGIC = "%PDF"
123
+ private_constant :PDF_CONTENT_TYPE_PREFIX, :PDF_MAGIC
124
+
125
+ def validate_pdf!(response, destination)
126
+ content_type = response["Content-Type"].to_s
127
+ unless content_type.start_with?(PDF_CONTENT_TYPE_PREFIX)
128
+ raise ValidationFailure.new(
129
+ Ucode::CodeChartNotFoundError.new(
130
+ "expected Content-Type application/pdf, got #{content_type.inspect}",
131
+ context: { url: response.uri.to_s, content_type: content_type },
132
+ ),
133
+ )
134
+ end
135
+
136
+ # Re-open the destination file and peek at the first 4 bytes.
137
+ # The response body has already been written to disk by
138
+ # `stream_to`; we don't re-read from the response (which is
139
+ # consumed by then).
140
+ magic = File.open(destination, "rb") { |f| f.read(4) }
141
+ unless magic == PDF_MAGIC
142
+ raise ValidationFailure.new(
143
+ Ucode::CodeChartNotFoundError.new(
144
+ "expected %PDF magic bytes, got #{magic.inspect}",
145
+ context: { url: response.uri.to_s, magic: magic },
146
+ ),
147
+ )
148
+ end
149
+ end
80
150
  end
81
151
  end
82
152
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "set"
5
+
6
+ require "ucode/cache"
7
+ require "ucode/glyphs/pdf_fetcher"
8
+ require "ucode/glyphs/writer"
9
+ require "ucode/parsers"
10
+
11
+ module Ucode
12
+ module Glyphs
13
+ # Assembles the per-block specs that {Glyphs::Writer#write_all} drains.
14
+ #
15
+ # Owns three pieces of orchestration that {Commands::GlyphsCommand}
16
+ # used to carry inline:
17
+ #
18
+ # - block loading from {Cache.ucd_dir}/Blocks.txt (with an optional
19
+ # block filter)
20
+ # - PDF fetcher construction (with monolith fallback)
21
+ # - the per-block page-map heuristic (per-block PDFs are page 1 =
22
+ # title, page 2 = first chart page starting at the block's first
23
+ # codepoint; true for most BMP blocks; multi-page blocks need a
24
+ # richer resolver — mismatches yield placeholder SVGs only, never
25
+ # wrong glyphs)
26
+ #
27
+ # The Command stays a thin wrapper that prints the experimental
28
+ # warning and wires the writer. See Candidate 3 of the 2026-06-29
29
+ # architecture review.
30
+ class Pipeline
31
+ # Path to the monolith fallback file when no per-block PDF is on
32
+ # disk yet. Overridable for tests.
33
+ DEFAULT_MONOLITH_PATH = "CodeCharts.pdf"
34
+ # Cache path for the page-map corpus. Overridable for tests.
35
+ DEFAULT_PAGE_MAP_CACHE = "data/codecharts_page_map.json"
36
+
37
+ Spec = Struct.new(:block, :pdf_path, :page_map, keyword_init: true)
38
+
39
+ # @param version [String] resolved UCD version (callers must
40
+ # resolve via {VersionResolver.resolve} first)
41
+ # @param block_filter [Array<String>, nil] block ids to limit to;
42
+ # nil = every block
43
+ # @param monolith_path [String, Pathname, nil] fallback monolith
44
+ # @param page_map_cache [String, Pathname] cache for the page map
45
+ def initialize(version:, block_filter: nil,
46
+ monolith_path: DEFAULT_MONOLITH_PATH,
47
+ page_map_cache: DEFAULT_PAGE_MAP_CACHE)
48
+ @version = version
49
+ @block_filter = block_filter
50
+ @monolith_path = monolith_path
51
+ @page_map_cache = page_map_cache
52
+ end
53
+
54
+ # Load every block from the cached Blocks.txt (filtered by
55
+ # `@block_filter` when set) and pair each one with a fetched PDF
56
+ # path and a page map. Blocks whose PDF cannot be fetched are
57
+ # silently dropped — the placeholder pass downstream covers them.
58
+ #
59
+ # @param force [Boolean] re-fetch PDFs even when cached
60
+ # @return [Array<Spec>]
61
+ def build_specs(force: false)
62
+ blocks = load_blocks
63
+ fetcher = build_fetcher(blocks)
64
+ blocks.map { |block| spec_for(block, fetcher, force) }.compact
65
+ end
66
+
67
+ private
68
+
69
+ def load_blocks
70
+ path = Cache.ucd_dir(@version).join("Blocks.txt")
71
+ return [] unless path.exist?
72
+
73
+ all = Parsers::Blocks.each_record(path).to_a
74
+ return all unless @block_filter && !@block_filter.empty?
75
+
76
+ filter_set = @block_filter.to_set
77
+ all.select { |block| filter_set.include?(block.id) }
78
+ end
79
+
80
+ def build_fetcher(blocks)
81
+ monolith = @monolith_path ? Pathname.new(@monolith_path) : nil
82
+ monolith = monolith.exist? ? monolith : nil
83
+ PdfFetcher.new(
84
+ @version,
85
+ monolith_path: monolith,
86
+ blocks: blocks,
87
+ page_map_cache: @page_map_cache,
88
+ )
89
+ end
90
+
91
+ def spec_for(block, fetcher, force)
92
+ pdf_path = fetcher.fetch(block_first_cp: block.range_first, force: force)
93
+ return nil unless pdf_path
94
+
95
+ Spec.new(block: block, pdf_path: pdf_path, page_map: page_map_for(block))
96
+ end
97
+
98
+ # Per-block PDFs are page 1 = title, page 2 = first chart page
99
+ # starting at the block's first codepoint. True for most BMP
100
+ # blocks; multi-page blocks (CJK) need a richer resolver.
101
+ def page_map_for(block)
102
+ { 2 => block.range_first }
103
+ end
104
+ end
105
+ end
106
+ end
data/lib/ucode/glyphs.rb CHANGED
@@ -20,6 +20,7 @@ module Ucode
20
20
  autoload :CellExtractor, "ucode/glyphs/cell_extractor"
21
21
  autoload :MonolithPageMap, "ucode/glyphs/monolith_page_map"
22
22
  autoload :Writer, "ucode/glyphs/writer"
23
+ autoload :Pipeline, "ucode/glyphs/pipeline"
23
24
  autoload :LastResort, "ucode/glyphs/last_resort"
24
25
  autoload :EmbeddedFonts, "ucode/glyphs/embedded_fonts"
25
26
  autoload :RealFonts, "ucode/glyphs/real_fonts"
@@ -37,6 +37,40 @@ module Ucode
37
37
  nil
38
38
  end
39
39
 
40
+ # Resolves a block by its identifier (the underscored form of
41
+ # the block name, e.g. "Basic_Latin", "Egyptian_Hieroglyphs_Extended-B").
42
+ # Streams `Blocks.txt` once and short-circuits on first match —
43
+ # callers don't need to walk the whole ~340-block file.
44
+ #
45
+ # @param path [Pathname, String] path to a Blocks.txt
46
+ # @param id [String] block identifier (matches `Models::Block#id`)
47
+ # @return [Models::Block, nil] the block, or nil when no block
48
+ # has the given id
49
+ def find_by_id(path, id)
50
+ return nil if id.nil? || id.empty?
51
+
52
+ each_record(path) do |block|
53
+ return block if block.id == id
54
+ end
55
+ nil
56
+ end
57
+
58
+ # Same as {find_by_id} but raises {Ucode::UnknownBlockError} on
59
+ # miss. Use this in callers that can't recover from a missing
60
+ # block (CLI commands, extractors that need a block to proceed).
61
+ #
62
+ # @param path [Pathname, String] path to a Blocks.txt
63
+ # @param id [String] block identifier
64
+ # @return [Models::Block]
65
+ # @raise [Ucode::UnknownBlockError] when no block matches
66
+ def find_by_id!(path, id)
67
+ find_by_id(path, id) or
68
+ raise Ucode::UnknownBlockError.new(
69
+ "unknown Unicode block: #{id.inspect}",
70
+ context: { block_id: id, blocks_txt: path.to_s },
71
+ )
72
+ end
73
+
40
74
  private
41
75
 
42
76
  def build_block(range, name)