pubid 2.0.0.pre.alpha.12 → 2.0.0.pre.alpha.13

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 (227) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +43 -1
  3. data/data/ieee/update_codes.yaml +17 -4
  4. data/data/nist/update_codes.yaml +7 -3
  5. data/lib/pubid/adobe/builder.rb +2 -0
  6. data/lib/pubid/all_parts.rb +201 -0
  7. data/lib/pubid/all_parts_identifier.rb +19 -0
  8. data/lib/pubid/amca/CLAUDE.md +47 -0
  9. data/lib/pubid/amca/builder.rb +3 -5
  10. data/lib/pubid/amca/identifiers/base.rb +10 -0
  11. data/lib/pubid/amca/identifiers/publication.rb +13 -0
  12. data/lib/pubid/amca/parser.rb +2 -1
  13. data/lib/pubid/amca/renderer.rb +22 -33
  14. data/lib/pubid/amca/urn_generator.rb +21 -2
  15. data/lib/pubid/amca/urn_parser.rb +36 -10
  16. data/lib/pubid/ansi/builder.rb +6 -0
  17. data/lib/pubid/api/CLAUDE.md +23 -0
  18. data/lib/pubid/api/builder.rb +2 -0
  19. data/lib/pubid/ashrae/CLAUDE.md +13 -0
  20. data/lib/pubid/ashrae/builder.rb +58 -14
  21. data/lib/pubid/ashrae/identifiers/errata.rb +14 -2
  22. data/lib/pubid/ashrae/identifiers/interpretation.rb +2 -10
  23. data/lib/pubid/ashrae/parser.rb +62 -28
  24. data/lib/pubid/ashrae/renderer.rb +32 -1
  25. data/lib/pubid/ashrae/urn_generator.rb +32 -9
  26. data/lib/pubid/asme/CLAUDE.md +25 -0
  27. data/lib/pubid/asme/builder.rb +16 -9
  28. data/lib/pubid/asme/components/code.rb +2 -0
  29. data/lib/pubid/asme/identifiers/standard.rb +6 -1
  30. data/lib/pubid/asme/parser.rb +41 -14
  31. data/lib/pubid/astm/CLAUDE.md +9 -0
  32. data/lib/pubid/astm/builder.rb +2 -0
  33. data/lib/pubid/astm/components/code.rb +2 -0
  34. data/lib/pubid/astm/parser.rb +4 -1
  35. data/lib/pubid/bipm/CLAUDE.md +11 -0
  36. data/lib/pubid/bipm/builder.rb +2 -0
  37. data/lib/pubid/bsi/CLAUDE.md +93 -0
  38. data/lib/pubid/bsi/builder.rb +13 -11
  39. data/lib/pubid/bsi/identifiers/addendum_document.rb +2 -0
  40. data/lib/pubid/bsi/identifiers/adopted_european_norm.rb +6 -54
  41. data/lib/pubid/bsi/identifiers/adopted_international_standard.rb +5 -22
  42. data/lib/pubid/bsi/identifiers/amendment.rb +36 -12
  43. data/lib/pubid/bsi/identifiers/bundled_identifier.rb +2 -0
  44. data/lib/pubid/bsi/identifiers/consolidated_identifier.rb +23 -26
  45. data/lib/pubid/bsi/identifiers/corrigendum.rb +29 -12
  46. data/lib/pubid/bsi/identifiers/expert_commentary.rb +6 -7
  47. data/lib/pubid/bsi/identifiers/national_annex.rb +18 -20
  48. data/lib/pubid/bsi/identifiers/root_identity.rb +31 -0
  49. data/lib/pubid/bsi/identifiers/set.rb +2 -0
  50. data/lib/pubid/bsi/identifiers/supplement_document.rb +2 -0
  51. data/lib/pubid/bsi/identifiers.rb +1 -0
  52. data/lib/pubid/bsi/parser.rb +8 -8
  53. data/lib/pubid/bsi/renderer.rb +20 -20
  54. data/lib/pubid/bsi/urn_generator.rb +28 -18
  55. data/lib/pubid/builder/base.rb +27 -0
  56. data/lib/pubid/calconnect/builder.rb +2 -0
  57. data/lib/pubid/calconnect/identifier.rb +4 -0
  58. data/lib/pubid/ccsds/builder.rb +2 -0
  59. data/lib/pubid/ccsds/identifier.rb +8 -0
  60. data/lib/pubid/cen_cenelec/CLAUDE.md +59 -0
  61. data/lib/pubid/cen_cenelec/builder.rb +6 -1
  62. data/lib/pubid/cen_cenelec/identifier.rb +10 -27
  63. data/lib/pubid/cen_cenelec/identifiers/amendment.rb +3 -10
  64. data/lib/pubid/cen_cenelec/identifiers/corrigendum.rb +3 -10
  65. data/lib/pubid/cen_cenelec/parser.rb +11 -3
  66. data/lib/pubid/cie/CLAUDE.md +58 -0
  67. data/lib/pubid/cie/builder.rb +2 -0
  68. data/lib/pubid/cie/components/language.rb +2 -0
  69. data/lib/pubid/components/adoption.rb +2 -0
  70. data/lib/pubid/components/code.rb +2 -0
  71. data/lib/pubid/components/date.rb +8 -6
  72. data/lib/pubid/components/edition.rb +2 -0
  73. data/lib/pubid/components/iteration.rb +2 -0
  74. data/lib/pubid/components/language.rb +2 -0
  75. data/lib/pubid/components/locality.rb +2 -0
  76. data/lib/pubid/components/publisher.rb +2 -0
  77. data/lib/pubid/components/relationship.rb +2 -0
  78. data/lib/pubid/components/stage.rb +2 -0
  79. data/lib/pubid/components/supplement.rb +2 -0
  80. data/lib/pubid/components/type.rb +2 -0
  81. data/lib/pubid/components/typed_stage.rb +8 -0
  82. data/lib/pubid/csa/CLAUDE.md +41 -0
  83. data/lib/pubid/csa/builder.rb +2 -0
  84. data/lib/pubid/csa/identifier.rb +13 -1
  85. data/lib/pubid/csa/renderer.rb +12 -12
  86. data/lib/pubid/csa/single_identifier.rb +17 -0
  87. data/lib/pubid/doi/builder.rb +2 -0
  88. data/lib/pubid/easc/builder.rb +2 -0
  89. data/lib/pubid/ecma/CLAUDE.md +28 -0
  90. data/lib/pubid/ecma/builder.rb +2 -0
  91. data/lib/pubid/ecma/identifier.rb +7 -0
  92. data/lib/pubid/etsi/CLAUDE.md +34 -0
  93. data/lib/pubid/etsi/builder.rb +2 -0
  94. data/lib/pubid/etsi/components/code.rb +6 -0
  95. data/lib/pubid/etsi/components/version.rb +2 -0
  96. data/lib/pubid/etsi/identifiers/etsi_standard.rb +7 -0
  97. data/lib/pubid/evs/CLAUDE.md +58 -0
  98. data/lib/pubid/evs/builder.rb +2 -0
  99. data/lib/pubid/gb/CLAUDE.md +140 -0
  100. data/lib/pubid/gb/builder.rb +7 -2
  101. data/lib/pubid/gb/identifier.rb +5 -3
  102. data/lib/pubid/gb/identifiers/all_parts.rb +17 -0
  103. data/lib/pubid/gb/identifiers.rb +1 -0
  104. data/lib/pubid/gb/renderer.rb +0 -1
  105. data/lib/pubid/gost/CLAUDE.md +64 -0
  106. data/lib/pubid/gost/builder.rb +3 -1
  107. data/lib/pubid/gost/identifier.rb +5 -0
  108. data/lib/pubid/iala/CLAUDE.md +82 -0
  109. data/lib/pubid/iala/builder.rb +2 -0
  110. data/lib/pubid/iana/CLAUDE.md +7 -0
  111. data/lib/pubid/iana/builder.rb +2 -0
  112. data/lib/pubid/identifier.rb +161 -17
  113. data/lib/pubid/idf/builder.rb +6 -1
  114. data/lib/pubid/idf/identifier.rb +5 -0
  115. data/lib/pubid/idf/identifiers/all_parts.rb +17 -0
  116. data/lib/pubid/idf/identifiers.rb +1 -0
  117. data/lib/pubid/iec/CLAUDE.md +31 -0
  118. data/lib/pubid/iec/builder.rb +7 -1
  119. data/lib/pubid/iec/components/consolidated_amendment.rb +4 -0
  120. data/lib/pubid/iec/components/sheet.rb +2 -0
  121. data/lib/pubid/iec/components/trf_info.rb +2 -0
  122. data/lib/pubid/iec/components/vap_suffix.rb +2 -0
  123. data/lib/pubid/iec/identifier.rb +7 -2
  124. data/lib/pubid/iec/identifiers/all_parts.rb +19 -0
  125. data/lib/pubid/iec/identifiers.rb +1 -0
  126. data/lib/pubid/iec/renderer.rb +0 -1
  127. data/lib/pubid/iec/urn_generator.rb +9 -1
  128. data/lib/pubid/iec/urn_parser.rb +3 -2
  129. data/lib/pubid/ieee/CLAUDE.md +97 -0
  130. data/lib/pubid/ieee/builder.rb +134 -5
  131. data/lib/pubid/ieee/components/code.rb +2 -0
  132. data/lib/pubid/ieee/components/draft.rb +35 -2
  133. data/lib/pubid/ieee/components/typed_stage.rb +2 -0
  134. data/lib/pubid/ieee/identifiers/base.rb +20 -0
  135. data/lib/pubid/ieee/identifiers/iec_ieee_copublished.rb +9 -0
  136. data/lib/pubid/ieee/identifiers/joint_development.rb +17 -10
  137. data/lib/pubid/ieee/identifiers/project_draft_identifier.rb +8 -1
  138. data/lib/pubid/ieee/parser.rb +134 -21
  139. data/lib/pubid/ieee/renderer.rb +40 -7
  140. data/lib/pubid/ieee/urn_generator.rb +31 -0
  141. data/lib/pubid/ietf/CLAUDE.md +7 -0
  142. data/lib/pubid/ietf/builder.rb +2 -0
  143. data/lib/pubid/iho/builder.rb +2 -0
  144. data/lib/pubid/isbn/builder.rb +2 -0
  145. data/lib/pubid/iso/CLAUDE.md +47 -0
  146. data/lib/pubid/iso/builder.rb +19 -5
  147. data/lib/pubid/iso/components/publisher.rb +2 -0
  148. data/lib/pubid/iso/identifier.rb +6 -14
  149. data/lib/pubid/iso/identifiers/all_parts.rb +19 -0
  150. data/lib/pubid/iso/identifiers/directives_supplement.rb +4 -2
  151. data/lib/pubid/iso/identifiers.rb +1 -0
  152. data/lib/pubid/iso/rendering_style.rb +0 -1
  153. data/lib/pubid/itu/CLAUDE.md +69 -0
  154. data/lib/pubid/itu/builder.rb +2 -0
  155. data/lib/pubid/itu/components/code.rb +2 -0
  156. data/lib/pubid/itu/components/designation.rb +2 -0
  157. data/lib/pubid/itu/components/sector.rb +2 -0
  158. data/lib/pubid/itu/components/series.rb +2 -0
  159. data/lib/pubid/itu/identifiers/supplement.rb +15 -0
  160. data/lib/pubid/jcgm/CLAUDE.md +7 -0
  161. data/lib/pubid/jcgm/builder.rb +2 -0
  162. data/lib/pubid/jcgm/components/publisher.rb +2 -0
  163. data/lib/pubid/jis/builder.rb +5 -1
  164. data/lib/pubid/jis/identifier.rb +5 -17
  165. data/lib/pubid/jis/identifiers/all_parts.rb +19 -0
  166. data/lib/pubid/jis/identifiers.rb +1 -0
  167. data/lib/pubid/jis/renderer.rb +0 -2
  168. data/lib/pubid/jis/urn_generator.rb +0 -1
  169. data/lib/pubid/nist/CLAUDE.md +56 -0
  170. data/lib/pubid/nist/builder.rb +2 -0
  171. data/lib/pubid/nist/components/edition.rb +2 -0
  172. data/lib/pubid/nist/components/issue_number.rb +2 -0
  173. data/lib/pubid/nist/components/part.rb +2 -0
  174. data/lib/pubid/nist/components/stage.rb +2 -0
  175. data/lib/pubid/nist/components/supplement.rb +2 -0
  176. data/lib/pubid/nist/components/translation.rb +2 -0
  177. data/lib/pubid/nist/components/update.rb +2 -0
  178. data/lib/pubid/nist/components/version.rb +2 -0
  179. data/lib/pubid/nist/components/volume.rb +2 -0
  180. data/lib/pubid/nist/identifiers/base.rb +24 -3
  181. data/lib/pubid/nist/parser.rb +15 -2
  182. data/lib/pubid/nist/preprocessor.rb +53 -2
  183. data/lib/pubid/oasis/CLAUDE.md +19 -0
  184. data/lib/pubid/oasis/builder.rb +2 -0
  185. data/lib/pubid/oasis/identifier.rb +19 -0
  186. data/lib/pubid/ogc/CLAUDE.md +34 -0
  187. data/lib/pubid/ogc/builder.rb +2 -0
  188. data/lib/pubid/ogc/identifier.rb +11 -0
  189. data/lib/pubid/oiml/CLAUDE.md +189 -0
  190. data/lib/pubid/oiml/builder.rb +20 -0
  191. data/lib/pubid/oiml/components/code.rb +6 -0
  192. data/lib/pubid/oiml/identifier.rb +13 -0
  193. data/lib/pubid/oiml/identifiers/annex.rb +4 -0
  194. data/lib/pubid/oiml/identifiers/certification_system.rb +34 -0
  195. data/lib/pubid/oiml/identifiers/code_number.rb +8 -0
  196. data/lib/pubid/oiml/identifiers/dual_published.rb +174 -0
  197. data/lib/pubid/oiml/identifiers.rb +2 -0
  198. data/lib/pubid/oiml/parser.rb +35 -4
  199. data/lib/pubid/oiml/renderer.rb +23 -1
  200. data/lib/pubid/oiml/single_identifier.rb +4 -0
  201. data/lib/pubid/oiml/supplement_identifier.rb +7 -0
  202. data/lib/pubid/oiml/urn_generator.rb +28 -0
  203. data/lib/pubid/oiml.rb +5 -0
  204. data/lib/pubid/omg/CLAUDE.md +15 -0
  205. data/lib/pubid/omg/builder.rb +2 -0
  206. data/lib/pubid/parser/grammar.rb +23 -0
  207. data/lib/pubid/plateau/builder.rb +2 -0
  208. data/lib/pubid/plateau/identifiers/base.rb +4 -0
  209. data/lib/pubid/plateau/supplement_identifier.rb +14 -2
  210. data/lib/pubid/plateau/urn_generator.rb +7 -1
  211. data/lib/pubid/renderers/human_readable.rb +0 -1
  212. data/lib/pubid/sae/builder.rb +2 -0
  213. data/lib/pubid/sae/components/date.rb +2 -0
  214. data/lib/pubid/sae/components/type.rb +2 -0
  215. data/lib/pubid/subset_match.rb +197 -0
  216. data/lib/pubid/tgpp/CLAUDE.md +43 -0
  217. data/lib/pubid/tgpp/builder.rb +2 -0
  218. data/lib/pubid/tgpp/identifier.rb +14 -0
  219. data/lib/pubid/type_resolver.rb +14 -2
  220. data/lib/pubid/un/builder.rb +2 -0
  221. data/lib/pubid/version.rb +1 -1
  222. data/lib/pubid/w3c/CLAUDE.md +7 -0
  223. data/lib/pubid/w3c/builder.rb +2 -0
  224. data/lib/pubid/xsf/CLAUDE.md +11 -0
  225. data/lib/pubid/xsf/builder.rb +2 -0
  226. data/lib/pubid.rb +16 -3
  227. metadata +44 -2
