pubid 2.0.20 → 2.0.22
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/README.adoc +73 -31
- data/data/iec/legacy-notation.md +298 -0
- data/data/iho/s4-reserved-specs.yaml +15 -0
- data/data/iso/historic-schemes.md +368 -0
- data/data/parg/bipm.json +1 -1
- data/data/parg/bipm.parg +46 -11
- data/data/parg/iec.json +1 -1
- data/data/parg/iho.json +1 -1
- data/data/parg/iho.parg +128 -11
- data/data/parg/iso.json +1 -1
- data/data/parg/iso.parg +142 -2
- data/data/parg/plateau.json +1 -1
- data/data/parg/tables/iec_typed_stages.yaml +3 -0
- data/data/parg/tables/typed_stages_ballots.yaml +6 -0
- data/data/parg/tables/typed_stages_supplements.yaml +1 -0
- data/lib/pubid/bipm/CLAUDE.md +6 -1
- data/lib/pubid/bipm/builder.rb +20 -1
- data/lib/pubid/bipm/renderer.rb +14 -2
- data/lib/pubid/bipm/urn_generator.rb +80 -14
- data/lib/pubid/bipm/urn_parser.rb +70 -6
- data/lib/pubid/ecma/CLAUDE.md +12 -0
- data/lib/pubid/ecma/identifier.rb +1 -2
- data/lib/pubid/ecma/parser.rb +49 -0
- data/lib/pubid/iec/CLAUDE.md +9 -1
- data/lib/pubid/iec/builder.rb +47 -0
- data/lib/pubid/iec/identifier.rb +19 -70
- data/lib/pubid/iec/identifiers/amendment.rb +34 -0
- data/lib/pubid/iec/identifiers/corrigendum.rb +6 -1
- data/lib/pubid/iec/identifiers/fragment_identifier.rb +3 -3
- data/lib/pubid/iec/single_identifier.rb +69 -4
- data/lib/pubid/iec/supplement_identifier.rb +89 -83
- data/lib/pubid/iho/CLAUDE.md +54 -1
- data/lib/pubid/iho/builder.rb +105 -2
- data/lib/pubid/iho/identifiers/base.rb +52 -1
- data/lib/pubid/iho/pubid_urn_schema.rb +12 -4
- data/lib/pubid/iho/renderer.rb +39 -2
- data/lib/pubid/iho/reserved_specs.rb +54 -0
- data/lib/pubid/iho/urn_generator.rb +6 -0
- data/lib/pubid/iho/urn_parser.rb +50 -9
- data/lib/pubid/iho.rb +1 -0
- data/lib/pubid/iso/CLAUDE.md +49 -0
- data/lib/pubid/iso/builder.rb +77 -0
- data/lib/pubid/iso/identifiers/amendment.rb +1 -1
- data/lib/pubid/iso/single_identifier.rb +7 -1
- data/lib/pubid/nist/CLAUDE.md +49 -0
- data/lib/pubid/nist/builder.rb +5 -0
- data/lib/pubid/nist/identifiers/base.rb +42 -1
- data/lib/pubid/nist/identifiers/federal_information_processing_standards.rb +44 -5
- data/lib/pubid/nist/preprocessor.rb +18 -0
- data/lib/pubid/nist/urn_parser.rb +1 -1
- data/lib/pubid/plateau/CLAUDE.md +35 -0
- data/lib/pubid/plateau/builder.rb +4 -0
- data/lib/pubid/plateau/identifiers/handbook.rb +10 -2
- data/lib/pubid/plateau/pubid_urn_schema.rb +1 -0
- data/lib/pubid/plateau/renderer.rb +6 -1
- data/lib/pubid/sae/CLAUDE.md +11 -2
- data/lib/pubid/version.rb +1 -1
- metadata +7 -2
|
@@ -4,6 +4,11 @@ module Pubid
|
|
|
4
4
|
module Iec
|
|
5
5
|
# Identifier that represents a supplement to a base identifier.
|
|
6
6
|
class SupplementIdentifier < SingleIdentifier
|
|
7
|
+
# The supplement number a legacy standalone face implies when its
|
|
8
|
+
# type token carries none ("IEC/DCOR 60038-1" is Corrigendum 1 of
|
|
9
|
+
# 60038-1); the owner's no-number rule.
|
|
10
|
+
LEGACY_STANDALONE_SUPPLEMENT_NUMBER = "1"
|
|
11
|
+
|
|
7
12
|
attribute :base, Identifier, polymorphic: true
|
|
8
13
|
|
|
9
14
|
# Serialize the supplemented document under "base". The supplement's own
|
|
@@ -22,8 +27,9 @@ module Pubid
|
|
|
22
27
|
# (e.g. a bare "+AMD1:2008" nested in a consolidation): a fake doc whose
|
|
23
28
|
# number equals the supplement's own. It renders nothing and the getter
|
|
24
29
|
# recreates it identically on load, so don't persist it. A standalone
|
|
25
|
-
# supplement ("IEC/
|
|
26
|
-
# the
|
|
30
|
+
# supplement in the legacy prefix face ("IEC/DCOR 60038-1") instead
|
|
31
|
+
# holds the real base designation (base.number != own number), which
|
|
32
|
+
# must be kept.
|
|
27
33
|
return if model.synthetic_base? && base.number.to_s == model.number.to_s
|
|
28
34
|
|
|
29
35
|
doc.add_child(Lutaml::KeyValue::DataModel::Element.new("base",
|
|
@@ -33,26 +39,11 @@ module Pubid
|
|
|
33
39
|
def base_from_kv(model, value)
|
|
34
40
|
return unless value
|
|
35
41
|
|
|
36
|
-
base
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
# is always a concrete type (InternationalStandard, etc.). Re-engage the
|
|
42
|
-
# synthetic-base state so the supplement renders in standalone form
|
|
43
|
-
# (publisher/TYPE base-number-supp-number) instead of attached form.
|
|
44
|
-
if base.instance_of?(::Pubid::Iec::SingleIdentifier)
|
|
45
|
-
model.mark_synthetic_standalone!
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Re-engage the standalone synthetic-base state on a deserialized
|
|
50
|
-
# supplement. At this point `number` already holds the supplement number
|
|
51
|
-
# (set from the "number" key before "base"), so preserve it as the
|
|
52
|
-
# supplement number and flag the synthetic base.
|
|
53
|
-
def mark_synthetic_standalone!
|
|
54
|
-
@synthetic_base = true
|
|
55
|
-
@supplement_number = @number
|
|
42
|
+
# A bare SingleIdentifier here is the synthetic base of a standalone
|
|
43
|
+
# supplement; #synthetic_base? reads that class, so nothing else needs
|
|
44
|
+
# restoring. "number" already holds the supplement number, which is
|
|
45
|
+
# what ensure_base stores on the parse path too.
|
|
46
|
+
model.base = ::Pubid::Iec::Identifier.from_hash(value)
|
|
56
47
|
end
|
|
57
48
|
|
|
58
49
|
# Override base getter to ensure it's always created for standalone supplements
|
|
@@ -60,29 +51,22 @@ module Pubid
|
|
|
60
51
|
@base || ensure_base
|
|
61
52
|
end
|
|
62
53
|
|
|
63
|
-
#
|
|
54
|
+
# A standalone supplement written in the legacy prefix face holds the
|
|
55
|
+
# grammar's layout (the whole base designation on itself) until
|
|
56
|
+
# #ensure_base moves it. Not every construction path goes through one
|
|
57
|
+
# builder branch ("IEC DCOR 60038-1" reaches the typed branch, a
|
|
58
|
+
# hand-built Amendment none), so these readers make the move before
|
|
59
|
+
# they answer: the result never depends on which reader ran first, and
|
|
60
|
+
# #exclude, which copies through the readers, copies the moved layout.
|
|
61
|
+
# They only TRIGGER the move; they return the stored value.
|
|
64
62
|
def number
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
# For standalone supplements, trigger base creation first
|
|
69
|
-
ensure_base if @synthetic_base.nil? && publisher && part
|
|
70
|
-
|
|
71
|
-
if @synthetic_base
|
|
72
|
-
@supplement_number || @number
|
|
73
|
-
else
|
|
74
|
-
super
|
|
75
|
-
end
|
|
63
|
+
settle_standalone_layout
|
|
64
|
+
super
|
|
76
65
|
end
|
|
77
66
|
|
|
78
|
-
# Override part getter for standalone supplements (part becomes nil after swap)
|
|
79
67
|
def part
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
nil
|
|
83
|
-
else
|
|
84
|
-
super
|
|
85
|
-
end
|
|
68
|
+
settle_standalone_layout
|
|
69
|
+
super
|
|
86
70
|
end
|
|
87
71
|
|
|
88
72
|
# Delegate publisher and copublishers to base whenever there is
|
|
@@ -102,59 +86,75 @@ module Pubid
|
|
|
102
86
|
base ? base.copublishers : @copublishers
|
|
103
87
|
end
|
|
104
88
|
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
89
|
+
# Synthesise the base of a supplement that names none, and return it.
|
|
90
|
+
#
|
|
91
|
+
# A standalone supplement written in the legacy prefix face ("IEC/DCOR
|
|
92
|
+
# 60038-1", "IEC/DISH 60038-1") arrives from the grammar with the BASE
|
|
93
|
+
# designation stored on itself — the base number in `number`, the base
|
|
94
|
+
# part (and subpart) in `part`/`subpart`. That layout is the grammar's
|
|
95
|
+
# bound shape, not the identity: the face names a supplement OF that
|
|
96
|
+
# designation, so the designation moves WHOLE onto a concrete
|
|
97
|
+
# InternationalStandard base (publisher, copublishers and the date go
|
|
98
|
+
# with it — old notation prints the base's year, and a draft
|
|
99
|
+
# supplement carries no publication date of its own) and the
|
|
100
|
+
# supplement keeps a number of its own, defaulting to 1 — the
|
|
101
|
+
# no-number rule (an unnumbered draft face means
|
|
102
|
+
# Amendment/Corrigendum/Interpretation-Sheet 1; owner ruling
|
|
103
|
+
# 2026-11-12, reversing #693's part-to-number move, which stole the
|
|
104
|
+
# base's part for the supplement). The concrete base is what makes the
|
|
105
|
+
# decode hash-match (and equal) the modern on-base parse of the same
|
|
106
|
+
# document: `parse("IEC/DCOR 60038-1") == parse("IEC 60038-1/DCOR1")`.
|
|
107
|
+
#
|
|
108
|
+
# A supplement with no part (a true standalone like "IEC/AMD 1:2017",
|
|
109
|
+
# or a bare "+AMD1:2008" in a consolidation) keeps its own number and
|
|
110
|
+
# gets a synthetic self-base whose number equals it; its date stays
|
|
111
|
+
# its own.
|
|
112
|
+
#
|
|
113
|
+
# The base deliberately gets NO typed_stage. A stage without a code is
|
|
114
|
+
# not serialized, so `from_hash` cannot restore one, and a stage set
|
|
115
|
+
# here alone made `parse != from_hash(to_hash)` — `==` compares `base`
|
|
116
|
+
# (`lib/pubid/iec/CLAUDE.md`).
|
|
108
117
|
def ensure_base
|
|
109
118
|
return @base if @base
|
|
110
119
|
|
|
111
|
-
#
|
|
120
|
+
# publisher/copublishers read through `base`; this guard makes them
|
|
121
|
+
# read the supplement's own value while the base is being built.
|
|
112
122
|
@ensuring_base = true
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
base.subpart = subpart if subpart
|
|
133
|
-
base.date = date if date
|
|
134
|
-
|
|
135
|
-
# Create typed_stage for the base - use "IS" (International Standard) type
|
|
136
|
-
# not the supplement type
|
|
137
|
-
base.typed_stage = Pubid::Components::TypedStage.new(
|
|
138
|
-
abbr: ["IS"],
|
|
139
|
-
type_code: :is,
|
|
140
|
-
stage_code: :undated,
|
|
141
|
-
)
|
|
123
|
+
return nil unless publisher && (@number || @part)
|
|
124
|
+
|
|
125
|
+
if @part
|
|
126
|
+
base = Identifiers::InternationalStandard.new
|
|
127
|
+
base.publisher = publisher
|
|
128
|
+
base.copublishers = copublishers if copublishers&.any?
|
|
129
|
+
base.number = @number
|
|
130
|
+
base.part = @part
|
|
131
|
+
base.subpart = @subpart if @subpart
|
|
132
|
+
base.date = date if date
|
|
133
|
+
@number = LEGACY_STANDALONE_SUPPLEMENT_NUMBER
|
|
134
|
+
@part = nil
|
|
135
|
+
@date = nil
|
|
136
|
+
else
|
|
137
|
+
base = SingleIdentifier.new
|
|
138
|
+
base.publisher = publisher
|
|
139
|
+
base.number = @number if @number
|
|
140
|
+
base.date = date if date
|
|
141
|
+
end
|
|
142
142
|
|
|
143
143
|
@base = base
|
|
144
|
-
|
|
145
|
-
# Store the supplement number for getter override
|
|
146
|
-
# The original number is the base number (moved to base)
|
|
147
|
-
# The original part is the supplement number (what we want to return)
|
|
148
|
-
@supplement_number = supplement_number
|
|
149
|
-
|
|
150
|
-
@base
|
|
151
144
|
ensure
|
|
152
145
|
@ensuring_base = false
|
|
153
146
|
end
|
|
154
147
|
|
|
155
|
-
#
|
|
148
|
+
# True when the base is the bare SingleIdentifier that #ensure_base (or
|
|
149
|
+
# from_hash) synthesised for a standalone supplement; a real attached
|
|
150
|
+
# base is always a concrete type (InternationalStandard, SheetIdentifier
|
|
151
|
+
# …). Read from the base itself, so it survives #exclude, which copies
|
|
152
|
+
# `base` into a new object. A legacy prefix face's designation moves
|
|
153
|
+
# onto a CONCRETE InternationalStandard instead (see #ensure_base), so
|
|
154
|
+
# it renders in the attached, modern form and hash-matches the modern
|
|
155
|
+
# on-base parse of the same document.
|
|
156
156
|
def synthetic_base?
|
|
157
|
-
|
|
157
|
+
base.instance_of?(::Pubid::Iec::SingleIdentifier)
|
|
158
158
|
end
|
|
159
159
|
|
|
160
160
|
def to_s(**opts)
|
|
@@ -173,6 +173,12 @@ module Pubid
|
|
|
173
173
|
|
|
174
174
|
segments.join(".")
|
|
175
175
|
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
def settle_standalone_layout
|
|
180
|
+
ensure_base if @base.nil? && @part && !@ensuring_base
|
|
181
|
+
end
|
|
176
182
|
end
|
|
177
183
|
end
|
|
178
184
|
end
|
data/lib/pubid/iho/CLAUDE.md
CHANGED
|
@@ -96,6 +96,58 @@ The compact face (`compact: true`) folds the code-adjacent components
|
|
|
96
96
|
onto the code: supplement and part hyphenated, volume as the slash
|
|
97
97
|
chain — `IHO M-2-2`, `IHO S-100-2 5.0.0`, `IHO P-5/2024`.
|
|
98
98
|
|
|
99
|
+
## S-4 loose-leaf sections and specs (owner rulings, 2026-11-11)
|
|
100
|
+
|
|
101
|
+
The S-4 loose-leaf publication (Parts A/B/C → Sections 100-600, Part C
|
|
102
|
+
100-500 → Regulations/Specifications cited `<letter>-<hundreds><seq>`)
|
|
103
|
+
is modeled ONE COMPONENT DEEP, not two tiers: the spec number IS the
|
|
104
|
+
leaf. `B-401` binds the letter into `part` ("B") and the number into
|
|
105
|
+
`spec` ("401"); the SECTION (the hundreds digit) is DERIVED —
|
|
106
|
+
`#spec_section` (`B-401` → `"400"`) — never stored, never emitted, and
|
|
107
|
+
the `00` slot (B-400) IS the section-level citation, so `S-4 Part B,
|
|
108
|
+
Section 400` ≡ `IHO S-4 B-400` (one identity, two spellings).
|
|
109
|
+
|
|
110
|
+
| component | attribute | canonical face | parse-only forms |
|
|
111
|
+
|--------------|--------------|---------------------|-------------------------------------|
|
|
112
|
+
| spec | `spec` | `B-401` (with part) | the spelled section (", Section 400") |
|
|
113
|
+
| spec sub | `spec_sub` | `.1` | — |
|
|
114
|
+
| spec variant | `spec_variant` | `(I)` | — |
|
|
115
|
+
|
|
116
|
+
- **Faces**: canonical compact `IHO S-4 B-401` (dated:
|
|
117
|
+
`IHO S-4 4.10.0 B-401` — the edition precedes the citation, as it
|
|
118
|
+
precedes the part). The citation face REPLACES the Part face in the
|
|
119
|
+
renderer (`compact: true` is a no-op on it — it is already compact).
|
|
120
|
+
Subs and variants combine (`B-351.1(I)` is in the 4.9.0 text).
|
|
121
|
+
- **Three forms parse to one identity**: the anchored citation, the
|
|
122
|
+
LONG form `S-4 Part B, Section 400[, B-401]` (normalizing parse; the
|
|
123
|
+
spelled section becomes `spec` alone, and beside a citation it only
|
|
124
|
+
cross-checks the citation's derived section — "Section 500, B-401"
|
|
125
|
+
raises) and the BARE `B-401` (implicit S-4 anchor; B only — bare
|
|
126
|
+
C-x stays the Circular Letter series, and the 3-digit `[1-6]xx`
|
|
127
|
+
shape keeps the B Bibliographic series (B-12) on the main branch).
|
|
128
|
+
`IHO B-401` (publisher + bare) parses to the spec too.
|
|
129
|
+
- **The anchor is load-bearing**: a spec citation requires the S-4
|
|
130
|
+
anchor (series S number 4 — the builder raises otherwise), a
|
|
131
|
+
lettered Part (A/B/C, not a numbered part), and no component
|
|
132
|
+
editions or S-4-foreign components (supplement/annex/appendix/
|
|
133
|
+
volume/issue); the citation letter must agree with a Part-rule
|
|
134
|
+
capture.
|
|
135
|
+
- **Reserved numbers** (`B-170`, `B-230`, `B-305`, `B-323` — "NOT
|
|
136
|
+
CURRENTLY USED" in the Ed 4.9.0 and 4.10.0 contents) parse and
|
|
137
|
+
resolve AS RESERVED, never invalid: `#reserved?` answers from
|
|
138
|
+
`data/iho/s4-reserved-specs.yaml` (edition-keyed, provenance in the
|
|
139
|
+
file; an edition-less reference is reserved when ANY seeded edition
|
|
140
|
+
reserves the number). `lib/pubid/iho/reserved_specs.rb` is the
|
|
141
|
+
loader (`Pubid::Iho::ReservedSpecs`).
|
|
142
|
+
- **URN atoms**: the citation is a child of its part — the letter rides
|
|
143
|
+
the existing `part-` atom (case preserved: `part-B`, never the
|
|
144
|
+
lowercase `part-b` sketch), then `spec-`, `specsub-`, `specvar-`
|
|
145
|
+
(the `appsub-` naming convention; the `:sub-1`/`:variant-i` in the
|
|
146
|
+
ruling's sketch are these atoms). `urn:iho:s:4:ed-4.10.0:part-B:
|
|
147
|
+
spec-401`; the section is not emitted. No `ed-`/`year-` after a
|
|
148
|
+
spec node (UrnParseError — the spec rides its publication's
|
|
149
|
+
edition); `specsub-`/`specvar-` only after `spec-`.
|
|
150
|
+
|
|
99
151
|
## Identity semantics
|
|
100
152
|
|
|
101
153
|
- The components are identity-bearing in `==`/`matches?`; a bare
|
|
@@ -182,4 +234,5 @@ component with its own edition/year right after it (`sup-`, `part-`,
|
|
|
182
234
|
`parted-`, `appendix-`, `appsub-`, `vol-`, `issue-` …). Case is preserved
|
|
183
235
|
(`part-A`); the yearbook year is a volume (`vol-2024`, no `year-` value —
|
|
184
236
|
the legacy `y.` marker stays decodable in the official URN parser, never
|
|
185
|
-
emitted). The
|
|
237
|
+
emitted). The positional `urn:iho:` form of `UrnGenerator` stays on
|
|
238
|
+
`#to_urn` — pubid emits no `urn:mrn:iho:…` URN.
|
data/lib/pubid/iho/builder.rb
CHANGED
|
@@ -18,18 +18,34 @@ module Pubid
|
|
|
18
18
|
part_edition part_year supplement_edition
|
|
19
19
|
supplement_year annex_edition annex_year
|
|
20
20
|
appendix_edition appendix_year volume_edition
|
|
21
|
-
volume_year
|
|
21
|
+
volume_year
|
|
22
|
+
spec spec_sub spec_variant spec_part
|
|
23
|
+
spec_section].freeze
|
|
22
24
|
# The independently-publishable components' own editions; the
|
|
23
25
|
# base edition context is mutually exclusive with them (owner
|
|
24
26
|
# ruling on the two publication modes).
|
|
25
27
|
COMPONENT_EDITIONS = %i[part_edition supplement_edition annex_edition
|
|
26
28
|
appendix_edition volume_edition].freeze
|
|
29
|
+
# The S-4 anchor: the loose-leaf publication every spec citation
|
|
30
|
+
# resolves through (bare "B-401" carries it implicitly).
|
|
31
|
+
S4_TYPE = "S"
|
|
32
|
+
S4_NUMBER = "4"
|
|
33
|
+
# A citation letter is one of S-4's lettered Parts; the section
|
|
34
|
+
# vocabulary (100-600, Part C 100-500) is theirs.
|
|
35
|
+
SPEC_LETTERS = %w[A B C].freeze
|
|
36
|
+
# Components an S-4 spec citation cannot carry: the citation is a
|
|
37
|
+
# leaf of the Part, not a wrappable publication.
|
|
38
|
+
SPEC_FOREIGN_COMPONENTS = %i[supplement annex appendix
|
|
39
|
+
appendix_subnumber volume issue].freeze
|
|
27
40
|
|
|
28
41
|
def build(parsed)
|
|
29
42
|
hash = parsed.is_a?(Array) ? parsed.reduce({}, :merge) : parsed
|
|
30
43
|
attrs = stringified_attrs(hash)
|
|
44
|
+
resolve_spec_citation!(attrs)
|
|
45
|
+
type = type_letter_from(hash, attrs)
|
|
46
|
+
assert_spec_anchor!(attrs, type)
|
|
31
47
|
assert_edition_mode!(attrs)
|
|
32
|
-
klass = Pubid::Iho.identifier_klass_for_type_letter(
|
|
48
|
+
klass = Pubid::Iho.identifier_klass_for_type_letter(type)
|
|
33
49
|
klass.new(**attrs)
|
|
34
50
|
end
|
|
35
51
|
|
|
@@ -39,6 +55,93 @@ module Pubid
|
|
|
39
55
|
|
|
40
56
|
private
|
|
41
57
|
|
|
58
|
+
# The citation captures resolve onto the model (see the grammar
|
|
59
|
+
# header): the citation letter folds into `part` (a Part-rule
|
|
60
|
+
# capture must agree), and the long form's spelled section becomes
|
|
61
|
+
# the spec alone — beside a citation it only cross-checks the
|
|
62
|
+
# citation's derived section ("Part B, Section 400, B-401" stores
|
|
63
|
+
# spec 401; "Part B, Section 500, B-401" is rejected).
|
|
64
|
+
def resolve_spec_citation!(attrs)
|
|
65
|
+
letter = attrs.delete(:spec_part)
|
|
66
|
+
spelled = attrs.delete(:spec_section)
|
|
67
|
+
attrs[:part] = merge_citation_letter!(attrs[:part], letter) if letter
|
|
68
|
+
apply_spelled_section!(attrs, spelled)
|
|
69
|
+
assert_citation_shape!(attrs) if attrs[:spec]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def apply_spelled_section!(attrs, spelled)
|
|
73
|
+
return if spelled.nil?
|
|
74
|
+
return attrs[:spec] = spelled unless attrs[:spec]
|
|
75
|
+
|
|
76
|
+
return if spelled == spec_section_of(attrs[:spec])
|
|
77
|
+
|
|
78
|
+
raise ArgumentError,
|
|
79
|
+
"the spelled section #{spelled} disagrees with the " \
|
|
80
|
+
"citation #{attrs[:part]}-#{attrs[:spec]}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def spec_section_of(spec)
|
|
84
|
+
"#{spec[0]}00"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def merge_citation_letter!(part, letter)
|
|
88
|
+
return letter if part.nil? || part == letter
|
|
89
|
+
|
|
90
|
+
raise ArgumentError,
|
|
91
|
+
"the citation letter #{letter} conflicts with part #{part}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def assert_citation_shape!(attrs)
|
|
95
|
+
assert_citation_letter!(attrs)
|
|
96
|
+
assert_citation_leaf!(attrs)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def assert_citation_letter!(attrs)
|
|
100
|
+
return if SPEC_LETTERS.include?(attrs[:part])
|
|
101
|
+
|
|
102
|
+
raise ArgumentError,
|
|
103
|
+
"an S-4 spec citation needs a lettered Part (A/B/C), " \
|
|
104
|
+
"not #{attrs[:part].inspect}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The citation is a leaf of its Part: it carries no component
|
|
108
|
+
# edition and none of the wrappable components.
|
|
109
|
+
def assert_citation_leaf!(attrs)
|
|
110
|
+
if attrs[:part_edition] || attrs[:part_year]
|
|
111
|
+
raise ArgumentError,
|
|
112
|
+
"a spec citation rides the S-4 edition, not a part edition"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
foreign = attrs.slice(*SPEC_FOREIGN_COMPONENTS).compact.keys
|
|
116
|
+
return if foreign.empty?
|
|
117
|
+
|
|
118
|
+
raise ArgumentError,
|
|
119
|
+
"an S-4 spec citation is a leaf of its Part and cannot " \
|
|
120
|
+
"carry #{foreign.join(', ')}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The series letter: a bare citation branch parse has no series
|
|
124
|
+
# capture, and its anchor is implicitly the S-4 publication (the
|
|
125
|
+
# number binds with it).
|
|
126
|
+
def type_letter_from(hash, attrs)
|
|
127
|
+
type = stringify(hash[:type])
|
|
128
|
+
return type if type
|
|
129
|
+
|
|
130
|
+
raise ArgumentError, "unknown IHO series" unless attrs[:spec]
|
|
131
|
+
|
|
132
|
+
attrs[:number] = S4_NUMBER
|
|
133
|
+
S4_TYPE
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def assert_spec_anchor!(attrs, type)
|
|
137
|
+
return unless attrs[:spec]
|
|
138
|
+
return if type == S4_TYPE && attrs[:number] == S4_NUMBER
|
|
139
|
+
|
|
140
|
+
raise ArgumentError,
|
|
141
|
+
"an S-4 spec citation requires the S-4 anchor: " \
|
|
142
|
+
"#{type}-#{attrs[:number]} cannot carry spec #{attrs[:spec]}"
|
|
143
|
+
end
|
|
144
|
+
|
|
42
145
|
# The two publication modes are mutually exclusive: an identifier
|
|
43
146
|
# is either within-edition (a base edition its components ride) or
|
|
44
147
|
# separately-published (a component carries its own edition and
|
|
@@ -8,6 +8,11 @@ module Pubid
|
|
|
8
8
|
# IHO identifiers have the form:
|
|
9
9
|
# IHO {S|P|M|B|C}-{number}[ {edition}| Vol. N [(issue)]]
|
|
10
10
|
# [ Sup N][ Part X][ Annex A][ Appendix B[-2]] [(2024)]
|
|
11
|
+
# The S-4 loose-leaf publication adds the spec citation
|
|
12
|
+
# IHO S-4 [edition] <letter>-<hundreds><seq>[.sub][(variant)]
|
|
13
|
+
# ("IHO S-4 B-401", the long form "S-4 Part B, Section 400" and the
|
|
14
|
+
# bare "B-401" all parse to it; `spec` stores the leaf number, the
|
|
15
|
+
# citation letter rides `part`, the section is a derived reader).
|
|
11
16
|
#
|
|
12
17
|
# Every tail component is a first-class semantic component: a typed
|
|
13
18
|
# attribute, independently addressable, URN-encodable and matchable.
|
|
@@ -83,6 +88,15 @@ module Pubid
|
|
|
83
88
|
attribute :issue, :string
|
|
84
89
|
attribute :volume_edition, :string
|
|
85
90
|
attribute :volume_year, :string
|
|
91
|
+
# The S-4 loose-leaf spec citation (issue #691): `spec` is the leaf
|
|
92
|
+
# number ("401" of B-401), the citation letter rides `part` (the
|
|
93
|
+
# builder folds it in), the dotted sub and the parenthesized
|
|
94
|
+
# variant keep their own components. The SECTION (the hundreds
|
|
95
|
+
# digit) is never stored — a derived reader — and the `00` slot
|
|
96
|
+
# (B-400) IS the section-level citation.
|
|
97
|
+
attribute :spec, :string
|
|
98
|
+
attribute :spec_sub, :string
|
|
99
|
+
attribute :spec_variant, :string
|
|
86
100
|
|
|
87
101
|
# Polymorphic type map for lutaml::Model key_value serialization: maps
|
|
88
102
|
# each subclass's polymorphic_name to its class name so `from_hash` can
|
|
@@ -122,6 +136,9 @@ module Pubid
|
|
|
122
136
|
map "volume_edition", to: :volume_edition
|
|
123
137
|
map "volume_year", to: :volume_year
|
|
124
138
|
map "issue", to: :issue
|
|
139
|
+
map "spec", to: :spec
|
|
140
|
+
map "spec_sub", to: :spec_sub
|
|
141
|
+
map "spec_variant", to: :spec_variant
|
|
125
142
|
end
|
|
126
143
|
|
|
127
144
|
# from_hash is the shared polymorphic dispatch on Pubid::Identifier.
|
|
@@ -139,9 +156,40 @@ module Pubid
|
|
|
139
156
|
args = args.dup
|
|
140
157
|
args << :issue if args.include?(:volume)
|
|
141
158
|
args << :appendix_subnumber if args.include?(:appendix)
|
|
159
|
+
# The spec citation is the leaf: a reference that omits the spec
|
|
160
|
+
# nils its sub and variant with it.
|
|
161
|
+
args.push(:spec_sub, :spec_variant) if args.include?(:spec)
|
|
142
162
|
super
|
|
143
163
|
end
|
|
144
164
|
|
|
165
|
+
# The citation letter of an S-4 spec (nil unless the identifier IS
|
|
166
|
+
# a spec citation): the part carries the letter, the same way the
|
|
167
|
+
# long form spells it ("Part B" of "B-401").
|
|
168
|
+
def spec_part
|
|
169
|
+
spec && part
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# The section an S-4 spec belongs to: the hundreds digit of the
|
|
173
|
+
# spec number, zero-padded (B-401 -> "400"; B-400 derives its own
|
|
174
|
+
# section). DERIVED, never stored, never emitted — the owner
|
|
175
|
+
# ruling keeps the spec number the leaf.
|
|
176
|
+
def spec_section
|
|
177
|
+
return nil unless spec
|
|
178
|
+
|
|
179
|
+
"#{spec[0]}00"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# True when the spec number is reserved ("NOT CURRENTLY USED") in
|
|
183
|
+
# the edition's S-4 contents (data/iho/s4-reserved-specs.yaml). An
|
|
184
|
+
# edition-less reference is reserved when any seeded edition
|
|
185
|
+
# reserves the number. A reserved citation parses and resolves AS
|
|
186
|
+
# RESERVED, never invalid (issue #691).
|
|
187
|
+
def reserved?
|
|
188
|
+
return false unless spec
|
|
189
|
+
|
|
190
|
+
ReservedSpecs.reserved?(edition, part, spec)
|
|
191
|
+
end
|
|
192
|
+
|
|
145
193
|
# Render the compact face: the code-adjacent components folded onto
|
|
146
194
|
# the code with separators — the supplement and the part hyphenated
|
|
147
195
|
# (`IHO M-2-2` for `IHO M-2 Sup 2`, `IHO S-5-B` for `IHO S-5 Part B`,
|
|
@@ -153,7 +201,10 @@ module Pubid
|
|
|
153
201
|
def render(format: :human, **opts)
|
|
154
202
|
compact = opts.delete(:compact)
|
|
155
203
|
result = super
|
|
156
|
-
|
|
204
|
+
# A spec citation's canonical face IS the compact form (the
|
|
205
|
+
# citation is already folded onto the code: "IHO S-4 B-401"), so
|
|
206
|
+
# the compact flag is a no-op for it.
|
|
207
|
+
compact && format == :human && !spec ? compact_face(result) : result
|
|
157
208
|
end
|
|
158
209
|
|
|
159
210
|
# The shared mr_string path reads `edition&.number` (the base
|
|
@@ -13,10 +13,12 @@ module Pubid
|
|
|
13
13
|
# edition/year right after it — supplement, part, annex, appendix (the
|
|
14
14
|
# two-level Appendix D-2 keeps the subnumber as its own atom), volume
|
|
15
15
|
# (the unified locator, yearbook volumes included: `vol-2024`), issue.
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
16
|
+
# The S-4 spec citation sits inside its part (`spec-`, `specsub-`,
|
|
17
|
+
# `specvar-`). Letters stay case-preserved (`part-A`). The legacy
|
|
18
|
+
# `y.` yearbook marker is decodable in the official URN parser, never
|
|
19
|
+
# emitted here: a periodical has no `year-` atom value, its volume
|
|
20
|
+
# carries the year. The positional urn:iho:… form of UrnGenerator
|
|
21
|
+
# stays on #to_urn — pubid emits no urn:mrn:iho:… URN.
|
|
20
22
|
PubidUrnSchema = Pubid::PubidUrn.define("iho") do
|
|
21
23
|
atom "edition", "ed"
|
|
22
24
|
atom "year", "year"
|
|
@@ -26,6 +28,12 @@ module Pubid
|
|
|
26
28
|
atom "part", "part"
|
|
27
29
|
atom "part_edition", "parted"
|
|
28
30
|
atom "part_year", "party"
|
|
31
|
+
# The S-4 loose-leaf spec citation (issue #691): a child of the
|
|
32
|
+
# part (the citation letter rides `part-`); the section is
|
|
33
|
+
# derivable from the spec's hundreds digit — never an atom.
|
|
34
|
+
atom "spec", "spec"
|
|
35
|
+
atom "spec_sub", "specsub"
|
|
36
|
+
atom "spec_variant", "specvar"
|
|
29
37
|
atom "annex", "annex"
|
|
30
38
|
atom "annex_edition", "anned"
|
|
31
39
|
atom "annex_year", "annyear"
|
data/lib/pubid/iho/renderer.rb
CHANGED
|
@@ -19,6 +19,13 @@ module Pubid
|
|
|
19
19
|
# beside an edition is the date
|
|
20
20
|
# qualifier on standards,
|
|
21
21
|
# parenthesized — the only year face
|
|
22
|
+
# "IHO S-4 B-401" the S-4 spec citation (the long
|
|
23
|
+
# form "S-4 Part B, Section 400"
|
|
24
|
+
# parses to the same identity and
|
|
25
|
+
# renders compact: the citation
|
|
26
|
+
# replaces the Part face; the
|
|
27
|
+
# section is derivable, never
|
|
28
|
+
# printed)
|
|
22
29
|
#
|
|
23
30
|
# The renderer is registered as the `:human` format in the IHO format
|
|
24
31
|
# registry and invoked via `render(format: :human)`; the compact face
|
|
@@ -32,12 +39,17 @@ module Pubid
|
|
|
32
39
|
# edition (+ parenthesized year) right after it; the appendix joins
|
|
33
40
|
# its subnumber; the issue rides parenthesized after its volume;
|
|
34
41
|
# the base year prints only as the parenthesized with-year
|
|
35
|
-
# qualifier.
|
|
42
|
+
# qualifier. The S-4 spec citation face (" B-401") sits where the
|
|
43
|
+
# Part face sits — it REPLACES the Part face when a spec is
|
|
44
|
+
# present (the citation letter is the part), printing
|
|
45
|
+
# "<letter>-<number>" with the dotted sub and the parenthesized
|
|
46
|
+
# variant folded on.
|
|
36
47
|
FACES = [
|
|
37
48
|
[:edition, " %s"],
|
|
38
49
|
[:supplement, " Sup %s"], [:supplement_edition, " %s"],
|
|
39
50
|
[:supplement_year, " (%s)"],
|
|
40
51
|
[:part, " Part %s"], [:part_edition, " %s"], [:part_year, " (%s)"],
|
|
52
|
+
[:spec, " %s"],
|
|
41
53
|
[:annex, " Annex %s"], [:annex_edition, " %s"], [:annex_year, " (%s)"],
|
|
42
54
|
[:appendix, " Appendix %s"], [:appendix_subnumber, "-%s"],
|
|
43
55
|
[:appendix_edition, " %s"], [:appendix_year, " (%s)"],
|
|
@@ -50,11 +62,36 @@ module Pubid
|
|
|
50
62
|
id = @id
|
|
51
63
|
rendered = "#{id.publisher} #{id.class.type[:short]}-#{id.number}"
|
|
52
64
|
FACES.each do |component, face|
|
|
53
|
-
value = id
|
|
65
|
+
value = face_value(id, component)
|
|
54
66
|
rendered << (face % value) if value
|
|
55
67
|
end
|
|
56
68
|
rendered
|
|
57
69
|
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# nil = the face does not print. The citation face replaces the
|
|
74
|
+
# Part face (same containment slot: the citation letter IS the
|
|
75
|
+
# part), and it prints only on a spec citation.
|
|
76
|
+
def face_value(id, component)
|
|
77
|
+
if component == :spec
|
|
78
|
+
return nil unless id.spec
|
|
79
|
+
|
|
80
|
+
return spec_citation(id)
|
|
81
|
+
end
|
|
82
|
+
return nil if component == :part && id.spec
|
|
83
|
+
|
|
84
|
+
id.public_send(component)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# "B-401", "B-122.1", "B-521(I)", "B-351.1(I)" — the citation the
|
|
88
|
+
# S-4 contents and cross-references print.
|
|
89
|
+
def spec_citation(id)
|
|
90
|
+
citation = "#{id.part}-#{id.spec}"
|
|
91
|
+
citation << ".#{id.spec_sub}" if id.spec_sub
|
|
92
|
+
citation << "(#{id.spec_variant})" if id.spec_variant
|
|
93
|
+
citation
|
|
94
|
+
end
|
|
58
95
|
end
|
|
59
96
|
end
|
|
60
97
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Pubid
|
|
6
|
+
module Iho
|
|
7
|
+
# The S-4 reserved specification registry
|
|
8
|
+
# (data/iho/s4-reserved-specs.yaml): the "NOT CURRENTLY USED"
|
|
9
|
+
# numbers, edition-keyed (owner rulings of 2026-11-11, issue #691).
|
|
10
|
+
# {Reserved?} answers from this table — a reserved number parses and
|
|
11
|
+
# resolves AS RESERVED, never invalid.
|
|
12
|
+
module ReservedSpecs
|
|
13
|
+
DATA_PATH =
|
|
14
|
+
File.expand_path("../../../data/iho/s4-reserved-specs.yaml", __dir__)
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# True when the part-lettered spec number is reserved in the
|
|
18
|
+
# given S-4 edition. An edition-less reference is reserved when
|
|
19
|
+
# ANY seeded edition reserves the number: the number is out of
|
|
20
|
+
# circulation in every edition that names it.
|
|
21
|
+
def reserved?(edition, part, spec)
|
|
22
|
+
return false unless part && spec
|
|
23
|
+
|
|
24
|
+
if edition
|
|
25
|
+
specs_for(edition, part).include?(spec)
|
|
26
|
+
else
|
|
27
|
+
registry.values.any? do |parts|
|
|
28
|
+
specs_for_parts(parts, part).include?(spec)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The edition-keyed registry: edition => part letter => spec
|
|
34
|
+
# numbers.
|
|
35
|
+
def registry
|
|
36
|
+
@registry ||=
|
|
37
|
+
(YAML.safe_load_file(DATA_PATH) || {}).transform_values do |parts|
|
|
38
|
+
parts.transform_values { |specs| Array(specs) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def specs_for(edition, part)
|
|
45
|
+
specs_for_parts(registry.fetch(edition, {}), part)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def specs_for_parts(parts, part)
|
|
49
|
+
parts.fetch(part, [])
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|