ucode 0.2.0 → 0.2.2

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +10 -2
  3. data/README.md +66 -20
  4. data/Rakefile +19 -8
  5. data/TODO.extract-code-chart/01-pdf-fetch-validation.md +80 -0
  6. data/TODO.extract-code-chart/02-block-name-resolver.md +68 -0
  7. data/TODO.extract-code-chart/03-codechart-namespace.md +82 -0
  8. data/TODO.extract-code-chart/04-codechart-extractor.md +154 -0
  9. data/TODO.extract-code-chart/05-provenance-and-sidecar.md +147 -0
  10. data/TODO.extract-code-chart/06-codechart-writer.md +134 -0
  11. data/TODO.extract-code-chart/07-codechart-cli.md +135 -0
  12. data/TODO.extract-code-chart/08-specs.md +87 -0
  13. data/config/unicode17_universal_glyph_set.yml +1 -1
  14. data/lib/ucode/audit/reference_factory.rb +1 -1
  15. data/lib/ucode/cli.rb +101 -0
  16. data/lib/ucode/code_chart/extractor.rb +120 -0
  17. data/lib/ucode/code_chart/provenance.rb +82 -0
  18. data/lib/ucode/code_chart/sidecar.rb +52 -0
  19. data/lib/ucode/code_chart/writer.rb +128 -0
  20. data/lib/ucode/code_chart.rb +39 -0
  21. data/lib/ucode/commands/fetch.rb +1 -1
  22. data/lib/ucode/commands/glyphs.rb +1 -1
  23. data/lib/ucode/commands/lookup.rb +1 -1
  24. data/lib/ucode/commands/parse.rb +1 -1
  25. data/lib/ucode/coordinator/indices.rb +2 -2
  26. data/lib/ucode/error.rb +11 -0
  27. data/lib/ucode/fetch/code_charts.rb +3 -4
  28. data/lib/ucode/fetch/http.rb +75 -7
  29. data/lib/ucode/glyphs/page_renderer.rb +15 -2
  30. data/lib/ucode/glyphs/pipeline.rb +1 -2
  31. data/lib/ucode/parsers/blocks.rb +34 -0
  32. data/lib/ucode/repo/aggregate_writer.rb +1 -1
  33. data/lib/ucode/repo/writers/blocks_writer.rb +13 -13
  34. data/lib/ucode/repo/writers/enums_writer.rb +2 -2
  35. data/lib/ucode/repo/writers/indexes_writer.rb +4 -4
  36. data/lib/ucode/repo/writers/manifest_writer.rb +4 -4
  37. data/lib/ucode/repo/writers/named_sequences_writer.rb +1 -1
  38. data/lib/ucode/repo/writers/planes_writer.rb +17 -17
  39. data/lib/ucode/repo/writers/relationships_writer.rb +1 -1
  40. data/lib/ucode/repo/writers/scripts_writer.rb +6 -6
  41. data/lib/ucode/repo/writers.rb +1 -1
  42. data/lib/ucode/version.rb +1 -1
  43. data/lib/ucode.rb +3 -0
  44. data/ucode.gemspec +6 -1
  45. metadata +19 -6