@@ -15,6 +15,10 @@ module Pubid
15
15
  map "year_on_base", to: :year_on_base
16
16
  end
17
17
 
18
+ # A nil `letter` means the plural "Annexes" reference, not "any
19
+ # annex": it must not match a candidate naming a specific one.
20
+ subset_strict :letter
21
+
18
22
  def supplement_type
19
23
  letter ? "Annex #{letter}" : "Annexes"
20
24
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Oiml
5
+ module Identifiers
6
+ # OIML-CS certification-system documents ("OIML-CS PD-05 Edition 6").
7
+ # The number keeps its printed zero padding ("05"); family is the
8
+ # PD/OD/CID document class; the print states "Edition N" instead of a
9
+ # year, and may carry a parenthesized trailing amendment.
10
+ class CertificationSystem < SingleIdentifier
11
+ attribute :number, :string
12
+ attribute :family, :string
13
+ attribute :amendment, :string
14
+ # "PD 05" prints space-separated where "PD-05" is dash-joined.
15
+ attribute :space_separator, :boolean, default: false
16
+
17
+ key_value do
18
+ map "number", to: :number
19
+ map "family", to: :family
20
+ map "amendment", to: :amendment
21
+ map "space_separator", to: :space_separator
22
+ end
23
+
24
+ def self.subset_ignored_attributes
25
+ super + [:space_separator]
26
+ end
27
+
28
+ def type_string
29
+ "CS"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -34,6 +34,7 @@ module Pubid
34
34
  def self.included(base)
