pubid 2.0.3 → 2.0.5
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/data/bipm/update_codes.yaml +14 -0
- data/data/parg/iho.json +1 -1
- data/data/parg/iho.parg +358 -0
- data/data/parg/jis.json +1 -1
- data/data/parg/parg.json +1 -1
- data/data/parg/parg.parg +118 -0
- data/lib/pubid/bipm/CLAUDE.md +2 -0
- data/lib/pubid/bipm.rb +10 -4
- data/lib/pubid/bsi/CLAUDE.md +4 -0
- data/lib/pubid/bsi/single_identifier.rb +10 -1
- data/lib/pubid/ieee/builder.rb +6 -1
- data/lib/pubid/ietf/CLAUDE.md +2 -0
- data/lib/pubid/ietf.rb +5 -2
- data/lib/pubid/iho/CLAUDE.md +157 -81
- data/lib/pubid/iho/builder.rb +61 -16
- data/lib/pubid/iho/identifier.rb +2 -2
- data/lib/pubid/iho/identifiers/base.rb +134 -37
- data/lib/pubid/iho/renderer.rb +44 -22
- data/lib/pubid/iho/urn_generator.rb +56 -19
- data/lib/pubid/iho/urn_parser.rb +211 -40
- data/lib/pubid/iso/CLAUDE.md +32 -0
- data/lib/pubid/iso/urn_parser.rb +63 -6
- data/lib/pubid/jis/CLAUDE.md +9 -4
- data/lib/pubid/parg/backend.rb +1 -1
- data/lib/pubid/parg/model.rb +7 -2
- data/lib/pubid/parg.rb +13 -0
- data/lib/pubid/tgpp/CLAUDE.md +2 -0
- data/lib/pubid/tgpp.rb +11 -0
- data/lib/pubid/version.rb +1 -1
- metadata +8 -12
data/lib/pubid/iho/renderer.rb
CHANGED
|
@@ -4,33 +4,55 @@ module Pubid
|
|
|
4
4
|
module Iho
|
|
5
5
|
# Human-readable renderer for IHO identifiers.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
# "IHO S-
|
|
9
|
-
# "IHO
|
|
10
|
-
# "IHO S-100
|
|
11
|
-
# "IHO S-
|
|
12
|
-
# "IHO
|
|
13
|
-
#
|
|
7
|
+
# Canonical faces (owner rulings, 2026-11-03):
|
|
8
|
+
# "IHO S-100" undated (edition-non-specific)
|
|
9
|
+
# "IHO S-100 1.0.0" dated (edition-specific)
|
|
10
|
+
# "IHO S-100 5.0.0 Part 2" edition BEFORE the part
|
|
11
|
+
# "IHO S-98 1.0.0 Part A Annex B"
|
|
12
|
+
# "IHO S-122 1.0.0 Appendix D-2" canonical Appendix, subnumber
|
|
13
|
+
# joined by a dash
|
|
14
|
+
# "IHO M-2 Sup 1" canonical Sup (Suppl is an input
|
|
15
|
+
# alias)
|
|
16
|
+
# "IHO P-1 Vol. 30 (2)" volume-keyed series (P-1, P-6)
|
|
17
|
+
# "IHO P-5 Vol. 2024" the yearbook year IS a volume
|
|
18
|
+
# "IHO S-100 5.0.0 Part 2 (2024)" the with-year qualifier: a year
|
|
19
|
+
# beside an edition is the date
|
|
20
|
+
# qualifier on standards,
|
|
21
|
+
# parenthesized — the only year face
|
|
14
22
|
#
|
|
15
23
|
# The renderer is registered as the `:human` format in the IHO format
|
|
16
|
-
# registry and invoked via `render(format: :human)
|
|
24
|
+
# registry and invoked via `render(format: :human)`; the compact face
|
|
25
|
+
# (`IHO S-100-1 5.0.0`, `IHO M-2-2`, `IHO P-5/2024`) is the opt-in
|
|
26
|
+
# `compact: true` flag, and `with_publisher: false` drops the leading
|
|
27
|
+
# publisher as everywhere else. The legacy faces (glued lettered
|
|
28
|
+
# bases, Suppl, bare years) parse but are never rendered.
|
|
17
29
|
class Renderer < ::Pubid::Renderers::Base
|
|
30
|
+
# The canonical component faces in print order: edition BEFORE the
|
|
31
|
+
# part; each independently-publishable component carries its own
|
|
32
|
+
# edition (+ parenthesized year) right after it; the appendix joins
|
|
33
|
+
# its subnumber; the issue rides parenthesized after its volume;
|
|
34
|
+
# the base year prints only as the parenthesized with-year
|
|
35
|
+
# qualifier.
|
|
36
|
+
FACES = [
|
|
37
|
+
[:edition, " %s"],
|
|
38
|
+
[:supplement, " Sup %s"], [:supplement_edition, " %s"],
|
|
39
|
+
[:supplement_year, " (%s)"],
|
|
40
|
+
[:part, " Part %s"], [:part_edition, " %s"], [:part_year, " (%s)"],
|
|
41
|
+
[:annex, " Annex %s"], [:annex_edition, " %s"], [:annex_year, " (%s)"],
|
|
42
|
+
[:appendix, " Appendix %s"], [:appendix_subnumber, "-%s"],
|
|
43
|
+
[:appendix_edition, " %s"], [:appendix_year, " (%s)"],
|
|
44
|
+
[:volume, " Vol. %s"], [:issue, " (%s)"],
|
|
45
|
+
[:volume_edition, " %s"], [:volume_year, " (%s)"],
|
|
46
|
+
[:year, " (%s)"]
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
18
49
|
def render(context: nil, **opts)
|
|
19
50
|
id = @id
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
rendered << " Annex #{id.annex}" if id.annex
|
|
26
|
-
rendered << " Suppl #{id.supplement}" if id.supplement
|
|
27
|
-
# The grammar's tail picks exactly one discriminator: a dotted
|
|
28
|
-
# version, a year, or a volume (with an optional issue). The
|
|
29
|
-
# issue prints parenthesized; "Issue N" is an input alias only.
|
|
30
|
-
rendered << " #{id.version}" if id.version
|
|
31
|
-
rendered << " #{id.year}" if id.year
|
|
32
|
-
rendered << " Vol. #{id.volume}" if id.volume
|
|
33
|
-
rendered << " (#{id.issue})" if id.issue
|
|
51
|
+
rendered = "#{id.publisher} #{id.class.type[:short]}-#{id.number}"
|
|
52
|
+
FACES.each do |component, face|
|
|
53
|
+
value = id.public_send(component)
|
|
54
|
+
rendered << (face % value) if value
|
|
55
|
+
end
|
|
34
56
|
rendered
|
|
35
57
|
end
|
|
36
58
|
end
|
|
@@ -4,16 +4,33 @@ module Pubid
|
|
|
4
4
|
module Iho
|
|
5
5
|
# Generates URN strings for IHO identifiers.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# "IHO P-1 Vol. 30 (2)").
|
|
7
|
+
# The URN is the documented positional segment grammar (see the
|
|
8
|
+
# grammar header in pubid-grammar's grammars/iho.parg and the flavor
|
|
9
|
+
# notes): containment-ordered, one semantic atom per segment, every
|
|
10
|
+
# non-number component a marker-prefixed segment whose marker is the
|
|
11
|
+
# lowercased human-face token (edition follows ISO's ed- precedent):
|
|
13
12
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
13
|
+
# urn:iho:<type>:<number>[:ed-<x.y.z>][:sup-<n>][:part-<X>]
|
|
14
|
+
# [:annex-<A>][:appendix-<B>][:<sub>][:vol-<n>][:issue-<n>]
|
|
15
|
+
# [:year-<yyyy>]
|
|
16
|
+
#
|
|
17
|
+
# Containment: part contains annexes, annex contains appendices, the
|
|
18
|
+
# subnumber is a child atom of its appendix, the issue a child of its
|
|
19
|
+
# volume. Letters are case-preserved. The volume carries every serial
|
|
20
|
+
# locator — the yearbook year IS a volume (P-5 Vol. 2024 gives
|
|
21
|
+
# vol-2024), so a periodical identifier has no year segment; the
|
|
22
|
+
# year- segment exists only for the standards with-year qualifier.
|
|
23
|
+
# Both are marker-tagged so neither can collide with a colon-number
|
|
24
|
+
# segment (the number is not escaped and may span bare segments:
|
|
25
|
+
# "IHO S-158:100" gives "urn:iho:s:158:100").
|
|
26
|
+
#
|
|
27
|
+
# Examples:
|
|
28
|
+
# - urn:iho:s:44:ed-5.0.0 IHO S-44 5.0.0
|
|
29
|
+
# - urn:iho:s:100:ed-5.0.0:part-2:year-2024 IHO S-100 5.0.0 Part 2 (2024)
|
|
30
|
+
# - urn:iho:s:122:ed-1.0.0:appendix-D:2 IHO S-122 1.0.0 Appendix D-2
|
|
31
|
+
# - urn:iho:m:2:sup-1 IHO M-2 Sup 1
|
|
32
|
+
# - urn:iho:p:5:vol-2024 IHO P-5 Vol. 2024
|
|
33
|
+
# - urn:iho:p:1:vol-30:issue-2 IHO P-1 Vol. 30 (2)
|
|
17
34
|
class UrnGenerator
|
|
18
35
|
attr_reader :identifier
|
|
19
36
|
|
|
@@ -21,22 +38,42 @@ module Pubid
|
|
|
21
38
|
@identifier = identifier
|
|
22
39
|
end
|
|
23
40
|
|
|
41
|
+
# [component, marker] pairs in the containment/canonical-face
|
|
42
|
+
# order. A component edition/year emits right after its component
|
|
43
|
+
# (the ed- segment attaches to the node it follows, recording the
|
|
44
|
+
# separately-published mode). nil markers emit their component as
|
|
45
|
+
# a BARE atom (the appendix subnumber directly after its
|
|
46
|
+
# appendix).
|
|
47
|
+
SEGMENTS = [
|
|
48
|
+
[:edition, "ed"], [:year, "year"],
|
|
49
|
+
[:supplement, "sup"], [:supplement_edition, "ed"],
|
|
50
|
+
[:supplement_year, "year"],
|
|
51
|
+
[:part, "part"], [:part_edition, "ed"], [:part_year, "year"],
|
|
52
|
+
[:annex, "annex"], [:annex_edition, "ed"], [:annex_year, "year"],
|
|
53
|
+
[:appendix, "appendix"], [:appendix_subnumber, nil],
|
|
54
|
+
[:appendix_edition, "ed"], [:appendix_year, "year"],
|
|
55
|
+
[:volume, "vol"], [:issue, "issue"], [:volume_edition, "ed"],
|
|
56
|
+
[:volume_year, "year"]
|
|
57
|
+
].freeze
|
|
58
|
+
|
|
24
59
|
def generate
|
|
25
60
|
parts = ["urn", "iho"]
|
|
26
61
|
parts << identifier.class.type[:short].to_s.downcase
|
|
27
62
|
parts << identifier.number.to_s
|
|
28
|
-
parts
|
|
29
|
-
parts << "part.#{identifier.part}" if identifier.part
|
|
30
|
-
parts << "annex.#{identifier.annex}" if identifier.annex
|
|
31
|
-
parts << "suppl.#{identifier.supplement}" if identifier.supplement
|
|
32
|
-
# The grammar's tail picks exactly one discriminator; a volume
|
|
33
|
-
# may carry an issue beside it.
|
|
34
|
-
parts << "vol.#{identifier.volume}" if identifier.volume
|
|
35
|
-
parts << "issue.#{identifier.issue}" if identifier.issue
|
|
36
|
-
parts << identifier.version.to_s if identifier.version
|
|
37
|
-
parts << "y.#{identifier.year}" if identifier.year
|
|
63
|
+
parts.concat(component_segments)
|
|
38
64
|
parts.join(":")
|
|
39
65
|
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def component_segments
|
|
70
|
+
SEGMENTS.filter_map do |component, marker|
|
|
71
|
+
value = identifier.public_send(component)
|
|
72
|
+
next unless value
|
|
73
|
+
|
|
74
|
+
marker ? "#{marker}-#{value}" : value.to_s
|
|
75
|
+
end
|
|
76
|
+
end
|
|
40
77
|
end
|
|
41
78
|
end
|
|
42
79
|
end
|
data/lib/pubid/iho/urn_parser.rb
CHANGED
|
@@ -2,69 +2,240 @@
|
|
|
2
2
|
|
|
3
3
|
module Pubid
|
|
4
4
|
module Iho
|
|
5
|
-
# Parses IHO URNs back into identifiers
|
|
5
|
+
# Parses IHO URNs back into identifiers through the documented
|
|
6
|
+
# positional segment grammar (the same grammar the generator emits;
|
|
7
|
+
# the segment table lives in the grammar header and the flavor notes):
|
|
6
8
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
+
# urn:iho:<type>:<number>[:ed-<x.y.z>][:sup-<n>][:part-<X>]
|
|
10
|
+
# [:annex-<A>][:appendix-<B>][:<sub>][:vol-<n>][:issue-<n>]
|
|
11
|
+
# [:year-<yyyy>]
|
|
9
12
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
+
# Segment discipline (mirrors the ISO URN parser's strictness):
|
|
14
|
+
# - the docnumber occupies every leading bare number segment up to
|
|
15
|
+
# the first marker, so a colon number ("158:100") round-trips
|
|
16
|
+
# without escaping;
|
|
17
|
+
# - every other component is a marker-prefixed segment validated
|
|
18
|
+
# against its own value pattern — unknown, reordered or junk
|
|
19
|
+
# segments raise {Pubid::Errors::UrnParseError}, never
|
|
20
|
+
# mis-consume;
|
|
21
|
+
# - the appendix subnumber is a bare digit atom allowed only
|
|
22
|
+
# directly after its appendix segment (a year is always
|
|
23
|
+
# marker-tagged, so a bare digit there can only be the subnumber);
|
|
24
|
+
# - the issue is allowed only beside its volume and validated as
|
|
25
|
+
# digits.
|
|
13
26
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
27
|
+
# Legacy 2.0.x spellings remain decodable but are never emitted:
|
|
28
|
+
# the bare a.b.c edition segment, the dotted keys part./annex./
|
|
29
|
+
# suppl./ap./appendix./vol./issue. and the single-letter y. year tag
|
|
30
|
+
# (the #637 scheme the positional grammar replaces; the legacy
|
|
31
|
+
# appendix values stored the subnumber inside one string, "D-2",
|
|
32
|
+
# which decodes into the two components).
|
|
17
33
|
#
|
|
18
34
|
# Examples:
|
|
19
|
-
# - urn:iho:s:44:5.0.0
|
|
20
|
-
# - urn:iho:s:100:
|
|
21
|
-
# - urn:iho:s:
|
|
22
|
-
# - urn:iho:
|
|
23
|
-
# - urn:iho:p:
|
|
24
|
-
# - urn:iho:
|
|
25
|
-
# - urn:iho:p:1:vol.30:issue.2 → IHO P-1 Vol. 30 (2)
|
|
35
|
+
# - urn:iho:s:44:ed-5.0.0 → IHO S-44 5.0.0
|
|
36
|
+
# - urn:iho:s:100:ed-5.0.0:part-2 → IHO S-100 5.0.0 Part 2
|
|
37
|
+
# - urn:iho:s:122:ed-1.0.0:appendix-D:2 → IHO S-122 1.0.0 Appendix D-2
|
|
38
|
+
# - urn:iho:p:5:vol-2024 → IHO P-5 Vol. 2024
|
|
39
|
+
# - urn:iho:p:1:vol-30:issue-2 → IHO P-1 Vol. 30 (2)
|
|
40
|
+
# - urn:iho:s:158:100 → IHO S-158:100
|
|
26
41
|
class UrnParser < Pubid::UrnParser::Base
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"
|
|
42
|
+
TYPE_LETTERS = %w[s p m b c].freeze
|
|
43
|
+
|
|
44
|
+
# Marker => attribute, with the value pattern each marker's slot
|
|
45
|
+
# accepts. The patterns mirror the grammar's own production rules
|
|
46
|
+
# (upper-case letters are case-preserved; "part" allows the
|
|
47
|
+
# digits-then-letters compound like "4a" beside a lone letter; the
|
|
48
|
+
# volume takes any serial locator, yearbook years included).
|
|
49
|
+
MARKERS = {
|
|
50
|
+
"ed" => [:edition, /\A\d+\.\d+\.\d+\z/],
|
|
51
|
+
"sup" => [:supplement, /\A\d+\z/],
|
|
52
|
+
"part" => [:part, /\A(?:\d+[a-zA-Z]*|[A-Z]|[SPMBC]-\d+)\z/],
|
|
53
|
+
"annex" => [:annex, /\A[A-Z]\z/],
|
|
54
|
+
"appendix" => [:appendix, /\A(?:[A-Z]|\d+)\z/],
|
|
55
|
+
"vol" => [:volume, /\A\d+\z/],
|
|
56
|
+
"issue" => [:issue, /\A\d+\z/],
|
|
57
|
+
"year" => [:year, /\A(?:19|20)\d{2}\z/],
|
|
58
|
+
}.freeze
|
|
59
|
+
|
|
60
|
+
MARKER_RE = /\A(#{MARKERS.keys.join('|')})-(.+)\z/
|
|
61
|
+
|
|
62
|
+
# The positional markers: their attribute depends on the node they
|
|
63
|
+
# follow (the component-edition duality).
|
|
64
|
+
POSITIONAL_MARKERS = %i[edition year].freeze
|
|
65
|
+
|
|
66
|
+
# Legacy dotted keys (deprecated; decodable, never emitted).
|
|
67
|
+
# "ap" is the pre-positional appendix tag; "suppl" the pre-positional
|
|
68
|
+
# supplement tag; "y" the pre-positional year tag — under the
|
|
69
|
+
# serial-locator unification a year IS a volume, so the y. value
|
|
70
|
+
# decodes into the volume.
|
|
71
|
+
LEGACY_KEYS = {
|
|
72
|
+
"part" => :part,
|
|
73
|
+
"annex" => :annex,
|
|
74
|
+
"suppl" => :supplement,
|
|
75
|
+
"ap" => :appendix,
|
|
76
|
+
"appendix" => :appendix,
|
|
77
|
+
"vol" => :volume,
|
|
78
|
+
"issue" => :issue,
|
|
79
|
+
"y" => :volume,
|
|
36
80
|
}.freeze
|
|
37
81
|
|
|
38
|
-
|
|
82
|
+
LEGACY_RE = /\A([a-z]+)\.(.+)\z/
|
|
83
|
+
|
|
84
|
+
# A bare a.b.c segment is the legacy edition encoding.
|
|
85
|
+
LEGACY_EDITION_RE = /\A\d+\.\d+\.\d+\z/
|
|
86
|
+
SUBNUMBER_RE = /\A\d+\z/
|
|
87
|
+
# The docnumber's own charset: digits with : / - suffix runs.
|
|
88
|
+
NUMBER_RE = /\A\d+(?:[:\/-]\d+)*\z/
|
|
39
89
|
|
|
40
90
|
def parse_urn(urn)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
91
|
+
segments = split_parts(strip_namespace(urn))
|
|
92
|
+
type_token = read_type!(segments, urn)
|
|
93
|
+
attrs = { number: consume_number!(segments, urn) }
|
|
94
|
+
# Positional context: the component the upcoming ed-/year- would
|
|
95
|
+
# attach to, and the owner of the edition just consumed (a year
|
|
96
|
+
# belongs to a component only when it follows that component's
|
|
97
|
+
# own edition).
|
|
98
|
+
ctx = { component: nil, edition_owner: nil }
|
|
99
|
+
consume_segments!(attrs, segments, ctx, urn)
|
|
100
|
+
Builder.build({ type: type_token.upcase, **attrs })
|
|
45
101
|
end
|
|
46
102
|
|
|
47
103
|
private
|
|
48
104
|
|
|
49
|
-
def
|
|
50
|
-
|
|
105
|
+
def read_type!(segments, urn)
|
|
106
|
+
type_token = segments.shift.to_s.downcase
|
|
107
|
+
return type_token if TYPE_LETTERS.include?(type_token)
|
|
108
|
+
|
|
109
|
+
raise Pubid::Errors::UrnParseError,
|
|
110
|
+
"Unknown IHO series #{type_token.inspect} in #{urn.inspect}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def consume_segments!(attrs, segments, ctx, urn)
|
|
114
|
+
until segments.empty?
|
|
115
|
+
consume_segment!(attrs, segments, ctx, urn)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The number occupies every leading bare number segment; the first
|
|
120
|
+
# segment that is not number-shaped ends it (and the segment loop
|
|
121
|
+
# below decides whether it is a legal marker).
|
|
122
|
+
def consume_number!(segments, urn)
|
|
123
|
+
number = []
|
|
124
|
+
while segments.first&.match?(NUMBER_RE)
|
|
125
|
+
number << segments.shift
|
|
126
|
+
end
|
|
127
|
+
if number.empty?
|
|
128
|
+
raise Pubid::Errors::UrnParseError,
|
|
129
|
+
"Missing docnumber in #{urn.inspect}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
number.join(":")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def consume_segment!(attrs, segments, ctx, urn)
|
|
136
|
+
segment = segments.shift
|
|
137
|
+
case segment
|
|
138
|
+
when MARKER_RE then consume_marker!(segment, attrs, segments, ctx, urn)
|
|
139
|
+
when LEGACY_RE then consume_legacy!(segment, attrs, urn)
|
|
140
|
+
when LEGACY_EDITION_RE then store!(attrs, :edition, segment, urn)
|
|
141
|
+
else raise_unexpected!(segment, urn)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def raise_unexpected!(segment, urn)
|
|
146
|
+
raise Pubid::Errors::UrnParseError,
|
|
147
|
+
"Unexpected segment #{segment.inspect} in #{urn.inspect}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# A marker segment: validate the value against the marker's own
|
|
151
|
+
# pattern, store it, and claim the bare atom that follows an
|
|
152
|
+
# appendix (its subnumber — the only bare atom allowed after a
|
|
153
|
+
# marker). The ed- and year- markers are POSITIONAL: the edition
|
|
154
|
+
# attaches to the component it follows (the base when no component
|
|
155
|
+
# precedes it), and a year belongs to a component only when it
|
|
156
|
+
# follows that component's own edition — the separately-published
|
|
157
|
+
# mode; otherwise it is the base with-year qualifier.
|
|
158
|
+
def consume_marker!(segment, attrs, segments, ctx, urn)
|
|
159
|
+
marker, value = MARKER_RE.match(segment).captures
|
|
160
|
+
key, pattern = MARKERS.fetch(marker)
|
|
161
|
+
raise_invalid!(marker, value, urn) unless value.match?(pattern)
|
|
162
|
+
|
|
163
|
+
if POSITIONAL_MARKERS.include?(key)
|
|
164
|
+
return consume_positional!(attrs, key, value, ctx, urn)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
store!(attrs, key, value, urn)
|
|
168
|
+
ctx[:component] = key
|
|
169
|
+
ctx[:edition_owner] = nil
|
|
170
|
+
consume_subnumber!(attrs, key, segments, ctx, urn)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def raise_invalid!(marker, value, urn)
|
|
174
|
+
raise Pubid::Errors::UrnParseError,
|
|
175
|
+
"Invalid #{marker} value #{value.inspect} in #{urn.inspect}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# ed- and year- are POSITIONAL markers (see the marker docs): the
|
|
179
|
+
# edition attaches to the component it follows, the year to the
|
|
180
|
+
# edition owner.
|
|
181
|
+
def consume_positional!(attrs, key, value, ctx, urn)
|
|
182
|
+
if key == :edition
|
|
183
|
+
return store_component_scoped!(attrs, :edition, value, ctx, urn)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
store_year!(attrs, value, ctx, urn)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def store_component_scoped!(attrs, stem, value, ctx, urn)
|
|
190
|
+
owner = ctx[:component]
|
|
191
|
+
store!(attrs, owner ? :"#{owner}_#{stem}" : stem, value, urn)
|
|
192
|
+
ctx[:edition_owner] = owner
|
|
51
193
|
end
|
|
52
194
|
|
|
53
|
-
def
|
|
54
|
-
|
|
195
|
+
def store_year!(attrs, value, ctx, urn)
|
|
196
|
+
owner = ctx[:edition_owner]
|
|
197
|
+
attrs_key = owner && owner != :base ? :"#{owner}_year" : :year
|
|
198
|
+
ctx[:edition_owner] = nil
|
|
199
|
+
store!(attrs, attrs_key, value, urn)
|
|
55
200
|
end
|
|
56
201
|
|
|
57
|
-
def
|
|
58
|
-
return
|
|
202
|
+
def consume_subnumber!(attrs, key, segments, ctx, urn)
|
|
203
|
+
return unless key == :appendix && segments.first&.match?(SUBNUMBER_RE)
|
|
204
|
+
|
|
205
|
+
store!(attrs, :appendix_subnumber, segments.shift, urn)
|
|
206
|
+
end
|
|
59
207
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
208
|
+
# Legacy dotted-key decode. The legacy appendix value stored the
|
|
209
|
+
# subnumber inside one string ("appendix.D-2"); it decodes into the
|
|
210
|
+
# two components.
|
|
211
|
+
def consume_legacy!(segment, attrs, urn)
|
|
212
|
+
match = LEGACY_RE.match(segment)
|
|
213
|
+
key = LEGACY_KEYS.fetch(match[1]) do
|
|
63
214
|
raise Pubid::Errors::UrnParseError,
|
|
64
215
|
"Unexpected segment #{segment.inspect} in #{urn.inspect}"
|
|
65
216
|
end
|
|
66
217
|
|
|
67
|
-
|
|
218
|
+
store_legacy!(attrs, key, match[2], urn)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def store_legacy!(attrs, key, value, urn)
|
|
222
|
+
sub = /\A([A-Z])-(\d+)\z/.match(value) if key == :appendix
|
|
223
|
+
return store!(attrs, key, value, urn) unless sub
|
|
224
|
+
|
|
225
|
+
store!(attrs, :appendix, sub[1], urn)
|
|
226
|
+
store!(attrs, :appendix_subnumber, sub[2], urn)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def store!(attrs, key, value, urn)
|
|
230
|
+
raise_if_duplicate(key, attrs, urn)
|
|
231
|
+
attrs[key] = value
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def raise_if_duplicate(key, attrs, urn)
|
|
235
|
+
return unless attrs[key]
|
|
236
|
+
|
|
237
|
+
raise Pubid::Errors::UrnParseError,
|
|
238
|
+
"Duplicate #{key} segment in #{urn.inspect}"
|
|
68
239
|
end
|
|
69
240
|
end
|
|
70
241
|
end
|
data/lib/pubid/iso/CLAUDE.md
CHANGED
|
@@ -46,6 +46,38 @@
|
|
|
46
46
|
different attributes — the first pass stripped `edition.number.value` too,
|
|
47
47
|
and two corrigendum examples caught it.
|
|
48
48
|
|
|
49
|
+
- **URN item paths and document versions are cut, not parsed (issue #645).**
|
|
50
|
+
Glossarist-rendered corpora cite parent documents with URNs like
|
|
51
|
+
`urn:iso:std:iso:10303:-2:ed-1:v1:en:tech:ACTIVITY_MIM` and a duplicated
|
|
52
|
+
`:tech:X:tech:X:X` extraction tail. Neither tail is document identity, so
|
|
53
|
+
`UrnParser` drops both: the bare document-version token (`v1` right after
|
|
54
|
+
`ed-N` — the base identifier has no version attribute, and before this fix
|
|
55
|
+
the supplement loop consumed it as a type-less supplement number, which
|
|
56
|
+
wrapped the document as that phantom supplement and collapsed
|
|
57
|
+
"ISO 10303-2" to "ISO 1"), and the item path, cut at the first
|
|
58
|
+
supplement-loop token that can be nothing else (`item_path_segment?`).
|
|
59
|
+
Two invariants to keep when editing this loop:
|
|
60
|
+
**the cut's allow-list must mirror exactly what the loop consumes** —
|
|
61
|
+
`stage-`, a bare typed-stage abbr, the `ABBR.N` form, a supplement type,
|
|
62
|
+
digits, a `\Av\d` version, or a language field (`LANGUAGE_FIELD_RE`,
|
|
63
|
+
2-3-letter ISO 639 codes, comma-separated). A token allowed through that
|
|
64
|
+
no branch consumes spins the loop forever: the language branch used to be
|
|
65
|
+
the loose catch-all that guaranteed progress, and gating it on
|
|
66
|
+
`LANGUAGE_FIELD_RE` (so item-path segments stop overwriting the base
|
|
67
|
+
language as a bogus "(ACTIVITY_MIM)" suffix) removed that guarantee —
|
|
68
|
+
`FDAM.3` in `urn:iso:std:iso:17301:-1:FDAM.3:amd:2022:v1.3` exposed it.
|
|
69
|
+
That is also why the loop gained an explicit `ABBR.N` arm: the base stage
|
|
70
|
+
block deliberately skips a supplement-family stage when its own supplement
|
|
71
|
+
follows, and the loop had to consume the token instead. It is consumed
|
|
72
|
+
**and dropped** — the supplement layer has no stage-iteration slot, and
|
|
73
|
+
the old catch-all carried it as a bogus base language visible only in the
|
|
74
|
+
generated URN (`…:fdam.3:amd:1:2022:v1` → now `…:amd:1:2022:v1`; the
|
|
75
|
+
locked `to_s` "ISO 17301-1/AMD 1:2022" is unchanged). Net effect:
|
|
76
|
+
`parse(render(parse(urn)))` round-trips for both item-path forms — the
|
|
77
|
+
face renders `(EN)` (a form the grammar accepts) instead of the
|
|
78
|
+
unparseable `(ACTIVITY_MIM)` — and identifiers without an item path
|
|
79
|
+
render byte-identically. No grammar or renderer change was needed.
|
|
80
|
+
|
|
49
81
|
- **Typed model bindings (Phase D): the builder consumes the grammar's bound
|
|
50
82
|
map, not a parse tree.** `Identifier.parse` and `Normalizer` parse through
|
|
51
83
|
`Pubid::Parg::Model.bound(:iso, …)`; `UrnParser#build_identifier` constructs
|
data/lib/pubid/iso/urn_parser.rb
CHANGED
|
@@ -55,6 +55,12 @@ module Pubid
|
|
|
55
55
|
"data" => :data,
|
|
56
56
|
}.freeze
|
|
57
57
|
|
|
58
|
+
# An ISO 639 language field: 2-3 letter codes, comma-separated
|
|
59
|
+
# ("en", "fr", "en,fr"). Item-path segments ("tech", "ACTIVITY_MIM")
|
|
60
|
+
# do not match it and are cut as the URN's item path instead
|
|
61
|
+
# (issue #645).
|
|
62
|
+
LANGUAGE_FIELD_RE = /\A[a-zA-Z]{2,3}(?:,[a-zA-Z]{2,3})*\z/
|
|
63
|
+
|
|
58
64
|
# Parse ISO URN string
|
|
59
65
|
# @param urn [String] URN string to parse
|
|
60
66
|
# @return [Identifier] parsed identifier
|
|
@@ -126,12 +132,21 @@ module Pubid
|
|
|
126
132
|
edition = parts.shift.sub("ed-", "").to_i
|
|
127
133
|
end
|
|
128
134
|
|
|
135
|
+
# RFC 5141-bis document version token ("v1" in "…:ed-1:v1:en"). The
|
|
136
|
+
# base identifier has no version attribute -- the edition is the
|
|
137
|
+
# version axis -- so the token is dropped here, before the
|
|
138
|
+
# supplement loop mistakes it for a type-less supplement number,
|
|
139
|
+
# which wrapped the document as that phantom supplement and
|
|
140
|
+
# collapsed "ISO 10303-2" to "ISO 1" (issue #645).
|
|
141
|
+
parts.shift if parts.first&.match?(/\Av\d/)
|
|
142
|
+
|
|
129
143
|
# Parse language if present (2-letter codes like "en" or comma-separated)
|
|
130
144
|
languages = nil
|
|
131
145
|
if parts.first && !parts.first.match(/^\d+$/) &&
|
|
132
146
|
!parts.first.match?(/^(amd|cor|sup|add|v\d+|stage-|ed-)/i) &&
|
|
133
147
|
!TYPED_STAGE_REVERSE_MAP.key?(parts.first.upcase) &&
|
|
134
|
-
!parts.first.match?(/^[A-Z]+\.\d+$/i)
|
|
148
|
+
!parts.first.match?(/^[A-Z]+\.\d+$/i) &&
|
|
149
|
+
parts.first.match?(LANGUAGE_FIELD_RE)
|
|
135
150
|
languages = parse_languages(parts.shift)
|
|
136
151
|
end
|
|
137
152
|
|
|
@@ -181,6 +196,17 @@ module Pubid
|
|
|
181
196
|
# Check for supplements (amd, cor, sup, add)
|
|
182
197
|
supplements = []
|
|
183
198
|
while parts.any?
|
|
199
|
+
# Item path (issue #645): trailing URN segments naming a resource
|
|
200
|
+
# inside the document ("...:en:tech:ACTIVITY_MIM", and the
|
|
201
|
+
# Glossarist-era duplicated ":tech:X:tech:X:X" extraction
|
|
202
|
+
# tails). They are not document identity: the first token that
|
|
203
|
+
# cannot be a stage, supplement field, version or language
|
|
204
|
+
# starts the path, and it and everything after it are dropped so
|
|
205
|
+
# the duplication cannot derail the fields parsed before it.
|
|
206
|
+
if item_path_segment?(parts.first)
|
|
207
|
+
break
|
|
208
|
+
end
|
|
209
|
+
|
|
184
210
|
supp_type = nil
|
|
185
211
|
supp_number = nil
|
|
186
212
|
supp_version = nil
|
|
@@ -194,6 +220,16 @@ module Pubid
|
|
|
194
220
|
elsif TYPED_STAGE_REVERSE_MAP.key?(parts.first&.upcase)
|
|
195
221
|
supp_stage_abbr = parts.shift.upcase
|
|
196
222
|
supp_stage = TYPED_STAGE_REVERSE_MAP[supp_stage_abbr]
|
|
223
|
+
elsif parts.first&.match?(/\A[A-Za-z]+\.\d+\z/) &&
|
|
224
|
+
TYPED_STAGE_REVERSE_MAP.key?(parts.first.upcase.split(".").first)
|
|
225
|
+
# "FDAM.3" -- a supplement-family stage abbreviation with an
|
|
226
|
+
# iteration, meant for the supplement that follows
|
|
227
|
+
# ("...:FDAM.3:amd:..."). The supplement layer has no
|
|
228
|
+
# stage-iteration slot, so the token is consumed here and
|
|
229
|
+
# dropped: the previous catch-all language branch carried it
|
|
230
|
+
# as a bogus base language, surfacing only in the generated
|
|
231
|
+
# URN (issue #645).
|
|
232
|
+
parts.shift
|
|
197
233
|
end
|
|
198
234
|
|
|
199
235
|
# Check for supplement type (amd, cor, sup, add)
|
|
@@ -218,8 +254,8 @@ module Pubid
|
|
|
218
254
|
# the NUMBER. The RFC 5141 additive extension ("amd:1:2007:v2")
|
|
219
255
|
# keeps the number in its own slot, so a v-token after a number
|
|
220
256
|
# is the true <supplversion>.
|
|
221
|
-
if parts.first&.
|
|
222
|
-
version_str = parts.shift.
|
|
257
|
+
if parts.first&.match?(/\Av\d/)
|
|
258
|
+
version_str = parts.shift.delete_prefix("v")
|
|
223
259
|
value = version_str.split(".").first.to_i
|
|
224
260
|
if supp_number.nil?
|
|
225
261
|
supp_number = value
|
|
@@ -236,8 +272,8 @@ module Pubid
|
|
|
236
272
|
# A v-token after the year ("amd:1:2007:v1") would otherwise be
|
|
237
273
|
# left for the next loop iteration, wrapping a type-less
|
|
238
274
|
# supplement around the real one and losing the base.
|
|
239
|
-
if parts.first&.
|
|
240
|
-
version_str = parts.shift.
|
|
275
|
+
if parts.first&.match?(/\Av\d/)
|
|
276
|
+
version_str = parts.shift.delete_prefix("v")
|
|
241
277
|
value = version_str.split(".").first.to_i
|
|
242
278
|
if supp_number.nil?
|
|
243
279
|
supp_number = value
|
|
@@ -248,7 +284,9 @@ module Pubid
|
|
|
248
284
|
|
|
249
285
|
# Check for language after supplement
|
|
250
286
|
supp_languages = nil
|
|
251
|
-
if parts.first && !parts.first.match(/^\d+$/) &&
|
|
287
|
+
if parts.first && !parts.first.match(/^\d+$/) &&
|
|
288
|
+
!parts.first.match?(/^(amd|cor|sup|add|v\d+|stage-)/i) &&
|
|
289
|
+
parts.first.match?(LANGUAGE_FIELD_RE)
|
|
252
290
|
supp_languages = parse_languages(parts.shift)
|
|
253
291
|
end
|
|
254
292
|
|
|
@@ -270,6 +308,25 @@ module Pubid
|
|
|
270
308
|
|
|
271
309
|
private
|
|
272
310
|
|
|
311
|
+
# True when the token can only be an item-path segment (issue #645):
|
|
312
|
+
# it is not a stage, supplement type, number/year, version or
|
|
313
|
+
# language field, so it -- and everything after it -- names a
|
|
314
|
+
# resource inside the document rather than a component of the
|
|
315
|
+
# identifier. The allow-list mirrors exactly what the supplement
|
|
316
|
+
# loop can consume, so every token it lets through makes progress.
|
|
317
|
+
def item_path_segment?(token)
|
|
318
|
+
return false if token.nil?
|
|
319
|
+
return false if token.match?(/\A\d+\z/)
|
|
320
|
+
return false if token.match?(/\Av\d/)
|
|
321
|
+
return false if token.start_with?("stage-")
|
|
322
|
+
return false if TYPED_STAGE_REVERSE_MAP.key?(token.upcase)
|
|
323
|
+
return false if token.match?(/\A[A-Za-z]+\.\d+\z/) &&
|
|
324
|
+
TYPED_STAGE_REVERSE_MAP.key?(token.upcase.split(".").first)
|
|
325
|
+
return false if SUPPLEMENT_TYPE_MAP.key?(token.downcase)
|
|
326
|
+
|
|
327
|
+
!token.match?(LANGUAGE_FIELD_RE)
|
|
328
|
+
end
|
|
329
|
+
|
|
273
330
|
# Helper to find IS-type TypedStage by harmonized stage code
|
|
274
331
|
# Used when URN doesn't specify a type code (defaults to IS)
|
|
275
332
|
# Prefers the most specific (non-published) stage
|
data/lib/pubid/jis/CLAUDE.md
CHANGED
|
@@ -11,10 +11,15 @@
|
|
|
11
11
|
jis_identifier`. The WES branch is `"WES" space number parts …` with the same
|
|
12
12
|
tail as JIS (year, `R`, language, 規格群, supplements, SYMBOL) and binds a
|
|
13
13
|
`wes` capture, which the builder keys on. `JIS WES 7700` and `WES X 7700`
|
|
14
|
-
are rejected: the JIS prefix and a series letter belong only to JIS.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
are rejected: the JIS prefix and a series letter belong only to JIS.
|
|
15
|
+
- **The artifact is vendored, not baked here.** `data/parg/jis.json` is a
|
|
16
|
+
verbatim copy of pubid/pubid-grammar `artifacts/jis.json` (main `31fb965`,
|
|
17
|
+
parsanol **1.3.121**). Change the grammar there and copy the artifact back;
|
|
18
|
+
`spec/pubid/jis/parg_reaffirmed_spec.rb` pins its `source_hash`, so a local
|
|
19
|
+
rebake fails that spec.
|
|
20
|
+
- **`reaffirmed` binds the whole run of R's** (`":1997R"` → `"R"`, `":1997RR"`
|
|
21
|
+
→ `"RR"`; same for `amd_reaffirmed`/`corr_reaffirmed`). The builder reads
|
|
22
|
+
each only for truth (`true if data[:reaffirmed]`), so `RR` renders as `R`.
|
|
18
23
|
- **`series` is nil** for WES (JIS keeps its `""`-coerced String), so the hash
|
|
19
24
|
carries no `series` key. `number` is the bare number (`"7700"`), the index
|
|
20
25
|
key.
|
data/lib/pubid/parg/backend.rb
CHANGED
|
@@ -24,7 +24,7 @@ module Pubid
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
to_builder_hash(Artifact.for(flavor).parse(entry, input), merge: merge_top_sequence)
|
|
27
|
-
rescue
|
|
27
|
+
rescue *ENGINE_ERRORS => e
|
|
28
28
|
raise Pubid::Errors::ParseError.new(e.message, nil,
|
|
29
29
|
input: input,
|
|
30
30
|
flavor: registered_name(flavor))
|