@@ -0,0 +1,147 @@
1
+ # TODO 05 — CodeChart::Provenance and CodeChart::Sidecar
2
+
3
+ ## Status
4
+
5
+ Pending. Depends on TODO 03 (namespace). Depends on TODO 04 (Extractor)
6
+ because Sidecar consumes the Extractor's Result.
7
+
8
+ ## Goal
9
+
10
+ `Provenance` is the value object carrying the metadata the REQ (R5)
11
+ requires for each extracted SVG's sidecar JSON. `Sidecar` is the
12
+ writer that serializes one Provenance to disk next to its SVG.
13
+
14
+ ## Files
15
+
16
+ - `lib/ucode/code_chart/provenance.rb` — `Ucode::CodeChart::Provenance`
17
+ Struct with all REQ R5 fields plus the construction helper.
18
+ - `lib/ucode/code_chart/sidecar.rb` — `Ucode::CodeChart::Sidecar`
19
+ class (writes a sidecar JSON next to an SVG, idempotent via the
20
+ existing `Ucode::Repo::AtomicWrites`).
21
+ - `spec/ucode/code_chart/provenance_spec.rb`
22
+ - `spec/ucode/code_chart/sidecar_spec.rb`
23
+
24
+ ## Design
25
+
26
+ ### Provenance value object
27
+
28
+ The REQ (R5) lists these fields:
29
+
30
+ ```json
31
+ {
32
+ "codepoint": "U+10920",
33
+ "block": "Sidetic",
34
+ "source_pdf_url": "https://www.unicode.org/charts/PDF/U-10920.pdf",
35
+ "source_pdf_sha256": "...",
36
+ "ucd_version": "17.0.0",
37
+ "extracted_at": "2026-06-30T12:00:00Z",
38
+ "extractor_version": "0.1.0"
39
+ }
40
+ ```
41
+
42
+ `Struct` is the right tool — single source of truth for the schema,
43
+ keyword-init for clarity, immutable-by-convention. Mirror the
44
+ existing `Ucode::Repo::BuildReportAccumulator` pattern.
45
+
46
+ ```ruby
47
+ Provenance = Struct.new(
48
+ :codepoint, # String "U+10920"
49
+ :block, # String "Sidetic"
50
+ :source_pdf_url, # String
51
+ :source_pdf_sha256, # String (hex digest)
52
+ :ucd_version, # String "17.0.0"
53
+ :extracted_at, # String ISO8601 UTC
54
+ :extractor_version, # String "0.2.0"
55
+ keyword_init: true,
56
+ )
57
+ ```
58
+
59
+ `extractor_version` reads from `Ucode::VERSION` so it stays in sync
60
+ with the gem — single source of truth.
61
+
62
+ `extracted_at` is set at construction (not at file write) so the
63
+ field describes the extraction event, not the serialization event.
64
+
65
+ ### Provenance → Hash serialization
66
+
67
+ `Provenance#to_h` returns the hash form. NO hand-rolled `to_json` /
68
+ `from_json` per the global rule — `Provenance` is a value object, but
69
+ its schema is simple enough that `to_h` + `JSON.pretty_generate` is
70
+ the framework-driven approach (lutaml-model is overkill for a flat
71
+ struct).
72
+
73
+ Wait — re-reading the global rule: "ALL (de)serialization goes through
74
+ the framework. In Coradoc and any project using `lutaml-model`." So
75
+ the rule is for projects using lutaml-model, and ucode uses lutaml-model
76
+ for UCD models. This Provenance struct is not a UCD model — it's a
77
+ feature-local value object. JSON via `JSON.pretty_generate(provenance.to_h)`
78
+ is acceptable and avoids ceremony.
79
+
80
+ `to_h` produces a hash with the Struct's keyword keys. No
81
+ indirection, no lutaml-model mapping for what is effectively a
82
+ record.
83
+
84
+ ### Sidecar writer
85
+
86
+ ```ruby
87
+ class Sidecar
88
+ include Ucode::Repo::AtomicWrites
89
+
90
+ def initialize(output_root:)
91
+ @output_root = Pathname.new(output_root)
92
+ end
93
+
94
+ # Writes <output_root>/<cp_id>.json next to the corresponding SVG.
95
+ # Idempotent: re-writing the same content is a no-op (byte-stable).
96
+ #
97
+ # @param provenance [Ucode::CodeChart::Provenance]
98
+ # @return [Pathname] the written path
99
+ def write(provenance)
100
+ path = path_for(provenance)
101
+ payload = JSON.pretty_generate(provenance.to_h)
102
+ write_atomic(path, payload + "\n")
103
+ path
104
+ end
105
+
106
+ private
107
+
108
+ def path_for(provenance)
109
+ @output_root.join("#{provenance.codepoint}.json")
110
+ end
111
+ end
112
+ ```
113
+
114
+ `Repo::AtomicWrites#write_atomic` is the project's single source of
115
+ truth for idempotent file writes — bytes-identical re-writes are
116
+ no-ops (the temp-file rename is skipped when content matches).
117
+ Reuse, don't reimplement.
118
+
119
+ ### Why a separate Sidecar class
120
+
121
+ A `Provenance.to_disk(path)` method would couple the value object
122
+ to I/O. Keeping the writer separate lets:
123
+ - Tests assert `Provenance#to_h` without touching disk.
124
+ - The Writer (TODO 06) compose `Extractor` + `Sidecar` with explicit
125
+ dependency injection (seam for testing).
126
+ - Future formats (e.g. a different sidecar schema) replace Sidecar
127
+ without touching Provenance.
128
+
129
+ This is MECE: Provenance is data; Sidecar is I/O; Writer is
130
+ orchestration.
131
+
132
+ ## Acceptance
133
+
134
+ - `Provenance.new(codepoint: "U+10920", block: "Sidetic", ...)`
135
+ constructs without raising.
136
+ - `Provenance#to_h` returns a Hash with exactly the REQ's fields.
137
+ - `Sidecar#write(provenance)` writes `<codepoint>.json` next to
138
+ where the SVG lives; the JSON content matches `Provenance#to_h`.
139
+ - Re-writing the same Provenance is a no-op (file unchanged).
140
+ - Specs cover all five REQ fields plus the idempotency guarantee.
141
+
142
+ ## Out of scope
143
+
144
+ - License attribution text — the REQ mentions `LICENSE-SOURCES.md`
145
+ obligations, but that's a fontist-side concern (downstream
146
+ essenfont build). Ucode emits provenance; the consumer stitches
147
+ attribution.
@@ -0,0 +1,134 @@
1
+ # TODO 06 — CodeChart::Writer
2
+
3
+ ## Status
4
+
5
+ Pending. Depends on TODO 04 (Extractor) and TODO 05 (Provenance +
6
+ Sidecar).
7
+
8
+ ## Goal
9
+
10
+ `Ucode::CodeChart::Writer` is the single entry point for
11
+ "extract every codepoint in block X and write SVG + sidecar JSON
12
+ files under output_dir." It's the orchestration layer the CLI
13
+ calls and the only thing that touches disk.
14
+
15
+ ## Files
16
+
17
+ - `lib/ucode/code_chart/writer.rb` — `Ucode::CodeChart::Writer` class.
18
+ - `spec/ucode/code_chart/writer_spec.rb`
19
+
20
+ ## Design
21
+
22
+ ### Class shape
23
+
24
+ ```ruby
25
+ class Ucode::CodeChart::Writer
26
+ Summary = Struct.new(:block, :codepoints_total, :svgs_written,
27
+ :sidecars_written, :pdf_sha256, keyword_init: true)
28
+
29
+ def initialize(output_root:, pdf_path:, cache_dir: nil,
30
+ last_resort_root: nil, blocks_txt: nil)
31
+ @output_root = Pathname.new(output_root)
32
+ @pdf_path = Pathname.new(pdf_path)
33
+ @cache_dir = cache_dir
34
+ @last_resort_root = last_resort_root
35
+ @blocks_txt = blocks_txt || Ucode::Cache.ucd_dir(Ucode::VersionResolver.resolve(nil)).join("Blocks.txt")
36
+ @sidecar = Sidecar.new(output_root: @output_root)
37
+ end
38
+
39
+ # Extracts every codepoint in @block (a Models::Block) and writes
40
+ # SVG + sidecar JSON under @output_root. Returns a Summary.
41
+ #
42
+ # @param block [Ucode::Models::Block]
43
+ # @return [Summary]
44
+ def write(block)
45
+ end
46
+ end
47
+ ```
48
+
49
+ ### Per-codepoint flow (single source of truth)
50
+
51
+ ```ruby
52
+ def write(block)
53
+ output_root_for(block).mkpath
54
+ pdf_sha = sha256(@pdf_path)
55
+
56
+ extractor = Extractor.new(
57
+ block: block,
58
+ blocks_txt: @blocks_txt,
59
+ pdf_path: @pdf_path,
60
+ cache_dir: @cache_dir,
61
+ last_resort_root: @last_resort_root,
62
+ )
63
+ results = extractor.extract
64
+
65
+ svgs = 0
66
+ sidecars = 0
67
+ results.each do |result|
68
+ svg_path = output_root_for(block).join("#{cp_id(result.codepoint)}.svg")
69
+ File.write(svg_path, result.svg) unless svg_path.exist? && File.read(svg_path) == result.svg
70
+ svgs += 1 if svg_path.exist?
71
+
72
+ provenance = build_provenance(block, result.codepoint, pdf_sha)
73
+ @sidecar.write(provenance)
74
+ sidecars += 1
75
+ end
76
+
77
+ Summary.new(
78
+ block: block.id,
79
+ codepoints_total: results.size,
80
+ svgs_written: svgs,
81
+ sidecars_written: sidecars,
82
+ pdf_sha256: pdf_sha,
83
+ )
84
+ end
85
+ ```
86
+
87
+ ### Why not use `Repo::AtomicWrites` for the SVGs
88
+
89
+ The Sidecar uses `Repo::AtomicWrites` because JSON has a stable
90
+ canonical form. SVG output from `EmbeddedFonts::Svg#to_s` is also
91
+ byte-stable, but the writer pattern is simpler with `File.write` —
92
+ the byte-equality check above guarantees idempotency at the I/O
93
+ layer. Both paths reach the same outcome.
94
+
95
+ If future output formats gain non-stable serialization (timestamps,
96
+ random IDs), the SVG path will need `Repo::AtomicWrites` too. Until
97
+ then, simpler is better.
98
+
99
+ ### Why compute `pdf_sha256` once
100
+
101
+ Every Provenance in this block carries the same `source_pdf_sha256`.
102
+ Computing it once avoids 32+ disk reads of the PDF per block
103
+ extraction. The Writer is the single place that knows "one block,
104
+ one PDF, one hash" — pushing the calculation into Sidecar or
105
+ Provenance would require either (a) repeated computation per
106
+ Provenance or (b) a parameter-passing thread through Extractor. Both
107
+ violate locality.
108
+
109
+ ### Output layout
110
+
111
+ `<output_root>/<block_id>/<U+XXXX>.svg` and `<U+XXXX>.json`.
112
+
113
+ One folder per block keeps the `Writer`'s output self-contained and
114
+ discoverable — a downstream consumer (fontisan) can iterate a block's
115
+ folder without scanning the whole tree. This mirrors the existing
116
+ `Ucode::Repo::Writers::BlocksWriter` output convention (one folder
117
+ per block, index.json inside).
118
+
119
+ ## Acceptance
120
+
121
+ - `Writer#write(block)` creates `<output_root>/<block_id>/` and
122
+ fills it with `<U+XXXX>.svg` + `<U+XXXX>.json` for every
123
+ extracted codepoint.
124
+ - Re-running `Writer#write(block)` with no changes produces
125
+ byte-identical files (no rewrites).
126
+ - `Summary#svgs_written` equals the number of extracted codepoints.
127
+ - Specs cover the full lifecycle including idempotency.
128
+
129
+ ## Out of scope
130
+
131
+ - Per-block write isolation — concurrent `Writer#write` calls for
132
+ different blocks are safe (different folders), but the Writer is
133
+ not thread-safe within a single block. That's a parallel-extraction
134
+ concern, not a per-block concern.
@@ -0,0 +1,135 @@
1
+ # TODO 07 — CodeChart CLI
2
+
3
+ ## Status
4
+
5
+ Pending. Depends on TODO 06 (Writer), TODO 04 (Extractor),
6
+ TODO 02 (block name resolver).
7
+
8
+ ## Goal
9
+
10
+ `ucode code-chart fetch | extract | list` — the REQ (R4) commands.
11
+ Thin Thor wrappers that delegate to the existing `CodeChart::*`
12
+ modules. No orchestration logic in the CLI; every command is a
13
+ single delegation.
14
+
15
+ ## Files
16
+
17
+ - `lib/ucode/cli.rb` — add the `CodeChartCmd` Thor subcommand class.
18
+ - `spec/ucode/cli_spec.rb` (extend existing) — verify the new
19
+ subcommand wires up.
20
+
21
+ ## Design
22
+
23
+ ### Subcommand shape
24
+
25
+ ```ruby
26
+ class Cli < Thor
27
+ # …existing commands…
28
+
29
+ class CodeChartCmd < Thor
30
+ desc "fetch --block BLOCK", "Download the Code Charts PDF for a block"
31
+ option :block, type: :string, required: true,
32
+ desc: "Block identifier (e.g. Sidetic)"
33
+ def fetch
34
+ puts JSON.pretty_generate(
35
+ Commands::FetchCommand.new.fetch_charts(
36
+ VersionResolver.resolve(nil),
37
+ block_first_cps: [block_first_cp!(options[:block])],
38
+ ),
39
+ )
40
+ end
41
+
42
+ desc "extract --block BLOCK --to DIR", "Extract per-codepoint SVG + provenance sidecars"
43
+ option :block, type: :string, required: true,
44
+ desc: "Block identifier (e.g. Sidetic)"
45
+ option :to, type: :string, required: true,
46
+ desc: "Output directory"
47
+ def extract
48
+ # ...
49
+ end
50
+
51
+ desc "list", "List blocks that have Code Charts PDFs available locally"
52
+ def list
53
+ # ...
54
+ end
55
+ end
56
+
57
+ desc "code-chart", "Extract per-codepoint SVG glyphs from Unicode Code Charts PDFs"
58
+ subcommand "code-chart", CodeChartCmd
59
+ end
60
+ ```
61
+
62
+ ### `extract` flow
63
+
64
+ ```ruby
65
+ def extract
66
+ Ucode::Commands::FetchCommand.new.fetch_charts(
67
+ VersionResolver.resolve(nil),
68
+ block_first_cps: [block_first_cp!(options[:block])],
69
+ )
70
+ blocks_txt = Ucode::Cache.ucd_dir(VersionResolver.resolve(nil)).join("Blocks.txt")
71
+ block = Parsers::Blocks.find_by_name(blocks_txt, options[:block]) or
72
+ raise Thor::Error, "Unknown block: #{options[:block].inspect}"
73
+ pdf = Ucode::Glyphs::PdfFetcher.new(
74
+ VersionResolver.resolve(nil),
75
+ monolith_path: nil,
76
+ blocks: [block],
77
+ ).fetch(block_first_cp: block.range_first, force: false) or
78
+ raise Thor::Error, "PDF unavailable for block #{options[:block]}"
79
+
80
+ writer = Ucode::CodeChart::Writer.new(
81
+ output_root: Pathname.new(options[:to]),
82
+ pdf_path: pdf,
83
+ blocks_txt: blocks_txt,
84
+ )
85
+ summary = writer.write(block)
86
+ puts JSON.pretty_generate(summary.to_h.compact)
87
+ end
88
+
89
+ def block_first_cp!(block_id)
90
+ cache = Ucode::Cache.ucd_dir(VersionResolver.resolve(nil))
91
+ block = Ucode::Parsers::Blocks.find_by_name(cache.join("Blocks.txt"), block_id)
92
+ raise Thor::Error, "Unknown block: #{block_id.inspect}" unless block
93
+ block.range_first
94
+ end
95
+ ```
96
+
97
+ ### Why `Ucode::Commands::FetchCommand.new.fetch_charts` for fetch
98
+
99
+ `fetch_charts` is the existing CLI hook for "download a Code Charts
100
+ PDF for these block first-cps." We just call it with the block's
101
+ first cp. Reuse, don't reimplement.
102
+
103
+ ### Why resolve version once at the top of `extract`
104
+
105
+ Per Candidate 4 of the architecture review (`refactor/build-context-resolve-version-once`,
106
+ merged): every CLI method resolves the version once and threads it
107
+ through. This CLI method does the same — one call per invocation.
108
+
109
+ ### Why no separate `Commands::CodeChartCommand` class
110
+
111
+ Following the existing pattern (e.g. `Cli::Audit` calls
112
+ `Commands::Audit::*Command` *only* when the logic is non-trivial).
113
+ The CodeChart commands are trivial delegations — a one-liner each.
114
+ The CLI methods call `CodeChart::Writer` and `CodeChart::Extractor`
115
+ directly. Adding a `Commands::CodeChartCommand` class would be
116
+ indirection without a payoff.
117
+
118
+ If the extract logic grows (e.g. progress reporting, partial
119
+ extraction), extract it into a Command class at that point.
120
+
121
+ ## Acceptance
122
+
123
+ - `ucode code-chart fetch --block Sidetic` downloads the PDF.
124
+ - `ucode code-chart extract --block Sidetic --to /tmp/s/` extracts
125
+ to the given directory.
126
+ - `ucode code-chart list` prints available blocks.
127
+ - Unknown block names produce a clean error, not a stack trace.
128
+ - The CLI matches the REQ's signature exactly.
129
+
130
+ ## Out of scope
131
+
132
+ - `--version` flag (the REQ doesn't specify, and existing commands
133
+ default to the configured version).
134
+ - `--format svg|glif` (the REQ specifies SVG; `.glif` output is a
135
+ future extension).
@@ -0,0 +1,87 @@
1
+ # TODO 08 — CodeChart specs
2
+
3
+ ## Status
4
+
5
+ Pending. Depends on TODOs 01–07.
6
+
7
+ ## Goal
8
+
9
+ Comprehensive spec coverage for every new module. Per the project
10
+ rules: real model instances (no doubles), tight focused tests,
11
+ behavior assertions (not interaction counts).
12
+
13
+ ## Files
14
+
15
+ Already enumerated per TODO. Final spec coverage:
16
+
17
+ - `spec/ucode/fetch/code_charts_spec.rb` — happy path + HTTP 4xx +
18
+ wrong content-type + non-PDF body. (TODO 01)
19
+ - `spec/ucode/parsers/blocks_spec.rb` (extend) — `find_by_name`
20
+ happy path + nil on miss. (TODO 02)
21
+ - `spec/ucode/code_chart/extractor_spec.rb` — constructor invariants,
22
+ Resolver wiring, integration test against fixture PDF. (TODO 04)
23
+ - `spec/ucode/code_chart/provenance_spec.rb` — value object
24
+ construction + `to_h` schema. (TODO 05)
25
+ - `spec/ucode/code_chart/sidecar_spec.rb` — write sidecar, idempotent
26
+ re-write. (TODO 05)
27
+ - `spec/ucode/code_chart/writer_spec.rb` — full lifecycle:
28
+ extract → write → summary. Idempotent re-run produces byte-identical
29
+ files. (TODO 06)
30
+ - `spec/ucode/cli_spec.rb` (extend) — verify `ucode code-chart fetch`,
31
+ `extract`, `list` wire up. (TODO 07)
32
+
33
+ ## Design
34
+
35
+ ### Fixture strategy
36
+
37
+ The existing `spec/fixtures/pdfs/basic_latin.pdf` is the only PDF
38
+ fixture in the repo. It's tiny and validates the integration path.
39
+ The Sidetic + Egyptian Ext-B PDFs are large (whole-block) and would
40
+ inflate the repo. The integration spec uses `basic_latin.pdf` to
41
+ exercise the full pipeline; per-codepoint assertions cover
42
+ representative cases.
43
+
44
+ If Sidetic-specific behavior must be tested, a smaller fixture PDF
45
+ cropped to ~5 codepoints would be the right tool — out of scope for
46
+ this TODO.
47
+
48
+ ### No doubles policy
49
+
50
+ The project's `~/.claude/CLAUDE.md` rule: no doubles. All specs use
51
+ real instances:
52
+ - `Ucode::Models::Block.new(...)` for test blocks.
53
+ - A temp directory + real `Blocks.txt` text for parser specs.
54
+ - The real `Ucode::Glyphs::Resolver` for extractor specs.
55
+
56
+ ### Idempotency assertion pattern
57
+
58
+ `Writer#write` idempotency is asserted via byte-equality:
59
+
60
+ ```ruby
61
+ first_run = writer.write(block)
62
+ first_size = File.stat(svg_path).size
63
+ sleep 0.01 # ensure mtime changes would be detectable
64
+ second_run = writer.write(block)
65
+ second_size = File.stat(svg_path).size
66
+ expect(second_size).to eq(first_size)
67
+ expect(File.read(svg_path)).to eq(expected_svg_bytes)
68
+ ```
69
+
70
+ This is the existing pattern from `spec/ucode/repo/aggregate_writer_spec.rb`
71
+ (idiempotency spec there). Reuse.
72
+
73
+ ## Acceptance
74
+
75
+ - `bundle exec rspec spec/ucode/code_chart/ spec/ucode/fetch/code_charts_spec.rb spec/ucode/parsers/blocks_spec.rb`
76
+ passes 100%.
77
+ - Coverage for the new files is ≥ 95% (per the project's per-file
78
+ floor of 30% + the overall 80% minimum).
79
+ - No doubles are introduced (verify with `grep -r "double(" spec/ucode/code_chart/`).
80
+ - The integration spec exercises both the Extractor and Writer
81
+ together end-to-end.
82
+
83
+ ## Out of scope
84
+
85
+ - Performance benchmarks — separate concern.
86
+ - Sidetic-specific fixtures — requires PDF curation beyond the
87
+ scope of this feature.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  unicode_version: 17.0.0
3
- ucode_version: 0.1.1
3
+ ucode_version: 0.2.1
4
4
  generated_at: '2026-06-28T00:00:00Z'