35
35
  install_attributes(base)
36
36
  install_mappings(base)
37
+ install_subset_rules(base)
37
38
  end
38
39
 
39
40
  def self.install_attributes(base)
@@ -53,6 +54,13 @@ module Pubid
53
54
  base.attribute :space_suffix, :boolean, default: -> { false }
54
55
  end
55
56
 
57
+ # A nil `part`, `subpart` or `suffix` means the document has none of
58
+ # them: `OIML R 138` is not `OIML R 138-Amend`, which is its
59
+ # amendment. Declared on each leaf, beside the columns it governs.
60
+ def self.install_subset_rules(base)
61
+ base.subset_strict :part, :subpart, :suffix
62
+ end
63
+
56
64
  # Merged by lutaml with the block SingleIdentifier declares, which
57
65
  # carries publisher/year/edition/stage/iteration.
58
66
  def self.install_mappings(base)
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Oiml
5
+ module Identifiers
6
+ # OIML sometimes co-publishes a document jointly with another SDO
7
+ # (ISO confirmed so far, per pubid issue #437). The printed reference
8
+ # carries both identifiers, joined by a bare "|":
9
+ #
10
+ # ISO 4064-1:2024|OIML R 49-1:2024
11
+ #
12
+ # A sibling of SingleIdentifier/SupplementIdentifier — like
13
+ # SupplementIdentifier, it inherits NOTHING from SingleIdentifier and
14
+ # must delegate every identity-bearing reader (code/type/stage/
15
+ # iteration/publisher, the mr_* MR-slug hooks, #root) itself. Without
16
+ # the mr_* delegations, #to_mr_string would silently come out "" (the
17
+ # same filename-collision gap already documented for
18
+ # Amendment/Errata/Annex in lib/pubid/oiml/CLAUDE.md) — this class closes
19
+ # that gap from day one instead of repeating it.
20
+ #
21
+ # `first`/`second` hold the two sides in their original left-to-right
22
+ # print order, each a real, independently-typed `::Pubid::Identifier`
23
+ # (never an attr_accessor — see IEEE's CsaDualPublished for the bug
24
+ # that pattern causes: to_hash/from_hash/#exclude silently drop it).
25
+ # Either side may be OIML; `#oiml_identifier`/`#external_identifier`
26
+ # pick the OIML-typed one out of the pair regardless of order.
27
+ class DualPublished < Identifier
28
+ attribute :first, ::Pubid::Identifier, polymorphic: true
29
+ attribute :second, ::Pubid::Identifier, polymorphic: true
30
+
31
+ # Declared locally (rather than left to the inherited default)
32
+ # because Oiml::Identifier's shared key_value block maps "language"
33
+ # and "parsed_format" to real attributes — without a local
34
+ # declaration here, to_hash/from_hash would call a getter/setter
35
+ # this class never defines. Mirrors SingleIdentifier/
36
+ # SupplementIdentifier, which do the same for the same reason.
37
+ attribute :language, :string
38
+ attribute :parsed_format, :string, default: -> { "short" }
39
+
40
+ key_value do
41
+ map "first", with: { to: :first_to_kv, from: :first_from_kv }
42
+ map "second", with: { to: :second_to_kv, from: :second_from_kv }
43
+ end
44
+
45
+ # Either side may belong to any flavor, so (de)serialization goes
46
+ # through the generic top-level Pubid.from_hash/#to_hash, not the
47
+ # OIML-scoped Identifier.from_hash (which only resolves OIML types).
48
+ def first_to_kv(model, doc)
49
+ value = model.first
50
+ return unless value
51
+
52
+ doc.add_child(
53
+ Lutaml::KeyValue::DataModel::Element.new("first", value.to_hash),
54
+ )
55
+ end
56
+
57
+ def first_from_kv(model, value)
58
+ model.first = ::Pubid.from_hash(value) if value
59
+ end
60
+
61
+ def second_to_kv(model, doc)
62
+ value = model.second
63
+ return unless value
64
+
65
+ doc.add_child(
66
+ Lutaml::KeyValue::DataModel::Element.new("second", value.to_hash),
67
+ )
68
+ end
69
+
70
+ def second_from_kv(model, value)
71
+ model.second = ::Pubid.from_hash(value) if value
72
+ end
73
+
74
+ # The member that belongs to THIS flavor, wherever print order put
75
+ # it. relaton-index sorts on this side's root.number.
76
+ def oiml_identifier
77
+ [first, second].find { |member| member.is_a?(Oiml::Identifier) }
78
+ end
79
+
80
+ # The other SDO's identifier for the same document.
81
+ def external_identifier
82
+ [first, second].find { |member| !member.is_a?(Oiml::Identifier) }
83
+ end
84
+
85
+ def root
86
+ oiml_identifier ? oiml_identifier.root : self
87
+ end
88
+
89
+ def code
90
+ oiml_identifier&.code
91
+ end
92
+
93
+ def type
94
+ oiml_identifier&.type
95
+ end
96
+
97
+ def stage
98
+ oiml_identifier&.stage
99
+ end
100
+
101
+ def iteration
102
+ oiml_identifier&.iteration
103
+ end
104
+
105
+ def publisher
106
+ oiml_identifier&.publisher
107
+ end
108
+
109
+ def mr_publisher
110
+ oiml_identifier&.mr_publisher
111
+ end
112
+
113
+ def mr_type
114
+ oiml_identifier&.mr_type
115
+ end
116
+
117
+ def mr_number_with_part
118
+ oiml_identifier&.mr_number_with_part
119
+ end
120
+
121
+ def mr_year
122
+ oiml_identifier&.mr_year
123
+ end
124
+
125
+ # Splits `identifier` on "|" and, if it looks like an OIML dual-
126
+ # published reference (exactly two non-empty sides, exactly one of
127
+ # which starts with an OIML prefix), parses each side through its
128
+ # own flavor and returns the wrapper. Returns nil for anything else,
129
+ # so the caller (Pubid::Oiml.parse) falls through to the ordinary
130
+ # grammar, which raises the standard Parslet::ParseFailed for a
131
+ # string containing "|" (the grammar defines no rule for it).
132
+ def self.build(identifier)
133
+ sides = split_sides(identifier)
134
+ return nil unless sides
135
+
136
+ left, right = parse_sides(*sides)
137
+ return nil unless left && right
138
+
139
+ new(first: left, second: right)
140
+ end
141
+
142
+ # Two non-empty, stripped sides, or nil if `identifier` isn't
143
+ # shaped like "A|B".
144
+ def self.split_sides(identifier)
145
+ parts = identifier.split("|").map(&:strip)
146
+ return nil unless parts.length == 2
147
+ return nil if parts.any?(&:empty?)
148
+
149
+ parts
150
+ end
151
+ private_class_method :split_sides
152
+
153
+ # Parses each side through its own flavor, provided exactly one side
154
+ # looks like OIML (by prefix, not position). [nil, nil] otherwise.
155
+ def self.parse_sides(left, right)
156
+ left_is_oiml = Oiml::PREFIXES.any? { |p| left.start_with?(p) }
157
+ right_is_oiml = Oiml::PREFIXES.any? { |p| right.start_with?(p) }
158
+ return [nil, nil] if left_is_oiml == right_is_oiml
159
+
160
+ [parse_side(left, oiml: left_is_oiml),
161
+ parse_side(right, oiml: right_is_oiml)]
162
+ end
163
+ private_class_method :parse_sides
164
+
165
+ def self.parse_side(str, oiml:)
166
+ oiml ? Oiml.parse(str) : ::Pubid.parse(str)
167
+ rescue Parslet::ParseFailed, Pubid::Errors::InvalidInputError
168
+ nil
169
+ end
170
+ private_class_method :parse_side
171
+ end
172
+ end
173
+ end
174
+ end
@@ -7,8 +7,10 @@ module Pubid
7
7
  autoload :Annex, "#{__dir__}/identifiers/annex"
