pubid 2.0.0.pre.alpha.5 → 2.0.0.pre.alpha.7

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +40 -0
  3. data/lib/pubid/amca.rb +6 -0
  4. data/lib/pubid/ansi.rb +7 -0
  5. data/lib/pubid/api.rb +5 -0
  6. data/lib/pubid/ashrae.rb +6 -0
  7. data/lib/pubid/asme.rb +5 -0
  8. data/lib/pubid/astm.rb +5 -0
  9. data/lib/pubid/bsi/builder.rb +41 -48
  10. data/lib/pubid/bsi/identifiers/amendment.rb +28 -0
  11. data/lib/pubid/bsi/identifiers/consolidated_identifier.rb +11 -1
  12. data/lib/pubid/bsi/identifiers/corrigendum.rb +24 -0
  13. data/lib/pubid/bsi/identifiers/expert_commentary.rb +5 -0
  14. data/lib/pubid/bsi/identifiers/flex.rb +4 -0
  15. data/lib/pubid/bsi/parser.rb +19 -5
  16. data/lib/pubid/bsi/renderer.rb +19 -17
  17. data/lib/pubid/bsi.rb +15 -0
  18. data/lib/pubid/ccsds.rb +5 -0
  19. data/lib/pubid/cen_cenelec.rb +7 -0
  20. data/lib/pubid/cie.rb +5 -0
  21. data/lib/pubid/csa.rb +5 -0
  22. data/lib/pubid/etsi.rb +7 -0
  23. data/lib/pubid/iala/builder.rb +107 -0
  24. data/lib/pubid/iala/identifier.rb +67 -0
  25. data/lib/pubid/iala/identifiers/advice.rb +15 -0
  26. data/lib/pubid/iala/identifiers/annex.rb +55 -0
  27. data/lib/pubid/iala/identifiers/base.rb +14 -0
  28. data/lib/pubid/iala/identifiers/general_assembly.rb +16 -0
  29. data/lib/pubid/iala/identifiers/guideline.rb +15 -0
  30. data/lib/pubid/iala/identifiers/letter.rb +16 -0
  31. data/lib/pubid/iala/identifiers/manual.rb +16 -0
  32. data/lib/pubid/iala/identifiers/model_course.rb +15 -0
  33. data/lib/pubid/iala/identifiers/recommendation.rb +15 -0
  34. data/lib/pubid/iala/identifiers/report.rb +16 -0
  35. data/lib/pubid/iala/identifiers/resolution.rb +16 -0
  36. data/lib/pubid/iala/identifiers/standard.rb +15 -0
  37. data/lib/pubid/iala/identifiers.rb +20 -0
  38. data/lib/pubid/iala/parser.rb +130 -0
  39. data/lib/pubid/iala/renderer.rb +36 -0
  40. data/lib/pubid/iala/urn_generator.rb +49 -0
  41. data/lib/pubid/iala/urn_parser.rb +50 -0
  42. data/lib/pubid/iala.rb +71 -0
  43. data/lib/pubid/identifier.rb +54 -1
  44. data/lib/pubid/idf.rb +5 -0
  45. data/lib/pubid/iec.rb +8 -0
  46. data/lib/pubid/ieee.rb +8 -0
  47. data/lib/pubid/iho.rb +5 -0
  48. data/lib/pubid/iso.rb +6 -0
  49. data/lib/pubid/itu.rb +5 -0
  50. data/lib/pubid/jcgm.rb +5 -0
  51. data/lib/pubid/jis.rb +6 -0
  52. data/lib/pubid/nist.rb +13 -0
  53. data/lib/pubid/oiml/builder.rb +33 -0
  54. data/lib/pubid/oiml/identifier.rb +1 -0
  55. data/lib/pubid/oiml/identifiers/bulletin.rb +101 -0
  56. data/lib/pubid/oiml/identifiers.rb +1 -0
  57. data/lib/pubid/oiml/parser.rb +43 -1
  58. data/lib/pubid/oiml/renderer.rb +51 -0
  59. data/lib/pubid/oiml/urn_generator.rb +22 -0
  60. data/lib/pubid/oiml.rb +5 -0
  61. data/lib/pubid/plateau.rb +5 -0
  62. data/lib/pubid/prefixes_support.rb +51 -0
  63. data/lib/pubid/sae.rb +6 -0
  64. data/lib/pubid/version.rb +1 -1
  65. data/lib/pubid.rb +69 -0
  66. metadata +24 -2