5
5
  default_sources:
6
6
  - kind: fontist
@@ -63,4 +63,4 @@ module Ucode
63
63
  end
64
64
  end
65
65
  end
66
- end
66
+ end
data/lib/ucode/cli.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "thor"
4
4
 
5
5
  require "ucode/commands"
6
+ require "ucode/code_chart"
6
7
  require "ucode/version_resolver"
7
8
 
8
9
  module Ucode
@@ -157,6 +158,106 @@ module Ucode
157
158
  desc "site", "Generate the Vitepress site"
158
159
  subcommand "site", Site
159
160
 
161
+ # ─────────────── code-chart ───────────────
162
+ # Extract per-codepoint SVG glyphs from a Unicode Code Charts PDF.
163
+ # One folder per block under --to, with <U+XXXX>.svg + .json pairs.
164
+ class CodeChartCmd < Thor
165
+ desc "fetch --block BLOCK [VERSION]", "Download the Code Charts PDF for a block"
166
+ option :block, type: :string, required: true,
167
+ desc: "Block identifier (e.g. Sidetic, Basic_Latin)"
168
+ def fetch(version = nil)
169
+ with_codechart_errors do
170
+ block_first_cp = resolve_block_first_cp!(options[:block], version)
171
+ result = Commands::FetchCommand.new.fetch_charts(
172
+ VersionResolver.resolve(version),
173
+ block_first_cps: [block_first_cp],
174
+ )
175
+ puts JSON.pretty_generate(result)
176
+ end
177
+ end
178
+
179
+ desc "extract --block BLOCK --to DIR [VERSION]",
180
+ "Extract per-codepoint SVG + provenance sidecars from a Code Charts PDF"
181
+ option :block, type: :string, required: true,
182
+ desc: "Block identifier (e.g. Sidetic)"
183
+ option :to, type: :string, required: true,
184
+ desc: "Output directory (will contain <block_id>/<U+XXXX>.svg + .json)"
185
+ def extract(version = nil)
186
+ with_codechart_errors do
187
+ version_str = VersionResolver.resolve(version)
188
+ block = resolve_block!(options[:block], version_str)
189
+ block_first_cp = block.range_first
190
+
191
+ # Download (idempotent — re-runs skip when the PDF is cached).
192
+ Commands::FetchCommand.new.fetch_charts(version_str, block_first_cps: [block_first_cp])
193
+
194
+ pdf = Ucode::Glyphs::PdfFetcher.new(version_str)
195
+ .fetch(block_first_cp: block_first_cp)
196
+ unless pdf
197
+ raise Ucode::CodeChartNotFoundError.new(
198
+ "Code Charts PDF unavailable for block #{block.id.inspect}",
199
+ context: { block_id: block.id, version: version_str },
200
+ )
201
+ end
202
+
203
+ writer = Ucode::CodeChart::Writer.new(
204
+ output_root: Pathname.new(options[:to]),
205
+ pdf_path: pdf,
206
+ ucd_version: version_str,
207
+ )
208
+ summary = writer.write(block)
209
+ puts JSON.pretty_generate(summary.to_h.compact)
210
+ end
211
+ end
212
+
213
+ desc "list", "List cached Code Charts PDFs under the version's cache"
214
+ def list
215
+ version = VersionResolver.resolve(nil)
216
+ pdfs_dir = Ucode::Cache.pdfs_dir(version)
217
+ files = pdfs_dir.exist? ? pdfs_dir.children.sort : []
218
+ if files.empty?
219
+ puts "(no cached Code Charts PDFs)"
220
+ return
221
+ end
222
+ files.each do |f|
223
+ puts f.basename
224
+ end
225
+ end
226
+
227
+ private
228
+
229
+ # Resolve a block name to its first codepoint via the cached
230
+ # Blocks.txt. Raises {Ucode::UnknownBlockError} on miss.
231
+ def resolve_block!(block_id, version)
232
+ blocks_txt = Ucode::Cache.ucd_dir(VersionResolver.resolve(version)).join("Blocks.txt")
233
+ Ucode::Parsers::Blocks.find_by_id!(blocks_txt, block_id)
234
+ end
235
+
236
+ def resolve_block_first_cp!(block_id, version)
237
+ resolve_block!(block_id, version).range_first
238
+ end
239
+
240
+ # Convert semantic Ucode errors into Thor errors so Thor's
241
+ # dispatch prints the message cleanly instead of a stack trace.
242
+ # Thor's `start` rescues only `Thor::Error`; without this bridge,
243
+ # any `Ucode::Error` subclass propagates as an uncaught exception.
244
+ def with_codechart_errors
245
+ yield
246
+ rescue Ucode::Error => e
247
+ raise Thor::Error, e.message
248
+ end
249
+ end
250
+
251
+ # Register the subcommand under the underscored method name
252
+ # (`code_chart`). Thor's `normalize_command_name` converts the
253
+ # user's hyphenated form (`code-chart`) to the underscored form
254
+ # before lookup, so `ucode code-chart <cmd>` dispatches correctly.
255
+ # `desc` first registers the method as a Thor command so the
256
+ # dispatch table has an entry; `subcommand` then attaches the
257
+ # CodeChartCmd class to it.
258
+ desc "code_chart <command>", "Extract SVG glyphs from Unicode Code Charts PDFs"
259
+ subcommand "code_chart", CodeChartCmd
260
+
160
261
  # ─────────────── lookup ───────────────
161
262
  class Lookup < Thor
162
263
  desc "block CODEPOINT", "Block name covering CODEPOINT (integer or 0xNNNN)"