8
8
  autoload :BasicPublication, "#{__dir__}/identifiers/basic_publication"
9
9
  autoload :Bulletin, "#{__dir__}/identifiers/bulletin"
10
+ autoload :CertificationSystem, "#{__dir__}/identifiers/certification_system"
10
11
  autoload :CodeNumber, "#{__dir__}/identifiers/code_number"
11
12
  autoload :Document, "#{__dir__}/identifiers/document"
13
+ autoload :DualPublished, "#{__dir__}/identifiers/dual_published"
12
14
  autoload :Errata, "#{__dir__}/identifiers/errata"
13
15
  autoload :ExpertReport, "#{__dir__}/identifiers/expert_report"
14
16
  autoload :Guide, "#{__dir__}/identifiers/guide"
@@ -20,14 +20,17 @@ module Pubid
20
20
  rule(:identifier) do
21
21
  amendment_identifier | amendment_short | annex_letter_identifier |
22
22
  annex_identifier | plus_supplement_identifier |
23
- trailing_supplement_identifier | bulletin_identifier | base
23
+ trailing_supplement_identifier | cs_identifier |
24
+ bulletin_identifier | base
24
25
  end
25
26
 
26
27
  # Publisher - always "OIML"
27
28
  rule(:publisher) { str("OIML").as(:publisher) >> space }
