pubid 2.0.5 → 2.0.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.
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Itu
5
+ # The pubid-scheme URN of ITU (issue #656; docs/URN-SCHEMES.adoc).
6
+ #
7
+ # ITU-T G.729 Annex B (1996) Cor. 3 (03/2001)
8
+ # urn:pubid:itu:rec:729:sector-T:series-G:anx:B:year-1996:
9
+ # cor:3:year-2001:month-03
10
+ #
11
+ # The nodes go root first along +base+, so every supplement, annex and
12
+ # appendix of a Recommendation shares its slug prefix. The official
13
+ # urn:itu: form (IANA-registered) stays on #to_urn.
14
+ #
15
+ # Facets are the flags the ITU classes call rendering-only: the same
16
+ # document printed two ways ("Suppl.1" and "Suppl. 1", "V.25 ter" and
17
+ # "V.25ter", "D.502R" and "D.502 R", "Amd. 1/Cor. 1" and "Amd. 1 Cor. 1").
18
+ # Every other flag is an atom, because it names a different document
19
+ # ("Technical Cor.", "attachment", the word "series").
20
+ #
21
+ # Not placed, so #to_pubid_urn raises rather than drop it: the +parts+ of
22
+ # a combined designation ("G.780-1/Y.1351-2"), a list inside a list. No
23
+ # pass fixture has one.
24
+ PubidUrnSchema = Pubid::PubidUrn.define("itu") do
25
+ atom "sector", "sector"
26
+ atom "series", "series"
27
+ atom "series_word", "seriesword", kind: :bool
28
+ atom "series_dash", "seriesdash", kind: :bool
29
+ atom "imp_marker", "imp"
30
+ atom "subseries", "subseries"
31
+ atom "parts", "part", kind: :list
32
+ atom "series_suffix", "suffix"
33
+ atom "qualifier", "qualifier"
34
+ atom "range_end", "rangeend"
35
+ atom "attachment", "attachment", kind: :bool
36
+ atom "combined.*.series", "combseries"
37
+ atom "combined.*.number", "combnumber"
38
+ atom "combined.*.subseries", "combsubseries"
39
+ atom "combined.*.series_suffix", "combsuffix"
40
+ atom "combined.*.qualifier", "combqualifier"
41
+ atom "study_group", "sg"
42
+ atom "has_bl", "bl", kind: :bool
43
+ atom "bracketed", "bracketed", kind: :bool
44
+ atom "has_colon", "colon", kind: :bool
45
+ atom "technical", "technical", kind: :bool
46
+ atom "material", "material"
47
+ atom "version", "v"
48
+ atom "year", "year"
49
+ atom "month", "month"
50
+ atom "day", "day"
51
+ atom "language", "lang"
52
+ atom "common_text_twin", "twin", kind: :id
53
+
54
+ facet "series_suffix_spaced", "suffixspaced", kind: :bool
55
+ facet "qualifier_glued", "qualifierglued", kind: :bool
56
+ facet "combined.*.series_suffix_spaced", "combsuffixspaced",
57
+ kind: :bools
58
+ facet "combined.*.qualifier_glued", "combqualifierglued", kind: :bools
59
+ facet "number_glued", "numberglued", kind: :bool
60
+ facet "slash_joined", "slashjoined", kind: :bool
61
+
62
+ type "pubid:itu:recommendation", "rec"
63
+ type "pubid:itu:report", "rpt"
64
+ type "pubid:itu:combined-identifier", "comb"
65
+ type "pubid:itu:question", "q"
66
+ type "pubid:itu:handbook", "hb"
67
+ type "pubid:itu:radio-regulations", "rr"
68
+ type "pubid:itu:special-publication", "sp"
69
+ type "pubid:itu:contribution", "c"
70
+ type "pubid:itu:identifier", "id"
71
+
72
+ type "pubid:itu:annex", "annex"
73
+ type "pubid:itu:annex-of-recommendation", "anx"
74
+ type "pubid:itu:appendix-of-recommendation", "app"
75
+ type "pubid:itu:supplement", "sup"
76
+ type "pubid:itu:amendment", "amd"
77
+ type "pubid:itu:corrigendum", "cor"
78
+ type "pubid:itu:addendum", "add"
79
+ type "pubid:itu:errata", "err"
80
+ end
81
+ end
82
+ end
data/lib/pubid/itu.rb CHANGED
@@ -23,6 +23,7 @@ module Pubid
23
23
  autoload :Identifier, "#{__dir__}/itu/identifiers/base"
