pubid 2.0.2 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/data/ieee/update_codes.yaml +2 -2
  3. data/data/parg/aiee.json +1 -1
  4. data/data/parg/aiee.parg +99 -9
  5. data/data/parg/iala.json +1 -1
  6. data/data/parg/ieee.json +1 -1
  7. data/data/parg/ire.json +1 -1
  8. data/data/parg/ire.parg +51 -12
  9. data/data/parg/iso.json +1 -1
  10. data/data/parg/iso.parg +751 -0
  11. data/data/parg/jis.json +1 -1
  12. data/data/parg/nesc.json +1 -1
  13. data/data/parg/nesc.parg +97 -12
  14. data/data/parg/parg.json +1 -1
  15. data/lib/pubid/iala/builder.rb +48 -39
  16. data/lib/pubid/iala/identifier.rb +1 -1
  17. data/lib/pubid/ieee/CLAUDE.md +2 -0
  18. data/lib/pubid/ieee/aiee/identifier.rb +1 -1
  19. data/lib/pubid/ieee/builder.rb +175 -28
  20. data/lib/pubid/ieee/components/code.rb +46 -7
  21. data/lib/pubid/ieee/identifiers/base.rb +4 -1
  22. data/lib/pubid/ieee/identifiers/joint_development.rb +7 -3
  23. data/lib/pubid/ieee/ire/identifier.rb +1 -1
  24. data/lib/pubid/ieee/nesc/builder.rb +1 -1
  25. data/lib/pubid/ieee/project_renderer.rb +5 -1
  26. data/lib/pubid/ieee/renderer.rb +4 -2
  27. data/lib/pubid/ieee/urn_generator.rb +4 -1
  28. data/lib/pubid/iho/CLAUDE.md +15 -6
  29. data/lib/pubid/iho/urn_generator.rb +9 -6
  30. data/lib/pubid/iho/urn_parser.rb +43 -40
  31. data/lib/pubid/iso/CLAUDE.md +66 -0
  32. data/lib/pubid/iso/builder.rb +228 -14
  33. data/lib/pubid/iso/identifier.rb +22 -12
  34. data/lib/pubid/iso/normalizer.rb +5 -4
  35. data/lib/pubid/iso/urn_parser.rb +13 -8
  36. data/lib/pubid/jis/CLAUDE.md +36 -0
  37. data/lib/pubid/jis/builder.rb +12 -12
  38. data/lib/pubid/jis/identifier.rb +11 -1
  39. data/lib/pubid/jis/identifiers/welding_engineering_standard.rb +35 -0
  40. data/lib/pubid/jis/identifiers.rb +2 -0
  41. data/lib/pubid/jis/renderer.rb +1 -1
  42. data/lib/pubid/jis/supplement_identifier.rb +5 -5
  43. data/lib/pubid/jis/urn_parser.rb +2 -1
  44. data/lib/pubid/jis.rb +3 -2
  45. data/lib/pubid/version.rb +1 -1
  46. metadata +5 -2
@@ -39,7 +39,7 @@ module Pubid
39
39
 
40
40
  # Parse IRE identifier string
41
41
  def self.parse(input)