28
29
 
29
- # Document type - single letter
30
- rule(:doc_type) { match("[BDEGRSVX]").as(:type) >> space }
30
+ # Document type - single letter. Strict family set: OIML publishes
31
+ # R D B G E V S documents (the estate grammar's family letters);
32
+ # any other letter is a rejection, not a flavor.
33
+ rule(:doc_type) { match("[BDEGRSV]").as(:type) >> space }
31
34
 
32
35
  # Bulletin locator — structured form. Year optionally followed by
33
36
  # 2-digit issue and 2-digit sequence:
@@ -128,7 +131,7 @@ module Pubid
128
131
  # (with optional space before year)
129
132
  rule(:date) do
130
133
  edition_portion |
131
- (colon >> space.maybe >> year_digits.as(:year)) |
134
+ (space.maybe >> colon >> space.maybe >> year_digits.as(:year)) |
132
135
  (space.maybe >> lparen >> year_digits.as(:year) >> rparen)
133
136
  end
134
137
 
@@ -167,10 +170,22 @@ module Pubid
167
170
  match("[a-z]").repeat(2, 2) # Two letters: en, fr, etc.
168
171
  end
169
172
 
173
+ # Full-word language markers as OIML prints them ("(Fra)", "(Eng)"),
174
+ # three or more letters, any case. Kept verbatim on the identifier;
175
+ # the URN lowercases.
176
+ rule(:lang_word) do
177
+ match("[A-Za-z]").repeat(3)
178
+ end
179
+
170
180
  rule(:language_code) do
171
181
  (
172
182
  (lang_single >> slash >> lang_single) | # E/F
173
183
  lang_multi_oiml | # PO, PT, PE, SR
184
+ lang_word | # Fra, eng, rus — before
185
+ # the letter rules: a
186
+ # committed "F" of "(Fra)"
187
+ # or "fr" of "(fra)" would
188
+ # never fall through
174
189
  lang_single | # E, F, D, R, S, C, A, U, X
175
190
  lang_multi # en, fr
176
191
  ).as(:language)
@@ -216,9 +231,25 @@ module Pubid
216
231
  rule(:trailing_supplement_identifier) do
217
232
  base_without_language.as(:base) >>
218
233
  space >> (str("Amendment") | str("Errata")).as(:trailing_marker) >>
234
+ (space >> digits.as(:number)).maybe >>
219
235
  language_portion.maybe.as(:language)
220
236
  end
221
237
 
238
+ # OIML-CS certification-system documents. Two head spellings
239
+ # ("OIML-CS" / "OIML CS") and two family-number separators
240
+ # ("PD-05" / "PD 05"), an "Edition N" instead of a year, and an
241
+ # optional parenthesized trailing amendment - "(Amendment 1)".
242
+ rule(:cs_identifier) do
243
+ str("OIML").as(:publisher) >>
244
+ (dash >> str("CS") | space >> str("CS")).as(:cs_series) >> space >>
245
+ (str("PD") | str("OD") | str("CID")).as(:cs_family) >>
246
+ (dash | space).as(:cs_separator) >>
247
+ digits.as(:number) >>
248
+ space >> str("Edition") >> space >> digits.as(:edition) >>
249
+ (space >> lparen >> str("Amendment") >> space >>
250
+ digits.as(:cs_amendment) >> rparen).maybe
251
+ end
252
+
222
253
  # Plus-joined supplement - "BASE:YEAR+Supplement:YEAR" form where both
223
254
  # the base and the supplement carry their own year. Used for amendments
224
255
  # and errata to dated bases (e.g. "OIML B 10:2011+Amendment:2012").
@@ -18,10 +18,14 @@ module Pubid
18
18
  @context = context
19
19
 
20
20
  case id
21
+ when Identifiers::DualPublished
22
+ render_dual_published(id)
21
23
  when Identifiers::Annex
22
24
  render_annex(id)
23
25
  when Identifiers::Bulletin
24
26
  render_bulletin(id)
27
+ when Identifiers::CertificationSystem
28
+ render_cs(id)
25
29
  when SupplementIdentifier
26
30
  render_supplement(id)
27
31
  when SingleIdentifier
@@ -33,6 +37,16 @@ module Pubid
33
37
 
34
38
  private
35
39
 
40
+ # Certification-system document: "OIML-CS PD-05 Edition 6 (Amendment 1)".
41
+ # The family-number separator keeps the parsed spelling; OIML CS
42
+ # documents state an edition instead of a year.
43
+ def render_cs(id)
44
+ result = "#{id.publisher}-CS #{id.family}" \
45
+ "#{id.space_separator ? ' ' : '-'}#{id.number} Edition #{id.edition}"
46
+ result += " (Amendment #{id.amendment})" if id.amendment
47
+ result
48
+ end
49
+
36
50
  # Render the Bulletin in the requested or parsed form. Default is the
37
51
  # structured "YYYY-II-SS" form (the dataset's primary docid). The
38
52
  # citation form ("LXVII(2) 20260211") is emitted when the user asks
@@ -93,6 +107,12 @@ module Pubid
93
107
  str.sub(/\s*\([^)]+\)\s*$/, "").strip
94
108
  end
95
109
 
110
+ # Bare "|", original left-to-right print order, e.g.
111
+ # "ISO 4064-1:2024|OIML R 49-1:2024".
112
+ def render_dual_published(id)
113
+ "#{id.first}|#{id.second}"
114
+ end
115
+
96
116
  def render_single(id)
97
117
  format = effective_format(id)
98
118
 
@@ -147,10 +167,12 @@ module Pubid
147
167
 
148
168
  # Trailing-word shorthand: "BASE Amendment" / "BASE Errata" with the
149
169
  # publication year kept on the base identifier. The word comes from the
150
- # concrete supplement class.
170
+ # concrete supplement class; an ordinal, when printed ("Amendment 1"),
171
+ # follows it.
151
172
  if id.trailing