24
24
  autoload :Identifiers, "#{__dir__}/itu/identifiers"
25
25
  autoload :Parser, "#{__dir__}/itu/parser"
26
+ autoload :PubidUrnSchema, "#{__dir__}/itu/pubid_urn_schema"
26
27
  autoload :UrnGenerator, "#{__dir__}/itu/urn_generator"
27
28
  autoload :UrnParser, "#{__dir__}/itu/urn_parser"
28
29
  autoload :Model, "#{__dir__}/itu/model"
@@ -80,3 +80,16 @@ Read this before you change `lib/pubid/nist/` or `spec/pubid/nist/`. The root
80
80
  `to_short_style`: the publisher prefixes only when the input carried one
81
81
  (`NIST.`/`NBS.`), and a bare `FIPS 140-3` still renders `FIPS.140-3`.
82
82
  Locked by `spec/pubid/nist/identifiers/fips_spec.rb`.
83
+
84
+ - **pubid-scheme URN (issue #656).** `lib/pubid/nist/pubid_urn_schema.rb`
85
+ covers all 22 types; rulings in `docs/URN-SCHEMES.adoc` (`[[urn-nist]]`).
86
+ `update` and `update_component` serialize every field, so each field is
87
+ its own atom (`upd-`, `updyear-`, `uc-` …); reducing them changes
88
+ `to_hash` and needs a `relaton-data-nist` re-crawl, so it was not done.
89
+ `publisher_was_parsed` (`false` for a bare `FIPS 140-3`),
90
+ `parsed_format`, `edition.original_prefix` and the bracketed
91
+ `translation` are facets. No pass fixture sets `publisher_was_parsed`,
92
+ `supplement.year_end` or the Integer `year`; the review found them by a
93
+ sweep of `full/` and the spec literals, so sweep those too when a schema
94
+ changes. Found in passing, not fixed:
95
+ `NIST.IR.8259Apt` parses as `NIST IR 8259(por)` and loses the `A`.
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Nist
5
+ # The pubid-scheme URN of NIST (issue #656; docs/URN-SCHEMES.adoc).
6
+ #
7
+ # NIST SP 800-53r5
8
+ # urn:pubid:nist:sp:800-53:pub-NIST:series-SP:subseries-800:edtype-r:ed-5
9
+ #
10
+ # The core is the document number. +publisher+ and +series+ are atoms,
11
+ # because a few series print no number ("NIST OWMWP 06-13-2018" keeps
12
+ # its date there) and the series spelling varies inside one type
13
+ # ("CIRC", "NBS.CIRC", "LCIRC").
14
+ #
15
+ # +update+ and +update_component+ serialize every field of their
16
+ # component, so each field is its own atom. Reducing them to one
17
+ # identifying value changes #to_hash and needs a relaton-data-nist
18
+ # re-crawl; that is a separate decision (docs/URN-SCHEMES.adoc).
19
+ #
20
+ # Facets: +parsed_format+ (the input was an MR string), the printed
21
+ # prefix of a revision (" Rev. " against "r") and the bracketed
22
+ # +translation+ ("(spa)"), whose code is the +lang+ atom.
23
+ PubidUrnSchema = Pubid::PubidUrn.define("nist") do
24
+ atom "publisher", "pub"
25
+ atom "series", "series"
26
+ atom "subseries", "subseries"
27
+ atom "part.type", "parttype"
28
+ atom "part.value", "part"
29
+ atom "section", "section"
30
+ atom "volume", "vol"
31
+ atom "edition.type", "edtype"
32
+ atom "edition.id", "ed"
33
+ atom "edition.additional_text", "edtext"
34
+ atom "edition_year", "edyear"
35
+ atom "supplement.number", "sup"
36
+ atom "supplement.suffix", "supsuffix"
37
+ atom "supplement.year", "supyear"
38
+ atom "supplement.month", "supmonth"
39
+ atom "supplement.year_end", "supyearend"
40
+ atom "supplement_date_range_start", "suprangestart"
41
+ atom "supplement_date_range_end", "suprangeend"
42
+ atom "supplement.has_revision", "suprev", kind: :bool
43
+ atom "implicit_supplement", "implicitsup", kind: :bool
44
+ atom "update.prefix", "updprefix"
45
+ atom "update.number", "upd"
46
+ atom "update.year", "updyear"
47
+ atom "update.month", "updmonth"
48
+ atom "update_component.prefix", "ucprefix"
49
+ atom "update_component.number", "uc"
50
+ atom "update_component.year", "ucyear"
51
+ atom "update_component.month", "ucmonth"
52
+ atom "revision_year", "revyear"
53
+ atom "revision_month", "revmonth"
54
+ atom "revised_date", "reviseddate"
55
+ atom "update_number", "updnumber"
56
+ atom "update_year", "updnumberyear"
57
+ atom "stage.type", "stagetype"
58
+ atom "stage.id", "stage"
59
+ atom "draft_number", "draftnumber"
60
+ atom "version_component.value", "v"
61
+ atom "version", "version"
62
+ atom "year", "year", kind: :int
63
+ atom "month", "month", kind: :int
64
+ atom "date_year", "dateyear"
65
+ atom "date_month", "datemonth"
66
+ atom "date_day", "dateday"
67
+ atom "dated_seq", "datedseq"
68
+ atom "prefix", "prefix"
69
+ atom "range_notation", "range"
70
+ atom "translation_component.code", "lang"
71
+ atom "draft", "draft"
72
+ atom "index", "index"
73
+ atom "insert", "insert"
74
+ atom "errata", "errata"
75
+ atom "appendix", "appendix"
76
+ atom "addendum", "addendum"
77
+ atom "addendum_number", "addendumnumber"
78
+
79
+ facet "publisher_was_parsed", "pubparsed", kind: :bool
80
+ facet "parsed_format", "format"
81
+ facet "edition.original_prefix", "edprefix"
82
+ facet "translation", "translationsrc"
83
+
84
+ type "pubid:nist:identifier", "id"
85
+ type "pubid:nist:circular", "circ"
86
+ type "pubid:nist:circular-supplement", "circsup"
87
+ type "pubid:nist:commercial-standard", "cs"
88
+ type "pubid:nist:commercial-standard-emergency", "cse"
89
+ type "pubid:nist:commercial-standards-monthly", "csm"
90
+ type "pubid:nist:crpl-report", "crpl"
91
+ type "pubid:nist:dated-document", "dd"
92
+ type "pubid:nist:federal-information-processing-standards", "fips"
93
+ type "pubid:nist:grant-contractor-report", "gcr"
94
+ type "pubid:nist:handbook", "hb"
95
+ type "pubid:nist:interagency-report", "ir"
96
+ type "pubid:nist:letter-circular", "lc"
97
+ type "pubid:nist:miscellaneous-publication", "mp"
98
+ type "pubid:nist:monograph", "mono"
99
+ type "pubid:nist:ncstar", "ncstar"
100
+ type "pubid:nist:nsrds", "nsrds"
101
+ type "pubid:nist:owmwp", "owmwp"
102
+ type "pubid:nist:report", "rpt"
103
+ type "pubid:nist:special-publication", "sp"
104
+ type "pubid:nist:technical-note", "tn"
105
+ type "pubid:nist:supplement-identifier", "sup"
106
+ end
107
+ end
108
+ end
data/lib/pubid/nist.rb CHANGED
@@ -21,6 +21,7 @@ module Pubid
21
21
  autoload :Caster, "#{__dir__}/nist/caster"
22
22
  autoload :CircularSupplementBuilder, "#{__dir__}/nist/circular_supplement_builder"
23
23
  autoload :Components, "#{__dir__}/nist/components"
24
+ autoload :PubidUrnSchema, "#{__dir__}/nist/pubid_urn_schema"
24
25
  autoload :Configuration, "#{__dir__}/nist/configuration"
25
26
  autoload :Identifier, "#{__dir__}/nist/identifiers/base"
26
27
  autoload :Identifiers, "#{__dir__}/nist/identifiers"
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module PubidUrn
5
+ # Reads the body of a pubid-scheme URN back into an identifier hash.
6
+ # The inverse of Encoder; strict about structure. Canonical form (atom
7
+ # order, escapes, trailing "~") is checked by PubidUrn.parse, which
8
+ # re-encodes the result and compares.
9
+ class Decoder
10
+ FACET = /\A(\d+)\.([a-z0-9]+)-(.*)\z/
11
+
12
+ def initialize(body)
13
+ @body = body
14
+ end
15
+
16
+ # @return [Hash] the identifier hash (+_type+ and +base+ included)
17
+ def to_identifier_hash
18
+ flavor, rest = @body.split(":", 2)
19
+ fail!("no type segment") if rest.nil? || rest.empty?
20
+ decode_identifier(rest, flavor)
21
+ end
22
+
23
+ private
24
+
25
+ def decode_identifier(body, flavor)
26
+ tokens = PubidUrn.split_top(body, ":")
27
+ nodes = []
28
+ schemas = []
29
+ i = 0
30
+ while i < tokens.size
31
+ token = tokens[i]
32
+ if token == "f"
33
+ decode_facets(tokens.drop(i + 1), nodes, schemas)
34
+ break
35
+ end
36
+
37
+ schema = node_schema(token, flavor)
38
+ node = { "_type" => schema.type }
39
+ i = decode_core(tokens, i + 1, node, schema)
40
+ i = decode_atoms(tokens, i, node, schema)
41
+ nodes << node
42
+ schemas << schema
43
+ end
44
+ fail!("no identifier") if nodes.empty?
45
+
46
+ nodes.each_cons(2) { |base, node| node["base"] = base }
47
+ nodes.last
48
+ end
49
+
50
+ def node_schema(token, flavor)
51
+ if token == "type"
52
+ fail!("type-metadata URN names a type, not a document")
53
+ end
54
+
55
+ schema = if PubidUrn.structural?(token)
56
+ owner, name = PubidUrn.split_token(token, flavor)
57
+ PubidUrn.flavor_schema(owner)&.for_token(name)
58
+ end
59
+ fail!("unknown type or segment #{token.inspect}") unless schema
60
+
61
+ schema
62
+ end
63
+
64
+ def decode_core(tokens, index, node, schema)
65
+ schema.core.each do |parts|
66
+ break if index >= tokens.size
67
+
68
+ token = tokens[index]
69
+ break if PubidUrn.structural?(token)
70
+
71
+ Paths.put(node, parts, Escape.decode(token)) unless token == NULL
72
+ index += 1
73
+ end
74
+ index
75
+ end
76
+
77
+ def decode_atoms(tokens, index, node, schema)
78
+ last = -1
79
+ while index < tokens.size &&
80
+ (position = schema.atom_position(tokens[index]))
81
+ if position <= last
82
+ fail!("atom #{tokens[index].inspect} is out of order or repeated")
83
+ end
84
+
85
+ last = position
86
+ atom = schema.atoms[position]
87
+ raw = tokens[index].split("-", 2).last
88
+ Paths.put(node, atom.parts, atom_value(raw, atom))
89
+ index += 1
90
+ end
91
+ index
92
+ end
93
+
94
+ def atom_value(raw, atom)
95
+ case atom.kind
96
+ when :int then integer(raw)
97
+ when :id then nested(raw)
98
+ when :ids then PubidUrn.split_top(raw, ",").map { |item| nested(item) }
99
+ else slot_value(raw, atom)
100
+ end
101
+ end
102
+
103
+ # The value of a :string, :bool or list atom, or of a facet.
104
+ def slot_value(raw, slot)
105
+ return scalar(raw, slot) unless slot.list?
106
+
107
+ PubidUrn.split_top(raw, ",", allow_empty: true).map do |item|
108
+ item == NULL ? nil : scalar(item, slot)
109
+ end
110
+ end
111
+
112
+ def scalar(raw, slot)
113
+ slot.element == :bool ? boolean(raw) : Escape.decode(raw)
114
+ end
115
+
116
+ def boolean(raw)
117
+ return true if raw == "true"
118
+ return false if raw == "false"
119
+
120
+ fail!("#{raw.inspect} is not a boolean")
121
+ end
122
+
123
+ def integer(raw)
124
+ unless /\A-?[0-9]+\z/.match?(raw)
125
+ fail!("#{raw.inspect} is not an integer")
126
+ end
127
+
128
+ Integer(raw, 10)
129
+ end
130
+
131
+ def nested(raw)
132
+ unless raw.start_with?("(") && raw.end_with?(")")
133
+ fail!("nested identifier #{raw.inspect} needs brackets")
134
+ end
135
+
136
+ Decoder.new(raw[1..-2]).to_identifier_hash
137
+ end
138
+
139
+ def decode_facets(tokens, nodes, schemas)
140
+ fail!("empty facet group") if tokens.empty?
141
+
142
+ tokens.each do |token|
143
+ match = FACET.match(token) || fail!("bad facet #{token.inspect}")
144
+ index = Integer(match[1], 10)
145
+ schema = schemas[index] || fail!("facet #{token.inspect} has no node")
146
+ facet = schema.facet(match[2]) || fail!("bad facet #{token.inspect}")
147
+ Paths.put(nodes[index], facet.parts, slot_value(match[3], facet))
148
+ end
149
+ end
150
+
151
+ def fail!(message)
152
+ raise Errors::UrnParseError, "pubid URN #{@body.inspect}: #{message}"
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module PubidUrn
5
+ # Writes the pubid-scheme URN of an identifier hash.
6
+ #
7
+ # urn:pubid:<flavor>:<node>[:<node>…][:f:<facet>…]
8
+ # <node> = <type>[:<core>…][:<prefix>-<value>…]
9
+ #
10
+ # The nodes go root first, along +base+: "ISO 9001:2015/Amd 1:2016" is
11
+ # "is:9001:year-2015:amd:1:year-2016", so every supplement of a document
12
+ # shares its slug prefix. A core value is positional; an absent one in
13
+ # the middle is "~", and absent ones at the end are dropped. An optional
14
+ # value is a prefixed atom, so it leaves no segment when it is absent.
15
+ class Encoder
16
+ def initialize(hash)
17
+ @hash = hash
18
+ end
19
+
20
+ # @return [String] the body after "urn:pubid:"
21
+ def body
22
+ flavor = PubidUrn.flavor_segment(@hash["_type"])
23
+ "#{flavor}:#{encode_identifier(@hash, flavor)}"
24
+ end
25
+
26
+ private
27
+
28
+ def encode_identifier(hash, flavor)
29
+ segments = []
30
+ facets = []
31
+ chain(hash).each_with_index do |node, index|
32
+ schema = PubidUrn.type_schema!(node["_type"],
33
+ error: Errors::InvalidInputError)
34
+ node = deep_dup(node)
35
+ node.delete("_type")
36
+ segments << token(schema, flavor)
37
+ segments.concat(core_segments(node, schema))
38
+ segments.concat(atom_segments(node, schema))
39
+ facets.concat(facet_segments(node, schema, index))
40
+ check_placed!(node, schema)
41
+ end
42
+ segments.push("f", *facets) unless facets.empty?
43
+ segments.join(":")
44
+ end
45
+
46
+ # The nodes from the root document to +hash+ itself.
47
+ def chain(hash)
48
+ nodes = []
49
+ node = hash
50
+ while node
51
+ unless node.is_a?(Hash) && node["_type"].is_a?(String)
52
+ raise Errors::InvalidInputError,
53
+ "a pubid URN base must be an identifier hash, " \
54
+ "got #{node.inspect}"
55
+ end
56
+
57
+ nodes.unshift(node.except("base"))
58
+ node = node["base"]
59
+ end
60
+ nodes
61
+ end
62
+
63
+ def token(schema, flavor)
64
+ return schema.token if schema.flavor == flavor
65
+
66
+ "#{schema.flavor}.#{schema.token}"
67
+ end
68
+
69
+ def core_segments(node, schema)
70
+ values = schema.core.map { |parts| Paths.take(node, parts) }
71
+ values.pop while !values.empty? && values.last.nil?
72
+ values.map do |value|
73
+ value.nil? ? NULL : core_value(value, schema)
74
+ end
75
+ end
76
+
77
+ # A core value that has the shape of structure (PubidUrn::STRUCTURE)
78
+ # gets its first character escaped.
79
+ def core_value(value, schema)
80
+ unless value.is_a?(String) && !value.empty?
81
+ raise Errors::InvalidInputError,
82
+ "core value of #{schema.type} must be a non-empty String, " \
83
+ "got #{value.inspect}"
84
+ end
85
+
86
+ encoded = Escape.encode(value)
87
+ return encoded unless PubidUrn.structural?(encoded)
88
+
89
+ Escape.force(value[0]) + Escape.encode(value[1..])
90
+ end
91
+
92
+ def atom_segments(node, schema)
93
+ schema.atoms.filter_map do |atom|
94
+ value = Paths.take(node, atom.parts)
95
+ next if value.nil?
96
+
97
+ "#{atom.prefix}-#{atom_value(value, atom, schema)}"
98
+ end
99
+ end
100
+
101
+ def atom_value(value, atom, schema)
102
+ case atom.kind
103
+ when :int then typed(value, Integer, atom, schema).to_s
104
+ when :id then nested(typed(value, Hash, atom, schema))
105
+ when :ids then nested_list(typed(value, Array, atom, schema))
106
+ else slot_value(value, atom, schema)
107
+ end
108
+ end
109
+
110
+ # The value of a :string, :bool or list atom, or of a facet.
111
+ def slot_value(value, slot, schema)
112
+ return scalar(value, slot, schema) unless slot.list?
113
+
114
+ typed(value, Array, slot, schema).map do |item|
115
+ item.nil? ? NULL : scalar(item, slot, schema)
116
+ end.join(",")
117
+ end
118
+
119
+ def scalar(value, slot, schema)
120
+ if slot.element == :bool
121
+ typed(value, [true, false], slot, schema).to_s
122
+ else
123
+ Escape.encode(typed(value, String, slot, schema))
124
+ end
125
+ end
126
+
127
+ def nested(hash)
128
+ "(#{Encoder.new(hash).body})"
129
+ end
130
+
131
+ def nested_list(hashes)
132
+ hashes.map { |hash| nested(hash) }.join(",")
133
+ end
134
+
135
+ def facet_segments(node, schema, index)
136
+ schema.facets.filter_map do |facet|
137
+ value = Paths.take(node, facet.parts)
138
+ next if value.nil?
139
+
140
+ "#{index}.#{facet.name}-#{slot_value(value, facet, schema)}"
141
+ end
142
+ end
143
+
144
+ def typed(value, expected, slot, schema)
145
+ ok = if expected.is_a?(Array)
146
+ expected.include?(value)
147
+ else
148
+ value.is_a?(expected)
149
+ end
150
+ return value if ok
151
+
152
+ raise Errors::InvalidInputError,
153
+ "#{schema.type} #{slot.path}: #{value.inspect} " \
154
+ "is not #{slot.kind}"
155
+ end
156
+
157
+ def check_placed!(node, schema)
158
+ left = node.reject { |_, value| Paths.blank?(value) }
159
+ return if left.empty?
160
+
161
+ raise Errors::InvalidInputError,
162
+ "the pubid URN schema of #{schema.type} " \
163
+ "does not place #{left.inspect}"
164
+ end
165
+
166
+ def deep_dup(value)
167
+ case value
168
+ when Hash then value.to_h { |k, v| [k, deep_dup(v)] }
169
+ when Array then value.map { |v| deep_dup(v) }
170
+ else value
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module PubidUrn
5
+ # The value escape of the pubid-scheme URN.
6
+ #
7
+ # The URN is also an HTTP path slug (relaton/relaton#441), so it cannot
8
+ # use "%": servers and proxies decode "%2F" before they route, or they
9
+ # refuse it. "~XX" (uppercase hex of one UTF-8 byte) has no meaning in a
10
+ # path, and "~" is an RFC 3986 unreserved character. Every byte outside
11
+ # SAFE is escaped, so ":" "," "(" ")" "~" never occur raw in a value and
12
+ # stay free for the structure of the URN.
13
+ module Escape
14
+ SAFE = /[A-Za-z0-9._-]/
15
+ RAW_TOKEN = /\A(?:[A-Za-z0-9._-]|~[0-9A-F]{2})*\z/
16
+
17
+ module_function
18
+
19
+ # @param value [String]
20
+ # @return [String]
21
+ def encode(value)
22
+ value.to_s.each_char.map { |char| encode_char(char) }.join
23
+ end
24
+
25
+ # Escape every character of +char+, safe or not. The codec uses it on
26
+ # the first character of a core value that would otherwise read as
27
+ # structure ("amd" -> "~61md").
28
+ #
29
+ # @param char [String] one character
30
+ # @return [String]
31
+ def force(char)
32
+ char.b.each_byte.map { |byte| format("~%02X", byte) }.join
33
+ end
34
+
35
+ # @param token [String]
36
+ # @return [String]
37
+ # @raise [Pubid::Errors::UrnParseError] for a raw character outside the
38
+ # safe set, or a malformed or lower-case escape
39
+ def decode(token)
40
+ unless RAW_TOKEN.match?(token)
41
+ raise Pubid::Errors::UrnParseError,
42
+ "bad character or escape in pubid URN value #{token.inspect}"
43
+ end
44
+
45
+ bytes = token.b.gsub(/~([0-9A-F]{2})/n) { Regexp.last_match(1).hex.chr }
46
+ decoded = bytes.force_encoding(Encoding::UTF_8)
47
+ unless decoded.valid_encoding?
48
+ raise Pubid::Errors::UrnParseError,
49
+ "escape in pubid URN value #{token.inspect} is not UTF-8"
50
+ end
51
+
52
+ decoded
53
+ end
54
+
55
+ def encode_char(char)
56
+ SAFE.match?(char) ? char : force(char)
57
+ end
58
+ end
59
+ end
60
+ end