data/lib/pubid/etsi.rb CHANGED
@@ -4,6 +4,13 @@ require "lutaml/model"
4
4
 
5
5
  module Pubid
6
6
  module Etsi
7
+ extend Pubid::PrefixesSupport
8
+
9
+ # Sole ETSI publisher token (see the parser's `etsi_prefix` rule). The
10
+ # document-type tokens (EN, TS, TR, ...) are sub-types that follow the ETSI
11
+ # prefix, not standalone routing tokens, so they are excluded.
12
+ PREFIXES = ["ETSI"].freeze
13
+
7
14
  autoload :Builder, "#{__dir__}/etsi/builder"
8
15
  autoload :Components, "#{__dir__}/etsi/components"
9
16
  autoload :Identifier, "#{__dir__}/etsi/identifier"
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ # Builds an IALA identifier object from the Parslet parse tree.
6
+ class Builder
7
+ def build(parsed)
8
+ hash = parsed
9
+
10
+ return build_annex(hash) if hash[:annex_marker]
11
+
12
+ type_letter = stringify(hash[:type_letter])&.upcase
13
+
14
+ attrs = {
15
+ number: build_number(hash, type_letter),
16
+ edition: stringify(hash.dig(:edition, :edition_value)),
17
+ language: stringify(hash.dig(:language_group, :language))&.upcase,
18
+ }.compact
19
+
20
+ Iala.identifier_klass_for_type_letter(type_letter).new(**attrs)
21
+ end
22
+
23
+ def self.build(parsed)
24
+ new.build(parsed)
25
+ end
26
+
27
+ private
28
+
29
+ # Build the Annex wrapper from the parse tree. The base sub-tree
30
+ # (captured under :base) is recursively built via the same logic as
31
+ # a flat identifier, then wrapped.
32
+ def build_annex(hash)
33
+ base_hash = hash[:base]
34
+ base_type = stringify(base_hash[:type_letter])&.upcase
35
+ base_number = build_number(base_hash, base_type)
36
+ base = Iala.identifier_klass_for_type_letter(base_type).new(number: base_number)
37
+
38
+ attrs = {
39
+ base_identifier: base,
40
+ annex_form: stringify(hash[:annex_marker]),
41
+ letter: stringify(hash[:annex_letter]),
42
+ edition: stringify(hash.dig(:edition, :edition_value)),
43
+ language: stringify(hash.dig(:language_group, :language))&.upcase,
44
+ }.compact
45
+
46
+ Identifiers::Annex.new(**attrs)
47
+ end
48
+
49
+ # Compose the full number string from the leading doc_number plus any
50
+ # dotted continuation (GA01.13 → "01.13", L2.1.11 → "2.1.11") plus
51
+ # the optional dash-separated subpart (C0103-1 → "0103-1", L2.7.1-2
52
+ # → "2.7.1-2"). Padding is type-aware per IALA's canonical form:
53
+ # typed S/R/G/M/C zero-pad to 4 digits; GeneralAssembly (GA) zero-
54
+ # pads each segment to 2; Advices (A) and Letters (L) preserve input
55
+ # (their canonical form is already what the IALA site prints).
56
+ def build_number(hash, type_letter = nil)
57
+ base = stringify(hash[:doc_number])
58
+ return base unless base
59
+
60
+ base = canonical_base(base, type_letter)
61
+
62
+ dots = hash[:doc_number_dots]
63
+ dots_str = dots.is_a?(Array) ? "" : canonical_dots(stringify(dots).to_s, type_letter)
64
+
65
+ subpart_str = hash[:subpart] ? "-#{subpart_to_s(hash[:subpart])}" : ""
66
+ subpart_str = "" if subpart_str == "-"
67
+ "#{base}#{dots_str}#{subpart_str}"
68
+ end
69
+
70
+ # Per-type canonical width for the leading numeric run.
71
+ def canonical_base(base, type_letter)
72
+ case type_letter
73
+ when "S", "R", "G", "M", "C", "X", "P" then base.rjust(4, "0")
74
+ when "GA" then base.rjust(2, "0")
75
+ else base
76
+ end
77
+ end
78
+
79
+ # Per-type canonical width for each dotted continuation segment.
80
+ # `str` looks like ".13" or ".1.11" (leading dot, then digits).
81
+ def canonical_dots(str, type_letter)
82
+ return "" if str.empty?
83
+
84
+ if type_letter == "GA"
85
+ str.split(".", -1).map { |seg| seg.empty? ? seg : seg.rjust(2, "0") }.join(".")
86
+ else
87
+ str
88
+ end
89
+ end
90
+
91
+ # The subpart capture may be an array of {subpart_number: "1"},
92
+ # {subpart_number: "9"}, … (repeated matches). Join with "-".
93
+ def subpart_to_s(subpart)
94
+ return stringify(subpart).to_s unless subpart.is_a?(Array)
95
+
96
+ subpart.filter_map { |h| h.is_a?(Hash) ? stringify(h[:subpart_number]) : nil }.join("-")
97
+ end
98
+
99
+ def stringify(value)
100
+ return nil if value.nil?
101
+
102
+ str = value.to_s
103
+ str.empty? ? nil : str
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ # Base class for all IALA identifiers. Canonical name
6
+ # Pubid::Iala::Identifier; every concrete IALA identifier
7
+ # (Identifiers::*) descends from it, and Identifiers::Base — aliased
8
+ # at the foot of identifiers/base.rb — points back to it.
9
+ #
10
+ # IALA identifiers have the form:
11
+ # [IALA ]{S|R|G|M|C|X|P}<4-digit number>[-<subpart>][ Ed <edition>][ (<LangLetter>)]
12
+ #
13
+ # Examples:
14
+ # S1070
15
+ # IALA S1070 Ed 2.0
16
+ # R1016:ed2.0(F)
17
+ # C0103-1 Ed 3.0
18
+ class Identifier < ::Pubid::Identifier
19
+ # Parse an IALA identifier string into an identifier object.
20
+ # @param identifier [String]
21
+ # @return [Pubid::Iala::Identifier]
22
+ # @raise [Parslet::ParseFailed] If parsing fails
23
+ def self.parse(identifier)
24
+ parsed = Parser.parse(identifier)
25
+ Builder.build(parsed)
26
+ rescue Parslet::ParseFailed => e
27
+ raise "Failed to parse IALA identifier '#{identifier}': #{e.message}"
28
+ end
29
+
30
+ attribute :publisher, :string, default: "IALA"
31
+ attribute :number, :string # "1070", "0126", "0103-1"
32
+ attribute :edition, :string # "2.0", "1.3", nil
33
+ attribute :language, :string # "E", "F", "S", "C", "A", "R", nil
34
+
35
+ IALA_TYPE_MAP = {
36
+ "pubid:iala:standard" => "Pubid::Iala::Identifiers::Standard",
37
+ "pubid:iala:recommendation" => "Pubid::Iala::Identifiers::Recommendation",
38
+ "pubid:iala:guideline" => "Pubid::Iala::Identifiers::Guideline",
39
+ "pubid:iala:manual" => "Pubid::Iala::Identifiers::Manual",
40
+ "pubid:iala:model-course" => "Pubid::Iala::Identifiers::ModelCourse",
41
+ "pubid:iala:advice" => "Pubid::Iala::Identifiers::Advice",
42
+ "pubid:iala:general-assembly" => "Pubid::Iala::Identifiers::GeneralAssembly",
43
+ "pubid:iala:letter" => "Pubid::Iala::Identifiers::Letter",
44
+ "pubid:iala:annex" => "Pubid::Iala::Identifiers::Annex",
45
+ "pubid:iala:report" => "Pubid::Iala::Identifiers::Report",
46
+ "pubid:iala:resolution" => "Pubid::Iala::Identifiers::Resolution",
47
+ }.freeze
48
+
49
+ key_value do
50
+ map "_type", to: :_type, polymorphic_map: IALA_TYPE_MAP
51
+ map "number", to: :number
52
+ map "edition", to: :edition
53
+ map "language", to: :language
54
+ end
55
+
56
+ def to_urn
57
+ UrnGenerator.new(self).generate
58
+ end
59
+
60
+ # Single-letter type code (S, R, G, …) — convenience accessor used
61
+ # by Renderer and UrnGenerator.
62
+ def type_letter
63
+ self.class.type[:short]
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA Advices (A prefix). Numbering is dashed: A12-01, A13-04.
7
+ # Carries no edition in the corpus.
8
+ class Advice < Base
9
+ def self.type
10
+ { key: :advice, title: "Advice", short: "A" }
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA Annex to a base publication. Two surface forms appear in the
7
+ # corpus, both wrapping a Guideline:
8
+ #
9
+ # "G1045 Annex Ed 1" — bare Annex, no letter
10
+ # "G1128 ANNEX A Ed 1.6" — uppercase + letter (A/B/C/D)
11
+ #
12
+ # The case of the marker is preserved (Annex vs ANNEX) for round-trip
13
+ # fidelity. Edition and language apply to the Annex itself, not the base.
14
+ class Annex < Base
15
+ # The publication this annex supplements. Always present; carries the
16
+ # type letter and number (e.g. G1045).
17
+ attribute :base_identifier, ::Pubid::Iala::Identifier, polymorphic: true
18
+ # "Annex" or "ANNEX" — preserved verbatim for exact round-trip.
19
+ attribute :annex_form, :string
20
+ # "A", "B", "C", "D", or nil for the bare form.
21
+ attribute :letter, :string
22
+
23
+ key_value do
24
+ map "base_identifier",
25
+ with: { to: :base_identifier_to_kv,
26
+ from: :base_identifier_from_kv }
27
+ map "annex_form", to: :annex_form
28
+ map "letter", to: :letter
29
+ end
30
+
31
+ def self.type
32
+ { key: :annex, title: "Annex", short: nil }
33
+ end
34
+
35
+ private
36
+
37
+ def base_identifier_to_kv(model, doc)
38
+ base = model.base_identifier
39
+ return unless base
40
+
41
+ doc.add_child(
42
+ Lutaml::KeyValue::DataModel::Element.new("base_identifier",
43
+ base.to_hash),
44
+ )
45
+ end
46
+
47
+ def base_identifier_from_kv(model, value)
48
+ return unless value
49
+
50
+ model.base_identifier = ::Pubid::Iala::Identifier.from_hash(value)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Backward-compatible alias: IALA's base class used to be
4
+ # Pubid::Iala::Identifiers::Base. It is now Pubid::Iala::Identifier
5
+ # (defined in identifier.rb), which is loaded by the parent autoload.
6
+ require_relative "../identifier" unless defined?(Pubid::Iala::Identifier)
7
+
8
+ module Pubid
9
+ module Iala
10
+ module Identifiers
11
+ Base = Pubid::Iala::Identifier
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA General Assembly resolutions (GA prefix). Numbering is dotted
7
+ # with a series and an index: GA01.01, GA01.13. No edition.
8
+ class GeneralAssembly < Base
9
+ def self.type
10
+ { key: :"general-assembly", title: "General Assembly Resolution",
11
+ short: "GA" }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA Guidelines (G series).
7
+ # Examples: G1015 Ed 2.2, G1078 Ed 2.1.
8
+ class Guideline < Base
9
+ def self.type
10
+ { key: :guideline, title: "Guideline", short: "G" }
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA Letters (L prefix). Numbering is dotted with a series, sub-series,
7
+ # and item: L2.1.11, L2.7.1-2 (the trailing -2 is a sub-part on top of
8
+ # the dotted number).
9
+ class Letter < Base
10
+ def self.type
11
+ { key: :letter, title: "Letter", short: "L" }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA Manuals (M prefix, planned).
7
+ # Today manuals carry no code (NAVGUIDE, VTS Manual, …); M is
8
+ # reserved for future numbered manuals.
9
+ class Manual < Base
10
+ def self.type
11
+ { key: :manual, title: "Manual", short: "M" }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA Model Courses (C series).
7
+ # Examples: C0103-1 Ed 3.0, C2001-1 Ed 1.0.
8
+ class ModelCourse < Base
9
+ def self.type
10
+ { key: :"model-course", title: "Model Course", short: "C" }
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA Recommendations (R series).
7
+ # Examples: R0103 Ed 3.1, R0126 Ed 2.0, R1024 Ed 1.0.
8
+ class Recommendation < Base
9
+ def self.type
10
+ { key: :recommendation, title: "Recommendation", short: "R" }
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA Reports & Proceedings (X prefix, planned).
7
+ # Today reports carry no code; X is reserved for future numbered
8
+ # reports.
9
+ class Report < Base
10
+ def self.type
11
+ { key: :report, title: "Report", short: "X" }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA Resolutions and other Council publications (P prefix,
7
+ # planned). Today resolutions carry no code; P is reserved for
8
+ # future numbered resolutions.
9
+ class Resolution < Base
10
+ def self.type
11
+ { key: :resolution, title: "Resolution", short: "P" }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA Standards (S series).
7
+ # Examples: S1010, S1020 Ed 2.0, S1070 Ed 2.0.
8
+ class Standard < Base
9
+ def self.type
10
+ { key: :standard, title: "Standard", short: "S" }
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ autoload :Base, "#{__dir__}/identifiers/base"
7
+ autoload :Advice, "#{__dir__}/identifiers/advice"
8
+ autoload :Annex, "#{__dir__}/identifiers/annex"
9
+ autoload :Standard, "#{__dir__}/identifiers/standard"
10
+ autoload :Recommendation, "#{__dir__}/identifiers/recommendation"
11
+ autoload :Guideline, "#{__dir__}/identifiers/guideline"
12
+ autoload :Manual, "#{__dir__}/identifiers/manual"
13
+ autoload :ModelCourse, "#{__dir__}/identifiers/model_course"
14
+ autoload :GeneralAssembly, "#{__dir__}/identifiers/general_assembly"
15
+ autoload :Letter, "#{__dir__}/identifiers/letter"
16
+ autoload :Report, "#{__dir__}/identifiers/report"
17
+ autoload :Resolution, "#{__dir__}/identifiers/resolution"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parslet"
4
+
5
+ module Pubid
6
+ module Iala
7
+ # Parslet grammar for IALA identifiers.
8
+ #
9
+ # Accepts every shape that appears on the IALA website or cover page:
10
+ #
11
+ # S1070
12
+ # S1070 Ed 2.0
13
+ # IALA S1070 Ed 2.0
14
+ # S1070 Ed 2.0 (F)
15
+ # R1016:ed2.0(F)
16
+ # C0103-1
17
+ # C0103-1 Ed 3.0
18
+ class Parser < Parslet::Parser
19
+ include ::Pubid::Parser::CommonParseRules
20
+
21
+ root :identifier
22
+
23
+ rule(:space) { str(" ") }
24
+ rule(:space?) { space.maybe }
25
+ rule(:dash) { str("-") }
26
+ rule(:colon) { str(":") }
27
+ rule(:dot) { str(".") }
28
+ rule(:lparen) { str("(") }
29
+ rule(:rparen) { str(")") }
30
+
31
+ # Type prefix — IALA document classes. GA must be tried before G so it
32
+ # isn't left with a dangling "A". Captured to route to the right
33
+ # Identifiers::* subclass.
34
+ rule(:type_letter) do
35
+ (str("GA") | str("S") | str("R") | str("G") | str("M") |
36
+ str("C") | str("A") | str("L") | str("X") | str("P")).as(:type_letter)
37
+ end
38
+
39
+ # Document number — one or more decimal digits. The dataset historically
40
+ # zero-pads to 4 (S1070, R0126, M0001) but the canonical form going
41
+ # forward is unpadded (M1, GA1.1). Accept any length so both shapes
42
+ # round-trip; the number attribute preserves whatever was parsed.
43
+ rule(:doc_number) do
44
+ match("[0-9]").repeat(1).as(:doc_number)
45
+ end
46
+
47
+ # Dotted continuation that some prefixes use as part of the number:
48
+ # GA01.01 (series.index), L2.1.11 (series.sub.item). Captured into the
49
+ # same :doc_number atom so the number attribute stays a single string
50
+ # (e.g. "01.01", "2.1.11") and the existing builder needs no changes.
51
+ rule(:dotted_number) do
52
+ dot >> match("[0-9]").repeat(1)
53
+ end
54
+
55
+ rule(:number_with_dots) do
56
+ doc_number >> dotted_number.repeat.as(:doc_number_dots)
57
+ end
58
+
59
+ # Numeric sub-part suffix(es): "-1", "-9-10", "-11". The catalogue
60
+ # uses ranges like R0124-9-10 & 11 — that compound is captured
61
+ # verbatim and resolved by the caller, not here.
62
+ rule(:subpart) do
63
+ (dash >> digits.as(:subpart_number)).repeat(1).as(:subpart)
64
+ end
65
+
66
+ rule(:code) do
67
+ type_letter >> number_with_dots >> subpart.maybe
68
+ end
69
+
70
+ # Edition forms observed on covers and listing pages:
71
+ # " Ed 2.0" — cover-page human form
72
+ # ":ed2.0" — listing-page compact form
73
+ rule(:edition_human) do
74
+ (space >> str("Ed") >> space >> edition_value).as(:edition)
75
+ end
76
+
77
+ rule(:edition_compact) do
78
+ (colon >> str("ed") >> edition_value).as(:edition)
79
+ end
80
+
81
+ rule(:edition_value) do
82
+ (digits >> (dot >> digits).repeat).as(:edition_value)
83
+ end
84
+
85
+ rule(:edition) do
86
+ edition_human | edition_compact
87
+ end
88
+
89
+ # Language is a single uppercase letter inside parens: (E), (F), …
90
+ rule(:language) do
91
+ (space? >> lparen >>
92
+ (str("E") | str("F") | str("S") | str("C") | str("A") | str("R")).as(:language) >>
93
+ rparen).as(:language_group)
94
+ end
95
+
96
+ # Optional "IALA " prefix.
97
+ rule(:publisher) do
98
+ (str("IALA") >> space).maybe
99
+ end
100
+
101
+ rule(:identifier) do
102
+ annex_identifier | (publisher >> code >> edition.maybe >> language.maybe)
103
+ end
104
+
105
+ # Annex to a base publication. Wraps the base code and adds an
106
+ # "Annex" / "ANNEX" marker (case preserved verbatim), an optional
107
+ # single-letter suffix (A/B/C/D), then the usual edition + language.
108
+ # Tried before the flat `identifier` so the annex marker isn't left
109
+ # dangling after a successful base-code match.
110
+ #
111
+ # The letter suffix uses a negative lookahead for a following lowercase
112
+ # letter so the "E" in "Ed 1" isn't mis-captured as annex letter "E".
113
+ rule(:annex_identifier) do
114
+ (publisher >> type_letter >> number_with_dots >> subpart.maybe).as(:base) >>
115
+ space >> (str("Annex") | str("ANNEX")).as(:annex_marker) >>
116
+ (space >> match("[A-Z]").as(:annex_letter) >> match("[a-z]").absent?).maybe >>
117
+ edition.maybe >> language.maybe
118
+ end
119
+
120
+ # Parse a string and return the raw parslet tree.
121
+ # @param string [String]
122
+ # @return [Hash]
123
+ def self.parse(string)
124
+ raise ArgumentError, "IALA identifier string exceeds maximum length" if string.length > ::Pubid::MAX_INPUT_LENGTH
125
+
126
+ new.parse(string.strip)
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ # Human-readable renderer for IALA identifiers.
6
+ #
7
+ # Produces strings like:
8
+ # "IALA S1070"
9
+ # "IALA S1070 Ed 2.0"
10
+ # "IALA R1016 Ed 2.0 (F)"
11
+ class Renderer < ::Pubid::Renderers::Base
12
+ def render(context: nil, **_opts)
13
+ id = @id
14
+
15
+ return render_annex(id) if id.is_a?(Identifiers::Annex)
16
+
17
+ rendered = "#{id.publisher} #{id.type_letter}#{id.number}"
18
+ rendered << " Ed #{id.edition}" if id.edition
19
+ rendered << " (#{id.language})" if id.language
20
+ rendered
21
+ end
22
+
23
+ # Render an Annex wrapper. The base identifier is rendered first, then
24
+ # the marker form is preserved verbatim ("Annex" vs "ANNEX"), then the
25
+ # optional letter, edition, and language.
26
+ def render_annex(id)
27
+ base_str = id.base_identifier.to_s
28
+ rendered = "#{base_str} #{id.annex_form}"
29
+ rendered << " #{id.letter}" if id.letter
30
+ rendered << " Ed #{id.edition}" if id.edition
31
+ rendered << " (#{id.language})" if id.language
32
+ rendered
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ # Generates URN strings for IALA identifiers using the Maritime Resource
6
+ # Name (MRN) namespace that IALA prints on its cover pages.
7
+ #
8
+ # Format: urn:mrn:iala:pub:<type-lower><number>[:ed<edition>][:<lang-lower>]
9
+ # Example: urn:mrn:iala:pub:s1070:ed2.0
10
+ # urn:mrn:iala:pub:r1016:ed2.0:f
11
+ class UrnGenerator
12
+ attr_reader :identifier
13
+
14
+ def initialize(identifier)
15
+ @identifier = identifier
16
+ end
17
+
18
+ def generate
19
+ return generate_annex if identifier.is_a?(Identifiers::Annex)
20
+
21
+ parts = ["urn:mrn:iala:pub", code_segment]
22
+ parts << "ed#{identifier.edition}" if identifier.edition
23
+ parts << identifier.language.downcase if identifier.language
24
+ parts.join(":")
25
+ end
26
+
27
+ private
28
+
29
+ # URN for an Annex: embeds the base's code, then an "annex" segment
30
+ # (with optional letter suffix), then the annex's own edition/lang.
31
+ # G1045 Annex Ed 1 → urn:mrn:iala:pub:g1045:annex:ed1
32
+ # G1128 ANNEX A Ed 1.6 (E) → urn:mrn:iala:pub:g1128:annex-a:ed1.6:e
33
+ def generate_annex
34
+ base = identifier.base_identifier
35
+ parts = ["urn:mrn:iala:pub",
36
+ "#{base.type_letter.downcase}#{base.number}"]
37
+ annex_seg = identifier.letter ? "annex-#{identifier.letter.downcase}" : "annex"
38
+ parts << annex_seg
39
+ parts << "ed#{identifier.edition}" if identifier.edition
40
+ parts << identifier.language.downcase if identifier.language
41
+ parts.join(":")
42
+ end
43
+
44
+ def code_segment
45
+ "#{identifier.type_letter.downcase}#{identifier.number}"
46
+ end
47
+ end
48
+ end
49
+ end