152
173
  base_str = strip_language(id.base.to_s)
153
174
  result = "#{base_str} #{id.supplement_type}"
175
+ result += " #{id.number}" if id.number
154
176
  result += " (#{id.language})" if id.language
155
177
  return result
156
178
  end
@@ -14,6 +14,10 @@ module Pubid
14
14
  "short"
15
15
  } # Track parsed format
16
16
 
17
+ # A nil `language` means the document states none: `OIML R 126:2015
18
+ # Errata` is not `OIML R 126:2015 Errata (E)`, its English edition.
19
+ subset_strict :language
20
+
17
21
  # Serialization delta on top of Oiml::Identifier's shared block. The
18
22
  # `date` (year) component is flattened to a top-level key rather than a
19
23
  # nested hash, mirroring ISO (lib/pubid/iso/identifier.rb). `type` is
@@ -7,8 +7,14 @@ module Pubid
7
7
  # These wrap a base identifier like ISO amendments
8
8
  attribute :base, Oiml::Identifier, polymorphic: true
9
9
  attribute :year, :string
10
+ # Ordinal of the trailing-word form ("OIML R 138:2009 Amendment 1").
11
+ attribute :number, :string
10
12
  attribute :language, :string
11
13
 
14
+ # A nil `language` means the document states none, mirroring
15
+ # SingleIdentifier's rule.
16
+ subset_strict :language
17
+
12
18
  # Delegate the document code to the wrapped standard, mirroring
13
19
  # Pubid::Etsi::Identifiers::SupplementIdentifier#code.
14
20
  #
@@ -54,6 +60,7 @@ module Pubid
54
60
  map "base",
55
61
  with: { to: :base_to_kv, from: :base_from_kv }
56
62
  map "year", to: :year
63
+ map "number", to: :number
57
64
  map "trailing", to: :trailing
58
65
  map "joined", to: :joined
59
66
  end
@@ -27,6 +27,25 @@ module Pubid
27
27
  identifier.language&.to_s&.downcase
28
28
  end
29
29
 
30
+ # DualPublished declares no `date` attribute of its own (it lives on
31
+ # whichever side is OIML), so the shared Base#urn_year — which gates
32
+ # on `identifier.class.attributes.key?(:date)` — would silently drop
33
+ # the year. Read it through the OIML side instead.
34
+ def urn_year
35
+ if identifier.is_a?(Identifiers::DualPublished)
36
+ oiml_date = identifier.oiml_identifier&.date
37
+ return oiml_date&.year&.to_s
38
+ end
39
+
40
+ year = super
41
+ return year if year
42
+
43
+ # The trailing-word form keeps the publication year on the base
44
+ # ("R 138:2009 Amendment 1"); the supplement itself carries only the
45
+ # ordinal.
46
+ identifier.base&.date&.year&.to_s if identifier.is_a?(SupplementIdentifier)
47
+ end
48
+
30
49
  def generate
31
50
  # Bulletin issues carry no code; the (year, issue, sequence) tuple
32
51
  # is the locator. URNs are canonical regardless of how the input was
@@ -39,6 +58,15 @@ module Pubid
39
58
  return parts.join(":")
40
59
  end
41
60
 
61
+ # Certification-system documents: the family-number pair is the
62
+ # document identity ("cs:pd-05"); the printed edition and trailing
63
+ # amendment are print states, not URN segments.
64
+ if identifier.is_a?(Identifiers::CertificationSystem)
65
+ parts = ["urn", "oiml", "cs",
66
+ "#{identifier.family.downcase}-#{identifier.number}"]
67
+ return parts.join(":")
68
+ end
69
+
42
70
  parts = ["urn", "oiml"]
43
71
  parts << urn_type
44
72
  parts << urn_number if urn_number
data/lib/pubid/oiml.rb CHANGED
@@ -29,6 +29,11 @@ module Pubid
29
29
  raise Pubid::Errors::InvalidInputError, Pubid::INPUT_TOO_LONG_MESSAGE
30
30
  end
31
31
 
32
+ if identifier.include?("|")
33
+ dual = Identifiers::DualPublished.build(identifier)
34
+ return dual if dual
35
+ end
36
+
32
37
  parser = Parser.new
33
38
  builder = Builder.new
34
39
 