42
- parsed = Pubid::Parg::Backend.parse(:ire, input,
42
+ parsed = Pubid::Parg::Model.bound(:ire, input,
43
43
  entry: "ire_identifier")
44
44
  builder = Builder.new
45
45
  builder.build(parsed)
@@ -94,7 +94,7 @@ module Pubid
94
94
  return Identifiers::Nesc::Preprint if parsed_hash[:preprint]
95
95
 
96
96
  # Draft identifiers
97
- return Identifiers::Nesc::Draft if parsed_hash[:draft]
97
+ return Identifiers::Nesc::Draft if parsed_hash[:draft] || parsed_hash[:nesc_draft_marker]
98
98
 
99
99
  # Variant-based identifiers
100
100
  if parsed_hash[:variant]
@@ -14,8 +14,12 @@ module Pubid
14
14
 
15
15
  def render(context: nil, **_opts)
16
16
  id = @id
17
+ # Canonical code: a project draft's code is reassembled from
18
+ # parse-tree captures, and the assembled separators are synthetic
19
+ # — the printed face is the uniform spelling (the same document
20
+ # spelled with all dashes or all dots renders identically).
17
21
  body = if id.respond_to?(:code_obj) && id.code_obj
18
- id.code_obj.to_s
22
+ id.code_obj.canonical_to_s
19
23
  else
20
24
  id.number.to_s
21
25
  end
@@ -472,8 +472,10 @@ module Pubid
472
472
  end
473
473
 
474
474
  # Code (number) with draft version for PSI — the mark goes after the
475
- # code, before the draft and the date.
476
- code_part = id.code.to_s
475
+ # code, before the draft and the date. Canonical code: the PSI
476
+ # code is reassembled from parse-tree captures (synthetic
477
+ # separators — see Components::Code#canonical_to_s).
478
+ code_part = id.code.canonical_to_s
477
479
  code_part += mark(id.code_obj&.number, id.code_obj&.prefix,
478
480
  publishers: publishers_of(id))
479
481
  if id.draft_obj
@@ -190,7 +190,10 @@ module Pubid
190
190
 
191
191
  return nil unless identifier.code_obj
192
192
 
193
- identifier.code_obj.to_s
193
+ # The URN is spelling-stable: codes printed with mixed part
194
+ # separators ("7.4-3-2") share one identity with their uniform
195
+ # spelling, so the segment renders canonically, not as printed.
196
+ identifier.code_obj.canonical_to_s
194
197
  end
195
198
 
196
199
  # A multi-designation wrapper (AdoptedStandard, DualPublished,
@@ -62,12 +62,21 @@ and volume/issue only ride part-less ids, which cannot join
62
62
  `spec/pubid/all_parts_edition_keys_audit_spec.rb` covers the
63
63
  version-bearing S-series collapse.
64
64
 
65
- **URN conventions:** a year is a bare trailing segment
66
- (`urn:iho:p:5:2024`); a volume is a `vol.N` part-kind segment with an
67
- optional `issue.N` after it (`urn:iho:p:1:vol.30.issue.2`). The URN
68
- parser walks part-kind tokens as a run and hands the rebuilt text
69
- back through the flavor parse, so a URN round-trip exercises the same
70
- grammar as a string.
65
+ **URN conventions:** only the dotted `version` is an untagged
66
+ segment. A year is tagged `y.N` (`urn:iho:p:5:y.2024`); a volume is a
67
+ `vol.N` segment with an optional `issue.N` segment after it
68
+ (`urn:iho:p:1:vol.30:issue.2`). The URN parser walks the tagged
69
+ segments as a run and hands the rebuilt text back through the flavor
70
+ parse, so a URN round-trip exercises the same grammar as a string.
71
+ **The number is not escaped and can hold a colon** (`IHO S-158:100`
72
+ gives `urn:iho:s:158:100`), so the parser joins every untagged segment
73
+ that is not an a.b.c version back into the number. That is why the
74
+ year must carry a tag: with the bare year that pubid 2.0.2 emitted,
75
+ `IHO S-158:2024` and `IHO S-158 2024` both gave `urn:iho:s:158:2024`,
76
+ and no colon number round-tripped at all. A 2.0.2 year URN such as
77
+ `urn:iho:p:5:2024` now decodes as the number `5:2024`; it was emitted
78
+ only by 2.0.2. A tagged segment with an unknown tag raises
79
+ `Pubid::Errors::UrnParseError`.
71
80
 
72
81
  ## Fixtures run through the classify pipeline
73
82
 
@@ -4,13 +4,16 @@ 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}][:{version|year|vol.N[.issue.N]}]
7
+ # Format: urn:iho:{type-letter-lowercase}:{number}[:ap.{appendix}][:part.{part}][:annex.{annex}][:suppl.{supplement}][:vol.{volume}[:issue.{issue}]][:{version}][:y.{year}]
8
8
  # Example: urn:iho:s:44:5.0.0 for "IHO S-44 5.0.0".
9
- # The trailing series component follows the flavor's own conventions:
10
- # a version or a year is a bare segment ("urn:iho:p:5:2024" for
11
- # "IHO P-5 2024"), a volume is a vol.N part-kind segment with an
12
- # optional issue.N after it ("urn:iho:p:1:vol.30.issue.2" for
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
13
12
  # "IHO P-1 Vol. 30 (2)").
