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.
- checksums.yaml +4 -4
- data/TODO.extract-code-chart/01-pdf-fetch-validation.md +80 -0
- data/TODO.extract-code-chart/02-block-name-resolver.md +68 -0
- data/TODO.extract-code-chart/03-codechart-namespace.md +82 -0
- data/TODO.extract-code-chart/04-codechart-extractor.md +154 -0
- data/TODO.extract-code-chart/05-provenance-and-sidecar.md +147 -0
- data/TODO.extract-code-chart/06-codechart-writer.md +134 -0
- data/TODO.extract-code-chart/07-codechart-cli.md +135 -0
- data/TODO.extract-code-chart/08-specs.md +87 -0
- data/lib/ucode/audit/reference_factory.rb +66 -0
- data/lib/ucode/audit.rb +1 -0
- data/lib/ucode/cli.rb +134 -16
- data/lib/ucode/code_chart/extractor.rb +122 -0
- data/lib/ucode/code_chart/provenance.rb +81 -0
- data/lib/ucode/code_chart/sidecar.rb +52 -0
- data/lib/ucode/code_chart/writer.rb +128 -0
- data/lib/ucode/code_chart.rb +39 -0
- data/lib/ucode/commands/audit.rb +0 -1
- data/lib/ucode/commands/build.rb +4 -0
- data/lib/ucode/commands/canonical_build.rb +2 -3
- data/lib/ucode/commands/fetch.rb +12 -14
- data/lib/ucode/commands/glyphs.rb +25 -67
- data/lib/ucode/commands/lookup.rb +11 -11
- data/lib/ucode/commands/parse.rb +7 -5
- data/lib/ucode/commands/release.rb +0 -1
- data/lib/ucode/commands/universal_set.rb +10 -14
- data/lib/ucode/coordinator/indices.rb +38 -2
- data/lib/ucode/error.rb +11 -0
- data/lib/ucode/fetch/code_charts.rb +1 -1
- data/lib/ucode/fetch/http.rb +84 -14
- data/lib/ucode/glyphs/pipeline.rb +106 -0
- data/lib/ucode/glyphs.rb +1 -0
- data/lib/ucode/parsers/blocks.rb +34 -0
- data/lib/ucode/repo/aggregate_writer.rb +60 -298
- data/lib/ucode/repo/writers/blocks_writer.rb +73 -0
- data/lib/ucode/repo/writers/enums_writer.rb +38 -0
- data/lib/ucode/repo/writers/indexes_writer.rb +53 -0
- data/lib/ucode/repo/writers/manifest_writer.rb +78 -0
- data/lib/ucode/repo/writers/named_sequences_writer.rb +47 -0
- data/lib/ucode/repo/writers/planes_writer.rb +82 -0
- data/lib/ucode/repo/writers/relationships_writer.rb +71 -0
- data/lib/ucode/repo/writers/scripts_writer.rb +54 -0
- data/lib/ucode/repo/writers.rb +20 -0
- data/lib/ucode/repo.rb +1 -0
- data/lib/ucode/version.rb +1 -1
- data/lib/ucode.rb +3 -0
- data/ucode.gemspec +56 -0
- metadata +31 -5
- data/Gemfile.lock +0 -406
- 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
|
|
23
|
+
# @param version [String] resolved UCD version
|
|
21
24
|
# @param codepoint [Integer]
|
|
22
25
|
# @return [BlockResult]
|
|
23
|
-
def lookup_block(
|
|
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
|
|
31
|
+
# @param version [String] resolved UCD version
|
|
30
32
|
# @param codepoint [Integer]
|
|
31
33
|
# @return [ScriptResult]
|
|
32
|
-
def lookup_script(
|
|
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
|
|
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(
|
|
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
|
data/lib/ucode/commands/parse.rb
CHANGED
|
@@ -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
|
|
20
|
+
# @param version [String] resolved UCD version
|
|
18
21
|
# @param output_root [String, Pathname]
|
|
19
22
|
# @return [Hash] { version:, codepoint_count: }
|
|
20
|
-
def call(
|
|
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
|
|
@@ -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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
200
|
-
#
|
|
201
|
-
#
|
|
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,
|
|
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
|
data/lib/ucode/fetch/http.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 |
|
|
63
|
-
unless
|
|
64
|
-
raise "HTTP #{
|
|
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(
|
|
95
|
+
write_body(r, destination)
|
|
96
|
+
response = r
|
|
68
97
|
end
|
|
69
98
|
end
|
|
70
|
-
|
|
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"
|
data/lib/ucode/parsers/blocks.rb
CHANGED
|
@@ -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)
|