@@ -0,0 +1,15 @@
1
+ # OMG flavor notes
2
+
3
+ OMG document parts, the separator that normalizes, the acronym charset and its slash, and the bare `beta`.
4
+
5
+ These notes were part of the root `CLAUDE.md`. Read them before you change `lib/pubid/omg/` or `spec/pubid/omg/`. The root file keeps the cross-flavor contract that every flavor obeys.
6
+
7
+ - **The document part reuses the inherited `part`, retyped to `:string`**: an OMG identifier is `OMG <ACRONYM>[ <VERSION>][ <PART>]`, and the third component is a real OMG form, not an invention. OMG published UML 2.1.1 as **two** documents and its URLs carry the segment (`/spec/UML/2.1.1/Superstructure`, `/spec/UML/2.1.1/Infrastructure`); the same position also holds a format name (`/spec/DDS/1.4/PDF`). Both go into one optional component. The attribute is the **`part` that `::Pubid::Identifier` already declares** as a `Components::Code`, retyped to `:string` on `Pubid::Omg::Identifier` — the tranche-1 shape (ansi/api/idf/jcgm/bsi/cen_cenelec). Three things follow from reusing the inherited name rather than inventing a `document_part`: relaton gets `remove_part!` as a plain `exclude(:part)` with no OMG-specific knowledge, `Renderers::Annotator::TOKENS` already carries `[:part, "part"]` so annotation costs nothing, and there is no second, permanently-nil `part` on every identifier. The **placement** is what makes the retype safe: it sits once on `Pubid::Omg::Identifier`, which every OMG identifier inherits from and whose class body lives in one file (`lib/pubid/omg/identifier.rb`) and is never reopened — the condition the root `CLAUDE.md` states for a base-level declaration. Never move it onto `Identifiers::Specification`, and never add a second declaration there: a redeclaration moves the generated accessor's `owner`, which the retype tripwire specs of other flavors treat as a delegation.
8
+ - **The separator normalizes, and that is a matching decision, not a cosmetic one**: OMG writes the part behind either a space or a slash, so `Parser#part_separator` is `space | str("/")` and the parser takes both. The renderer prints **only a space**, so `OMG DDS 1.4/PDF` is a *normalizing* parse rendering `OMG DDS 1.4 PDF`. The alternative — a `part_separator` sibling attribute in the CSA `year_format` shape — round-trips both spellings byte for byte but makes them **not `==`**, and `#matches?` is `exclude(*ignore) == other.exclude(*ignore)`, so a relaton index lookup between the two spellings would return nothing with no error. That is the silent failure mode the root file records as the costliest here, and it is not worth a separator. `spec/pubid/omg/identifier_spec.rb` asserts the normalization **and** the equality, so a future attempt to preserve the separator turns both red. The slash forms therefore cannot live in `spec/fixtures/omg/pass/`, whose spec demands a byte-exact round-trip; they are pinned in the identifier spec instead, with a comment in the fixture file saying why.
9
+ - **The acronym is the URL segment, so it takes every character OMG puts there — and a slash before the version belongs to it.** A consumer (relaton) builds `https://www.omg.org/spec/<acronym>/` from the parsed acronym, so the acronym must be verbatim. The old rule `[A-Z][A-Za-z0-9]*` rejected **30 of the 270** acronyms the catalog names: 25 with a hyphen (`DDS-XTypes`, `IDL4-CPP`), 4 with a slash (`EDMC-FIBO/BE`), `VSIPL++` and the lower-case `smartant`. `Parser#acronym` now starts with any letter, takes letters, digits and `+`, and joins further non-empty segments with `-` or `/`, so a trailing hyphen or slash is never consumed. **The slash is the design decision.** The document-part bullet above made a slash separate the part, and `OMG EDMC-FIBO/BE` could then read as acronym `EDMC-FIBO` with part `BE` — which builds the URL of the wrong page, silently. The rule now is: **before the version a slash is part of the acronym, after the version it separates the part** (`OMG DDS 1.4/PDF` is unchanged; `OMG EDMC-FIBO/BE 1.1/PDF` reads both). `Parser#identifier` spells that out — a part that follows the acronym directly takes only a space. The cost is that `OMG UML/Superstructure` now reads as acronym `UML/Superstructure`; no spec, fixture or relaton corpus row used that spelling, and OMG writes the space. A fixed list of the four FIBO domains was the alternative and was rejected: it drifts when OMG adds a domain. **Known limit — a two-word title still parses.** The grammar cannot tell an acronym from a word, so `OMG Real-Time Extension` reads as acronym `Real-Time`, part `Extension`. This is not new: on `main` before this change, `OMG Model Driven` already read as acronym `Model`, part `Driven`; the wider charset only adds hyphenated and lower-case first words. A longer title (`OMG Model Driven Architecture Guide rev. 2.0`) still raises, because the grammar has no place for a third word. `spec/pubid/omg/identifier_spec.rb` carries all 30 catalog acronyms as a frozen, network-free list. To re-check against the live catalog (270 acronyms, 0 rejections on 2026-09-14): `curl -s https://www.omg.org/spec/ | ruby -e 'puts STDIN.read.scan(%r{spec/(.+?)/About-[^"]*"}).flatten.uniq'`, then `Pubid::Omg.parse("OMG #{acronym}")` for each. (hand-off: `metanorma__pubid__omg-acronym-charset`.)
10
+ - **The bare `beta` is not optional polish — the document part makes it load-bearing.** `parser.rb` used to demand `" beta "` followed by at least one digit. Once an optional third token is legal, a PEG grammar parses `OMG UML 2.5 beta` as version `2.5` plus a **document part named `beta`** — a silent wrong answer, not a parse failure, and exactly the shape that costs nothing until a consumer compares two identifiers. So the beta number is `.maybe`, and the version rule consumes the label whole. **This is what OMG actually publishes, checked against the source**: `https://www.omg.org/spec/UML/2.5/Beta1/` gives its own version as **`2.5 beta`**, the unnumbered form, while DDS 1.4 supersedes `https://www.omg.org/spec/DDS/1.4/Beta2`. Both spellings are real. The regression guard is the example asserting that `OMG UML 2.5 beta` leaves `part` **nil**; the round-trip example alone would pass either way.
11
+ - **Both halves of the beta label need a word boundary, and the failure mode is a rejection rather than a misparse.** Parslet never backtracks into a `.maybe` that already succeeded. With an unanchored `str(" beta")`, `OMG DDS 1.4 beta2` and `OMG DDS 1.4 betawave` made the version rule commit to `" beta"`, fail to find the beta number, and leave `2`/`wave` with no separator in front of it — so the **whole identifier raised**, not merely parsed oddly. The same trap sits behind the beta number: without a guard, `OMG UML 2.5 beta 1x` consumed `" 1"` and then choked on `x`. `Parser#beta` therefore ends each half with `word_boundary` (`match("[A-Za-z0-9]").absent?`), which costs nothing — a genuine bare `beta` is followed by the end of input or a space, and a genuine beta number by the same. All three inputs now read the tail as a document part. A code review found the first case; the second and third came out of probing the fix. The lesson generalizes to any flavor adding an optional trailing token after an optional literal-suffixed one: **anchor the literal, or the earlier rule eats the later one's first word and the identifier is rejected**.
12
+ - **Known limit — `Beta2` reads as a document part.** OMG's URL spelling glues the label and the number and capitalizes (`.../1.4/Beta2`), so `OMG DDS 1.4 Beta2` parses as version `1.4` with the part `"Beta2"`. It round-trips and it is not wrong enough to chase: no reference in the relaton corpus uses that spelling, the version rule follows the lowercase, space-separated form the relaton regex accepted, and widening it would need a rule that tells `Beta2` from a genuine volume name. Recorded rather than fixed.
13
+ - **`root.number` is nil for every OMG identifier, and so is the MR slug. Neither moved here, and nothing depends on either yet.** OMG models `acronym`/`version`/`part` and never sets the `number` it inherits, so the index key the root file requires of every leaf is empty and `to_mr_string` is `""` for every identifier — both were already true before this branch and both are unchanged by it (the base `mr_number_with_part` reads `number`, not `part`, so adding the part does not populate the slug). OMG has **no index and no `relaton-data-omg`** — it is scraped from `www.omg.org/spec` with Mechanize — and the relaton migration hand-off explicitly forbids adding one, so the binary-search degradation the root file describes cannot bite today. Do not "fix" it by mirroring `acronym` into `number` without checking the renderer first: that is the precondition the BIPM entry names, and the NIST attempt recorded in the root file is what happens when it does not hold.
14
+ - **The fixture corpus is hand-written.** OMG has no `spec/fixtures/omg/identifiers/full/` tree and no entry in `spec/fixtures/classify_fixtures.rb`, so `validation:classify` never rewrites `pass/` or `fail/` and the root file's rule against hand-editing them does not apply. `spec/pubid/omg/fixtures_spec.rb` skips `#` lines, so the files carry comments.
15
+ - **relaton note**: `relaton/relaton` moves its OMG flavor off the hand-written regex at `lib/relaton/omg/scraper.rb:19` onto this flavor. Both blockers it named are fixed. Its probe expected `OMG DDS 1.4/PDF` to round-trip byte for byte; it now sees a normalizing parse, which is harmless there because the flavor reads `acronym` and `version` off the parsed object and keeps the document part out of the request URL. (hand-offs: `metanorma__pubid__omg-document-part-and-bare-beta`, `relaton__relaton__omg-pubid-migration`.)
@@ -18,3 +18,5 @@ module Pubid
18
18
  end