13
+ #
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.
14
17
  class UrnGenerator
15
18
  attr_reader :identifier
16
19
 
@@ -31,7 +34,7 @@ module Pubid
31
34
  parts << "vol.#{identifier.volume}" if identifier.volume
32
35
  parts << "issue.#{identifier.issue}" if identifier.issue
33
36
  parts << identifier.version.to_s if identifier.version
34
- parts << identifier.year.to_s if identifier.year
37
+ parts << "y.#{identifier.year}" if identifier.year
35
38
  parts.join(":")
36
39
  end
37
40
  end
@@ -5,63 +5,66 @@ module Pubid
5
5
  # Parses IHO URNs back into identifiers.
6
6
  #
7
7
  # UrnGenerator emits:
8
- # urn:iho:{type-lower}:{number}[:{part-kind}.{part}][:{version|year|vol.N[.issue.N]}]
8
+ # urn:iho:{type-lower}:{number}[:{tag}.{value}]*[:{version}][:y.{year}]
9
9
  #
10
10
  # type-lower is the lowercase series letter (s, p, m, b, c).
11
- # part-kind is the lowercase part label (part, ap, annex, suppl,
12
- # vol, issue).
13
- # The trailing segment is the dotted version, a bare year, or the
14
- # volume/issue pair.
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
+ #
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.
15
17
  #
16
18
  # Examples:
17
19
  # - urn:iho:s:44:5.0.0 → IHO S-44 5.0.0
18
20
  # - urn:iho:s:100:part.4a:1.0.0 → IHO S-100 Part 4a 1.0.0
19
21
  # - urn:iho:s:65:ap.A:1.0.0 → IHO S-65 Ap. A 1.0.0
20
- # - urn:iho:p:5:2024 → IHO P-5 2024
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
21
24
  # - urn:iho:p:1:vol.30 → IHO P-1 Vol. 30
22
- # - urn:iho:p:1:vol.30.issue.2 → IHO P-1 Vol. 30 (2)
25
+ # - urn:iho:p:1:vol.30:issue.2 → IHO P-1 Vol. 30 (2)
23
26
  class UrnParser < Pubid::UrnParser::Base
24
- PART_LABELS = {
25
- "part" => "Part",
26
- "ap" => "Ap.",
27
- "annex" => "Annex",
28
- "suppl" => "Suppl",
29
- "vol" => "Vol.",
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}" },
30
36
  }.freeze
31
37
 
38
+ VERSION_SEGMENT = /\A\d+\.\d+\.\d+\z/
39
+
32
40
  def parse_urn(urn)
33
- body = strip_namespace(urn)
34
- parts = split_parts(body)
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}")
45
+ end
35
46
 
36
- type_token = parts.fetch(0).upcase
37
- number = parts.fetch(1)
38
- idx = 2
39
- text = "IHO #{type_token}-#{number}"
40
- # Part-kind tokens come in any run (a volume may carry an issue
41
- # beside it); "issue.N" prints as the parenthesized issue.
42
- while parts[idx] && part_kind?(parts[idx])
43
- kind, value = parts[idx].split(".", 2)
44
- if kind.downcase == "issue"
45
- text += " (#{value})"
46
- else
47
- label = PART_LABELS.fetch(kind.downcase) { kind.capitalize }
48
- text += " #{label} #{value}"
49
- end
50
- idx += 1
51
- end
52
- tail = parts[idx]
47
+ private
53
48
 
54
- text += " #{tail}" if tail
55
- flavor_parse(text)
49
+ def untagged_number?(segment)
50
+ !tagged?(segment) && !segment.match?(VERSION_SEGMENT)
56
51
  end
57
52
 
58
- private
53
+ def tagged?(segment)
54
+ segment.match?(/\A[a-z]+\./i)
55
+ end
56
+
57
+ def segment_text(segment, urn)
58
+ return " #{segment}" if segment.match?(VERSION_SEGMENT)
59
+
60
+ tag, value = segment.split(".", 2)
61
+ render = TAG_TEXT[tag.downcase] if tagged?(segment)
62
+ unless render
63
+ raise Pubid::Errors::UrnParseError,
64
+ "Unexpected segment #{segment.inspect} in #{urn.inspect}"
65
+ end
59
66
 
