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.
- checksums.yaml +4 -4
- data/Gemfile +10 -2
- data/README.md +66 -20
- data/Rakefile +19 -8
- 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/config/unicode17_universal_glyph_set.yml +1 -1
- data/lib/ucode/audit/reference_factory.rb +1 -1
- data/lib/ucode/cli.rb +101 -0
- data/lib/ucode/code_chart/extractor.rb +120 -0
- data/lib/ucode/code_chart/provenance.rb +82 -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/fetch.rb +1 -1
- data/lib/ucode/commands/glyphs.rb +1 -1
- data/lib/ucode/commands/lookup.rb +1 -1
- data/lib/ucode/commands/parse.rb +1 -1
- data/lib/ucode/coordinator/indices.rb +2 -2
- data/lib/ucode/error.rb +11 -0
- data/lib/ucode/fetch/code_charts.rb +3 -4
- data/lib/ucode/fetch/http.rb +75 -7
- data/lib/ucode/glyphs/page_renderer.rb +15 -2
- data/lib/ucode/glyphs/pipeline.rb +1 -2
- data/lib/ucode/parsers/blocks.rb +34 -0
- data/lib/ucode/repo/aggregate_writer.rb +1 -1
- data/lib/ucode/repo/writers/blocks_writer.rb +13 -13
- data/lib/ucode/repo/writers/enums_writer.rb +2 -2
- data/lib/ucode/repo/writers/indexes_writer.rb +4 -4
- data/lib/ucode/repo/writers/manifest_writer.rb +4 -4
- data/lib/ucode/repo/writers/named_sequences_writer.rb +1 -1
- data/lib/ucode/repo/writers/planes_writer.rb +17 -17
- data/lib/ucode/repo/writers/relationships_writer.rb +1 -1
- data/lib/ucode/repo/writers/scripts_writer.rb +6 -6
- data/lib/ucode/repo/writers.rb +1 -1
- data/lib/ucode/version.rb +1 -1
- data/lib/ucode.rb +3 -0
- data/ucode.gemspec +6 -1
- 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.
|
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)"
|