pubid 2.0.4 → 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.
@@ -4,33 +4,55 @@ module Pubid
4
4
  module Iho
5
5
  # Human-readable renderer for IHO identifiers.
6
6
  #
7
- # Produces strings like:
8
- # "IHO S-44 5.0.0"
9
- # "IHO P-1/21 1.0.0"
10
- # "IHO S-100 Part 4a 1.0.0"
11
- # "IHO S-65 Ap. A 1.0.0"
12
- # "IHO P-5 2024" (year-identified series)
13
- # "IHO P-1 Vol. 30 (2)" (volume/issue-identified series)
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
- letter = id.class.type[:short]
22
- rendered = "#{id.publisher} #{letter}-#{id.number}"
23
- rendered << " Ap. #{id.appendix}" if id.appendix
24
- rendered << " Part #{id.part}" if id.part
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
- # Format: urn:iho:{type-letter-lowercase}:{number}[:ap.{appendix}][:part.{part}][:annex.{annex}][:suppl.{supplement}][:vol.{volume}[:issue.{issue}]][:{version}][:y.{year}]
8
- # Example: urn:iho:s:44:5.0.0 for "IHO S-44 5.0.0".
9
- # Only the dotted version is a bare segment. A year is tagged
10
- # ("urn:iho:p:5:y.2024" for "IHO P-5 2024"), and so is a volume with
11
- # its optional issue ("urn:iho:p:1:vol.30:issue.2" for
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
- # The number is not escaped, so "IHO S-158:100" gives
15
- # "urn:iho:s:158:100". A bare year would then make "IHO S-158:2024"
16
- # and "IHO S-158 2024" share one URN; the y. tag keeps them apart.
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 << "ap.#{identifier.appendix}" if identifier.appendix
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
@@ -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
- # UrnGenerator emits:
8
- # urn:iho:{type-lower}:{number}[:{tag}.{value}]*[:{version}][:y.{year}]
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
- # type-lower is the lowercase series letter (s, p, m, b, c).
11
- # tag is a lowercase label: part, ap, annex, suppl, vol, issue or y.
12
- # version is the dotted a.b.c edition, the only untagged tail.
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
- # The number is not escaped, so it can span segments
15
- # ("IHO S-158:100" gives "urn:iho:s:158:100"). Every untagged
16
- # segment that is not a version therefore belongs to the number.
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 → IHO S-44 5.0.0
20
- # - urn:iho:s:100:part.4a:1.0.0 → IHO S-100 Part 4a 1.0.0
21
- # - urn:iho:s:65:ap.A:1.0.0 → IHO S-65 Ap. A 1.0.0
22
- # - urn:iho:s:158:100:1.0.0 → IHO S-158:100 1.0.0
23
- # - urn:iho:p:5:y.2024 → IHO P-5 2024
24
- # - urn:iho:p:1:vol.30 → IHO P-1 Vol. 30
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
- # Tag => the text that the human-readable form prints for it.
28
- TAG_TEXT = {
29
- "part" => ->(v) { " Part #{v}" },
30
- "ap" => ->(v) { " Ap. #{v}" },
31
- "annex" => ->(v) { " Annex #{v}" },
32
- "suppl" => ->(v) { " Suppl #{v}" },
33
- "vol" => ->(v) { " Vol. #{v}" },
34
- "issue" => ->(v) { " (#{v})" },
35
- "y" => ->(v) { " #{v}" },
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
- VERSION_SEGMENT = /\A\d+\.\d+\.\d+\z/
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
- type_token, first, *rest = split_parts(strip_namespace(urn))
42
- number = [first, *rest.take_while { |s| untagged_number?(s) }]
43
- tail = rest.drop(number.size - 1).map { |s| segment_text(s, urn) }
44
- flavor_parse("IHO #{type_token.upcase}-#{number.join(':')}#{tail.join}")
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 untagged_number?(segment)
50
- !tagged?(segment) && !segment.match?(VERSION_SEGMENT)
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 tagged?(segment)
54
- segment.match?(/\A[a-z]+\./i)
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 segment_text(segment, urn)
58
- return " #{segment}" if segment.match?(VERSION_SEGMENT)
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
- tag, value = segment.split(".", 2)
61
- render = TAG_TEXT[tag.downcase] if tagged?(segment)
62
- unless render
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
- render.call(value)
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
@@ -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
@@ -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&.start_with?("v")
222
- version_str = parts.shift.sub("v", "")
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&.start_with?("v")
240
- version_str = parts.shift.sub("v", "")
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+$/) && !parts.first.match?(/^(amd|cor|sup|add|v\d+|stage-)/i)
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
@@ -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. The
15
- source of record is pubid/pubid-grammar; the artifact was rebaked here with
16
- parsanol **1.3.114** — the PATH `parsanol` shim may be a newer version, so
17
- bake with the 1.3.114 gem's `exe/parsanol` and check `compiler_version`.
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.
@@ -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 Parsanol::ParseFailed => e
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))