60
- # A part-kind token starts with letters followed by a dot, e.g.,
61
- # "part.4a", "ap.A", "annex.A", "suppl.1". A version like "5.0.0"
62
- # starts with a digit.
63
- def part_kind?(token)
64
- token.match?(/^[a-z]+\./i)
67
+ render.call(value)
65
68
  end
66
69
  end
67
70
  end
@@ -45,3 +45,69 @@
45
45
  needs a lookbehind, because `tc_number.value` and `edition.number.value` are
46
46
  different attributes — the first pass stripped `edition.number.value` too,
47
47
  and two corrigendum examples caught it.
48
+
49
+ - **Typed model bindings (Phase D): the builder consumes the grammar's bound
50
+ map, not a parse tree.** `Identifier.parse` and `Normalizer` parse through
51
+ `Pubid::Parg::Model.bound(:iso, …)`; `UrnParser#build_identifier` constructs
52
+ the same bound-map dialect. The grammar is edition 2 with every
53
+ builder-consumed capture bound (`grammars/iso.parg`, `bindings identifier`).
54
+ The conversion preserved byte fidelity: `to_s` and `to_hash` are identical
55
+ for all 7,859 fixture inputs replayed against a `main` baseline, and the
56
+ wasm engine agrees with Ruby on every tree and bound map (the
57
+ `corpus-parity` gate). **What to know before editing either side:**
58
+
59
+ - **Supplement layers capture into position-scoped prefixes** — `supp_*`
60
+ (first supplement), `supp2_*`/`supp3_*` (chain layers), `isor_supp_*`
61
+ (ISO/R supplement), `dsupp_*` (directive supplement), `bs1_/bs2_/bs3_`
62
+ (bundle items), `idf_*/idf_supp_*` (the joint's IDF side, whose rules are
63
+ inlined from the imported idf grammar so the names never collide with the
64
+ ISO ones). Bindings.collect_captures collects captures globally by name
65
+ and delivers the FIRST occurrence, so shared names across layers would
66
+ collapse a layer's number/date onto the base's. `Builder#build` peels the
67
+ layers outermost-first, re-keying each prefix onto the builder-input
68
+ names and nesting the rest as the layer's `:base`.
69
+ - **The base document's own edition captures as `base_edition`** wherever a
70
+ supplement layer's third_part can carry its own edition
71
+ (`supplement_base_no_third`, the joint base). The builder re-keys it onto
72
+ `:edition` only at the innermost base — moving it inside a peel step
73
+ mis-assigns the edition to the wrapper on multi-layer chains (the
74
+ `ISO/IEC Guide 98-3 ED1/Suppl 1:2008/Cor 1:2009` spec caught that).
75
+ - **No array bindings.** The wasm engine's array-group harvest pads
76
+ non-firing reps with empty hashes (parsanol-ruby#399), so Ruby and wasm
77
+ bound maps disagree on any input whose parse backtracks past the array
78
+ branch. Two consequences: copublishers capture as ONE scalar span
79
+ (`"/IEC/IEEE"`, at-least-one item, absent when none) that the builder
80
+ splits, and bundle items capture into `bs1_/bs2_/bs3_` prefixes the
81
+ builder assembles into `supplements`. Verified to hold unchanged at the
82
+ 1.3.121 pin (the capture-flatten release): the re-baked artifact's
83
+ grammar/entries/bindings/tests/tables are byte-identical to the 1.3.114
84
+ bake. A newer engine's `(raw)` wrapper interiors would replace both
85
+ shapes — re-visit when such a wrapper ships.
86
+ - **The artifact exposes `["identifier", "idf.identifier"]`** — the
87
+ `use idf` import survives the conversion so the joint composition's
88
+ entry surface stays available (the fleet gate asserts the entry list).
89
+ The joint's IDF side itself binds as `idf_*` scalars; the pilot's
90
+ `publisher -> publisher_name (string) preprocess: publisher_names`
91
+ binding is also kept (the T1 materialization asserts the full
92
+ organization name), and the Ruby builder's `cast` returns nil for
93
+ `:publisher_name` — the Ruby identifier has no such attribute. The
94
+ Ruby artifact load resolves `use idf` against `data/parg/idf.parg`,
95
+ which sits beside the vendored artifact.
96
+ - **Captures must inline their bodies to raw terms.** A capture wrapping a
97
+ bare rule ref exposes the ref's child nodes and cannot bind as a string
98
+ (`cannot bind a Array capture`), and date/language bodies are inlined at
99
+ all capture sites for that reason. Multi-line rules must be brace bodies
100
+ (line-mode rejects newlines inside groups).
101
+ - **The URN parser emits the same dialect**: its supplement loop writes
102
+ `supp_`/`supp2_`/`supp3_`-prefixed flat keys (the prefixes the builder
103
+ peels), not `{base:, **supp}` nesting; the builder peels `supp_year` and
104
+ `supp_supplement_version` the same way by prefix. Its raw-stage fallback
105
+ (`:stage`), `:document_type` and `:supplement_version` keys are
106
+ builder-input names, not grammar captures, and pass through `cast`
107
+ unchanged.
108
+ - **Grammar probes**: `ruby -e 'gem "parsanol", "1.3.121"; …'` against the
109
+ artifact (`parse_and_bind`) shows the bound map; `Model.bound(:iso, …)`
110
+ shows the builder's actual input. The artifact and its vendored twin
111
+ (`data/parg/iso.parg`) must move together — the twin is the embedded
112
+ source's provenance copy.
113
+
@@ -66,6 +66,84 @@ module Pubid
66
66
  Pubid::Iso.locate_type(typed_stage.type_code)
67
67
  end
68
68
 
69
+ # Bound-hash layer fields: the grammar captures every supplement
70
+ # layer into a disjoint prefix (supp_ on the first supplement,
71
+ # supp2_ / supp3_ on the chain's later layers, isor_supp_ on an
72
+ # ISO/R supplement, dsupp_ on a directive supplement), so each
73
+ # wrapper's fields peel off the flat bound hash by prefix and
74
+ # re-key onto the builder-input names.
75
+ SUPPLEMENT_LAYERS = [
76
+ {
77
+ prefix: "supp3_",
78
+ keys: %i[supp3_type_with_stage supp3_number_with_part supp3_stage_iteration
79
+ supp3_number supp3_date supp3_undated_marker supp3_edition
80
+ supp3_languages supp3_all_parts],
81
+ plain_third_part: false,
82
+ },
83
+ {
84
+ prefix: "supp2_",
85
+ keys: %i[supp2_type_with_stage supp2_number_with_part supp2_stage_iteration
86
+ supp2_number supp2_date supp2_undated_marker supp2_edition
87
+ supp2_languages supp2_all_parts],
88
+ plain_third_part: false,
89
+ },
90
+ {
91
+ prefix: "supp_",
92
+ keys: %i[supp_type_with_stage supp_number_with_part supp_stage_iteration
93
+ supp_number supp_date supp_undated_marker supp_edition
94
+ supp_languages supp_all_parts],
95
+ # The first supplement's third_part keeps the plain names: the
96
+ # base document's own edition captures as base_edition, so the
97
+ # plain edition/languages/all_parts belong to the supplement.
98
+ plain_third_part: true,
99
+ },
100
+ {
101
+ prefix: "isor_supp_",
102
+ keys: %i[isor_supp_type_with_stage isor_supp_number_with_part
103
+ isor_supp_stage_iteration isor_supp_date isor_supp_undated_marker],
104
+ plain_third_part: true,
105
+ },
106
+ ].freeze
107
+ PLAIN_THIRD_PART_KEYS = %i[edition languages all_parts].freeze
108
+
109
+ # Directive-supplement layer (ISO/IEC DIR 1 ISO SUP): dsupp_* fields
110
+ # re-key onto the supplement hash, the base directive's fields stay
111
+ # plain.
112
+ DSUPP_KEYS = %i[dsupp_publisher dsupp_type_with_stage dsupp_date
113
+ dsupp_undated_marker dsupp_edition dsupp_languages
114
+ dsupp_all_parts].freeze
115
+
116
+ # Bundled directive items capture into position-scoped prefixes
117
+ # (one per bundle item — the engine's array-group harvest pads
118
+ # non-firing reps with empty hashes, parsanol-ruby#399, so bundle
119
+ # items cannot collect through an array binding).
120
+ BUNDLE_PREFIXES = %w[bs1_ bs2_ bs3_].freeze
121
+
122
+ # Joint identifier (ISO x|IDF y): the IDF side captures under idf_*
123
+ # names; they re-key onto the tree-shape hash Pubid::Idf.build_from_parse
124
+ # consumes.
125
+ IDF_KEYS = %i[idf_publisher idf_type_with_stage idf_stage_iteration
126
+ idf_number_with_part idf_date idf_languages idf_all_parts
127
+ idf_supp_type_with_stage idf_supp_number_with_part2
128
+ idf_supp_stage_iteration2 idf_supp_date2
129
+ idf_supp_undated_marker].freeze
130
+ IDF_REKEY = {
131
+ idf_publisher: :publisher,
132
+ idf_type_with_stage: :type_with_stage,
133
+ idf_stage_iteration: :stage_iteration,
134
+ idf_number_with_part: :number_with_part,
135
+ idf_date: :date,
136
+ idf_languages: :languages,
137
+ idf_all_parts: :all_parts,
138
+ }.freeze
139
+ IDF_SUPP_REKEY = {
140
+ idf_supp_type_with_stage: :type_with_stage,
141
+ idf_supp_number_with_part2: :number_with_part,
142
+ idf_supp_stage_iteration2: :stage_iteration,
143
+ idf_supp_date2: :date,
144
+ idf_supp_undated_marker: :undated_marker,
145
+ }.freeze
146
+
69
147
  def build(parsed_hash)
70
148
  # "(all parts)" and the ":ser" URN name every part of the document,
71
149
  # so they build an AllPartsIdentifier around the document (the URN
@@ -73,6 +151,51 @@ module Pubid
73
151
  # reason). The document itself holds no all-parts mark.
74
152
  all_parts = parsed_hash.delete(:all_parts)
75
153
 
154
+ # The copublisher tail captures as one scalar span ("/IEC/IEEE");
155
+ # split it into the {copublisher:} element hashes the merge and
156
+ # the :copublishers cast consume. A pre-built array (the URN
157
+ # parser) passes through.
158
+ if parsed_hash[:copublishers].is_a?(String)
159
+ parsed_hash[:copublishers] = parsed_hash.delete(:copublishers)
160
+ .split("/").map(&:strip).reject(&:empty?)
161
+ .map { |cp| { copublisher: cp } }
162
+ end
163
+
164
+ # Peel supplement layers outermost-first: each layer re-keys its
165
+ # prefixed fields onto the builder-input names, nests the rest as
166
+ # its :base, and re-enters build — the same shape the grammar's
167
+ # tree used to nest.
168
+ SUPPLEMENT_LAYERS.each do |layer|
169
+ next unless parsed_hash.keys.any? do |key|
170
+ key.to_s.start_with?(layer[:prefix])
171
+ end
172
+
173
+ return build_supplement_layer(parsed_hash, layer, all_parts)
174
+ end
175
+
176
+ # Joint identifier (ISO x|IDF y): the base designation's fields
177
+ # stay plain, the IDF side re-keys into the tree-shape hash the
178
+ # IDF builder consumes, and the pair routes through the
179
+ # CombinedIdentifier via the :joint_identifier key.
180
+ if parsed_hash.key?(:idf_publisher)
181
+ return build_joint_identifier(parsed_hash, all_parts)
182
+ end
183
+
184
+ # Directive supplement (ISO/IEC DIR 1 ISO SUP): the dsupp_* fields
185
+ # are the supplement's, the plain fields its base directive's.
186
+ if parsed_hash.key?(:dsupp_type_with_stage)
187
+ return build_directive_supplement(parsed_hash, all_parts)
188
+ end
189
+
190
+ # Bundled directives ("DIR 1:2022 + IEC SUP:2022"): the plain
191
+ # fields are the base document's; each bundle item captures under
192
+ # its bs1_/bs2_/bs3_ prefix.
193
+ if BUNDLE_PREFIXES.any? do |prefix|
194
+ parsed_hash.keys.any? { |key| key.to_s.start_with?(prefix) }
195
+ end
196
+ return build_bundled(parsed_hash, all_parts)
197
+ end
198
+
76
199
  # For ISO/R legacy format, split into publisher and type
77
200
  if parsed_hash[:iso_r_prefix]
78
201
  parsed_hash[:publisher] = "ISO"
@@ -80,6 +203,12 @@ module Pubid
80
203
  parsed_hash.delete(:iso_r_prefix)
81
204
  end
82
205
 
206
+ # The base document's own edition captures as base_edition (the
207
+ # supplement layers' third_part editions own the plain key), so
208
+ # it re-keys onto :edition only here — at the innermost base,
209
+ # after every wrapper layer has been peeled off.
210
+ parsed_hash[:edition] = parsed_hash.delete(:base_edition) if parsed_hash.key?(:base_edition)
211
+
83
212
  # For NSB-prefixed identifiers (FprISO, WD/ISO), set stage
84
213
  if parsed_hash[:nsb_stage]
85
214
  nsb_stage = parsed_hash.delete(:nsb_stage)
@@ -94,12 +223,11 @@ module Pubid
94
223
 
95
224
  # For French GUIDE entries: "Guide ISO/CEI 37:1995". The rename
96
225
  # must happen BEFORE class selection — locate_identifier_klass
97
- # reads :type_with_stage, and the tree of a guide-first spelling
98
- # carries an empty :type_with_stage plus the type under
99
- # :type_with_stage_fr, so deferring the rename selected the
100
- # default International Standard class for every guide-first
101
- # (and Cyrillic "Руководства ИСО …") reference.
102
- if type_with_stage_fr = parsed_hash.delete(:type_with_stage_fr)
226
+ # reads :type_with_stage, and the bound map of a guide-first
227
+ # spelling carries the type under :type_with_stage_fr, so
228
+ # deferring the rename selected the default International
229
+ # Standard class for every guide-first reference.
230
+ if (type_with_stage_fr = parsed_hash.delete(:type_with_stage_fr))
103
231
  parsed_hash[:type_with_stage] = type_with_stage_fr
104
232
  end
105
233
 
@@ -136,6 +264,89 @@ module Pubid
136
264
  all_parts ? identifier.to_all_parts : identifier
137
265
  end
138
266
 
267
+ # "ISO 5537|IDF 26": the ISO side builds as the combined
268
+ # identifier's base, the IDF side re-keys into the tree-shape hash
269
+ # Pubid::Idf.build_from_parse consumes and lands in
270
+ # :joint_identifier.
271
+ def build_joint_identifier(hash, all_parts)
272
+ base_fields = hash.except(*IDF_KEYS)
273
+ idf_fields = rekey(hash.slice(*IDF_REKEY.keys), IDF_REKEY)
274
+
275
+ if (supp_keys = IDF_SUPP_REKEY.keys & hash.keys).any?
276
+ idf_fields = {
277
+ base: idf_fields,
278
+ **rekey(hash.slice(*supp_keys), IDF_SUPP_REKEY),
279
+ }
280
+ end
281
+
282
+ inner = { base: base_fields, joint_identifier: idf_fields }
283
+ inner[:all_parts] = all_parts if all_parts
284
+ build(inner)
285
+ end
286
+
287
+ # One supplement layer: re-key the layer's prefixed fields, move
288
+ # the shared plain third-part fields onto the layer where the
289
+ # grammar captures them there, nest everything else as the layer's
290
+ # base, and re-enter build.
291
+ def build_supplement_layer(hash, layer, all_parts)
292
+ prefix = layer[:prefix]
293
+ layer_hash = hash.select { |key, _| key.to_s.start_with?(prefix) }
294
+ fields = rekey_prefix(layer_hash, prefix)
295
+
296
+ if layer[:plain_third_part]
297
+ third_part = hash.slice(*PLAIN_THIRD_PART_KEYS)
298
+ fields = { **third_part, **fields }
299
+ end
300
+
301
+ rest = hash.except(*layer_hash.keys)
302
+ rest = rest.except(*PLAIN_THIRD_PART_KEYS) if layer[:plain_third_part]
303
+
304
+ inner = { **fields, base: rest }
305
+ inner[:all_parts] = all_parts if all_parts
306
+ build(inner)
307
+ end
308
+
309
+ def build_directive_supplement(hash, all_parts)
310
+ fields = rekey_prefix(hash.slice(*DSUPP_KEYS), "dsupp_")
311
+ rest = hash.except(*DSUPP_KEYS)
312
+
313
+ inner = { **fields, base: rest }
314
+ inner[:all_parts] = all_parts if all_parts
315
+ build(inner)
316
+ end
317
+
318
+ # Bundled directives: each bundle item's prefixed fields re-key to
319
+ # the supplement hash and build a DirectivesSupplement, the plain
320
+ # fields build the base document, and the pair lands on a
321
+ # BundledIdentifier.
322
+ def build_bundled(hash, all_parts)
323
+ bundle_keys = BUNDLE_PREFIXES.flat_map do |prefix|
324
+ hash.keys.select { |key| key.to_s.start_with?(prefix) }
325
+ end
326
+ supplements = BUNDLE_PREFIXES.filter_map do |prefix|
327
+ keys = hash.keys.select { |key| key.to_s.start_with?(prefix) }
328
+ next if keys.empty?
329
+
330
+ build(rekey_prefix(hash.slice(*keys), prefix))
331
+ end
332
+ identifier = BundledIdentifier.new
333
+ identifier.base_document = build(hash.except(*bundle_keys))
334
+ identifier.supplements = supplements
335
+ all_parts ? identifier.to_all_parts : identifier
336
+ end
337
+
338
+ # Strip a layer prefix from every key ("supp_" / "supp2_" /
339
+ # "supp3_" / "isor_supp_" / "dsupp_").
340
+ def rekey_prefix(hash, prefix)
341
+ hash.to_h do |key, value|
342
+ [key.to_s.delete_prefix(prefix).to_sym, value]
343
+ end
344
+ end
345
+
346
+ def rekey(hash, map)
347
+ hash.to_h { |key, value| [map[key], value] }
348
+ end
349
+
139
350
  def handle_key(identifier, key, value)
140
351
  if key == :joint_identifier
141
352
  identifier.additional_identifiers ||= []
@@ -150,8 +361,8 @@ module Pubid
150
361
  # "4037/1979", "4037-1979", and "ISO/IEC DIR 1 IEC" all split cleanly.
151
362
  def normalize_number_with_part(value)
152
363
  value.to_s
153
- .tr("#{Parser::DASH_CHARS.join}/", "-")
154
- .gsub(" ", "-")
364
+ .tr("#{Parser::DASH_CHARS.join}/", "-")
365
+ .gsub(" ", "-")
155
366
  end
156
367
 
157
368
  # LEGACY: "ISO 4037-1979" used a hyphen where the modern form is
@@ -190,7 +401,7 @@ module Pubid
190
401
  # (e.g. a TC document, which skips the copublisher merge) matches
191
402
  # the [] convention used by copublisher-merged parses and by
192
403
  # Identifier.create. Otherwise equality fails on [] vs nil.
193
- Pubid::Iso::Components::Publisher.new(publisher: value,
404
+ Pubid::Iso::Components::Publisher.new(publisher: value,
194
405
  copublisher: [])
195
406
  end
196
407
 
@@ -285,10 +496,17 @@ module Pubid
285
496
  # Handle JTC 1 subgroup in directives (ISO/IEC JTC 1 DIR)
286
497
  value.to_s
287
498
 
499
+ when :publisher_name
500
+ # The grammar's publisher_names preprocess binding (the ISO
501
+ # organization's full name, consumed by the ts artifact's
502
+ # schema materialization). The Ruby identifier carries only
503
+ # the Publisher component, so the field assigns nothing.
504
+ nil
505
+
288
506
  when :supplements
289
507
  # Handle bundled supplements (+ operator)
290
508
  # Each supplement is a hash that needs to be built
291
- value.map { |supplement_hash| build(supplement_hash[:supplement]) }
509
+ value.map { |supplement_hash| build(supplement_hash) }
292
510
 
293
511
  when :base_document
294
512
  # For bundled identifiers, build the base document
@@ -301,10 +519,6 @@ module Pubid
301
519
  when :tc_number, :sc_number, :wg_number
302
520
  value.to_s
303
521
 
304
- when :year
305
- # For TC documents with year, convert to Date
306
- Pubid::Components::Date.new(year: value.to_s)
307
-
308
522
  when :number
309
523
  # For TC documents, number is the document number (N number)
310
524
  # For regular identifiers, this is handled in :number_with_part