19
19
  end
20
20
  end
21
+
22
+ Pubid::Omg::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -21,10 +21,22 @@ module Pubid
21
21
  # bypasses this and raises a bare `Parslet::ParseFailed`. Nothing in the
22
22
  # gem does that.
23
23
  class Grammar < ::Parslet::Parser
24
+ # A trailing "(all parts)" marks the reference as the whole document.
25
+ # The flavor grammars that carry their own rule consume it inside
26
+ # parslet; this shared strip gives every other flavor the same read:
27
+ # the suffix never reaches the flavor grammar, and the parsed tree
28
+ # carries :all_parts for the builder to wrap (see Builder::Base).
29
+ ALL_PARTS_SUFFIX = "(all parts)".freeze
30
+
24
31
  # @param io [String, IO]
25
32
  # @param options [Hash] passed through to parslet
26
33
  # @raise [Pubid::Errors::ParseError]
27
34
  def parse(io, options = {})
35
+ if io.is_a?(String) && io.end_with?(ALL_PARTS_SUFFIX)
36
+ base = io.sub(/\s*\(all parts\)\s*\z/, "")
37
+ return mark_all_parts(super(base, options))
38
+ end
39
+
28
40
  super
29
41
  rescue ::Pubid::Errors::ParseError
30
42
  # A nested grammar already wrapped it. Keep the inner flavor and input.
@@ -35,6 +47,17 @@ module Pubid
35
47
 
36
48
  private
37
49
 
50
+ # Carry the stripped suffix into the tree. Parslet tops are a Hash or
51
+ # an Array of Hashes; the marker joins either shape, and every builder
52
+ # (Builder::Base and the standalone ones) routes it to #to_all_parts.
53
+ def mark_all_parts(tree)
54
+ case tree
55
+ when Hash then tree.merge(all_parts: true)
56
+ when Array then tree.map { |t| t.merge(all_parts: true) }
57
+ else tree
58
+ end
59
+ end
60
+
38
61
  # @param error [Parslet::ParseFailed]
39
62
  # @param io [String, IO] what was handed to {#parse}
40
63
  # @return [Pubid::Errors::ParseError]
@@ -52,3 +52,5 @@ module Pubid
52
52
  end
53
53
  end
54
54
  end
55
+
56
+ Pubid::Plateau::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -14,6 +14,10 @@ module Pubid
14
14
  attribute :number, :integer
15
15
  attribute :annex, :integer, default: -> {}
16
16
 
17
+ # A nil `annex` means the document has none: PLATEAU Handbook #10 is
18
+ # not its annex, PLATEAU Handbook #10-1.
19
+ subset_strict :annex
20
+
17
21
  # Stored as a plain string (always "PLATEAU") so it round-trips through
18
22
  # to_hash/from_hash. Was a `def publisher` method, which made lutaml
19
23
  # serialize a String against the Components::Publisher attribute.
@@ -9,9 +9,16 @@ module Pubid
9
9
  class SupplementIdentifier < Pubid::Identifier
10
10
  attribute :base, Identifier
11
11
  attribute :letter, :string, default: -> {}
12
+ # Stored as a plain string (always "PLATEAU") so it round-trips through
13
+ # to_hash/from_hash. Was a `def publisher` method, which made lutaml
14
+ # serialize a String against the Components::Publisher attribute
15
+ # (pubid/pubid#407) — the same fix as Identifiers::Base.
16
+ attribute :publisher, :string, default: -> { "PLATEAU" }
12
17
 
13
- def publisher
14
- "PLATEAU"
18
+ # The UrnGenerator reads type_string on every identifier; the annex
19
+ # supplement's own type makes its "an" URN branch reachable.
20
+ def type_string
21
+ "Annex"
15
22
  end
16
23
 
17
24
  # Subclasses must implement supplement_string
@@ -22,6 +29,11 @@ module Pubid
22
29
  # Override base_hash to extract edition, type, and annex from base
23
30
  def base_hash
24
31
  hash = super
32
+ # The base document's number: without it from_hash cannot
33
+ # reconstruct the wrapped identifier (pubid/pubid#407).
34
+ if base.class.attributes.key?(:number) && base.number
35
+ hash[:number] = base.number
36
+ end
25
37
  # For Plateau supplements, edition comes from the base identifier
26
38
  if base.class.attributes.key?(:edition) && base.edition
27
39
  hash[:edition] = base.edition
@@ -20,7 +20,13 @@ module Pubid
20
20
 
21
21
  parts << format("%02d", identifier.number) if identifier.number
22
22
 
23
- parts << format("%02d", identifier.annex) if identifier.annex
23
+ if identifier.class.attributes.key?(:annex)
24
+ parts << format("%02d", identifier.annex) if identifier.annex
25
+ # Annex supplements carry a letter, not an annex number
26
+ # (pubid/pubid#407).
27
+ elsif identifier.class.attributes.key?(:letter) && identifier.letter
28
+ parts << identifier.letter.to_s.downcase
29
+ end
24
30
 
25
31
  parts.join(":")
26
32
  end
@@ -12,7 +12,6 @@ module Pubid
12
12
  parts << render_edition_portion(context) if with_edition
13
13
  result = parts.compact.join(" ")
14
14
  result << render_language_portion(context, with_edition: with_edition)
15
- result << " (all parts)" if @id.all_parts
16
15
  result
17
16
  end
18
17
 
@@ -30,3 +30,5 @@ module Pubid
30
30
  end
31
31
  end
32
32
  end
33
+
34
+ Pubid::Sae::Builder.prepend(Pubid::Builder::AllPartsWrap)
@@ -8,6 +8,8 @@ module Pubid
8
8
  # Date component for SAE standards
9
9
  # SAE uses year only (e.g., 2024, 2022)
10
10
  class Date < Lutaml::Model::Serializable
11
+ include ::Pubid::SubsetMatch
12
+
11
13
  attribute :year, :integer
12
14
 
13
15
  def present?