pubid 2.0.6 → 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,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module PubidUrn
5
+ # One prefixed atom: an optional value, found by its prefix and not by
6
+ # its position ("part-1", "co-IEC,IEEE", "ids-(iso:is:9001)").
7
+ #
8
+ # +path+ is a dotted key path into the node's +to_hash+; "*" walks the
9
+ # elements of an Array ("languages.*.code"). +kind+ is one of
10
+ # :string, :list (of Strings), :bool, :bools (a list of booleans), :int,
11
+ # :id (a nested identifier hash) or :ids (an Array of them). A path with
12
+ # "*" is always a list: :list, or :bools when its elements are booleans.
13
+ Atom = Struct.new(:path, :prefix, :kind) do
14
+ def parts
15
+ @parts ||= path.split(".")
16
+ end
17
+
18
+ def list?
19
+ LIST_KINDS.key?(kind)
20
+ end
21
+
22
+ # @return [Symbol] the kind of one value (of one element of a list)
23
+ def element
24
+ LIST_KINDS.fetch(kind, kind)
25
+ end
26
+ end
27
+
28
+ # A list kind => the kind of its elements.
29
+ LIST_KINDS = { list: :string, bools: :bool }.freeze
30
+
31
+ # A spelling-only value (+original_code+, +original_text+ …). Facets are
32
+ # written in the trailing "f" group, so a consumer can cut them off a slug.
33
+ # +kind+ is :string, :list, :bool or :bools, as for an Atom; a spelling
34
+ # flag ("V.25 ter" against "V.25ter") is a :bool facet.
35
+ Facet = Struct.new(:path, :name, :kind) do
36
+ def parts
37
+ @parts ||= path.split(".")
38
+ end
39
+
40
+ def list?
41
+ LIST_KINDS.key?(kind)
42
+ end
43
+
44
+ def element
45
+ LIST_KINDS.fetch(kind, kind)
46
+ end
47
+ end
48
+
49
+ # The URN shape of one concrete identifier type.
50
+ class TypeSchema
51
+ attr_reader :flavor, :type, :token, :core, :atoms, :facets
52
+
53
+ def initialize(flavor:, type:, token:, core:, atoms:, facets:)
54
+ @flavor = flavor
55
+ @type = type
56
+ @token = token
57
+ @core = core.map { |path| path.split(".") }.freeze
58
+ @atoms = atoms.freeze
59
+ @facets = facets.freeze
60
+ @atom_index = atoms.each_with_index.to_h { |atom, i| [atom.prefix, i] }
61
+ @facet_names = facets.to_h { |facet| [facet.name, facet] }
62
+ end
63
+
64
+ # @return [Integer, nil] the canonical position of the atom whose
65
+ # prefix starts +token+, or nil when +token+ is no atom of this type
66
+ def atom_position(token)
67
+ prefix = token[/\A([a-z0-9]+)-/, 1]
68
+ prefix && @atom_index[prefix]
69
+ end
70
+
71
+ def facet(name)
72
+ @facet_names[name]
73
+ end
74
+ end
75
+
76
+ # The pubid-scheme schema of one flavor: the canonical atom order, the
77
+ # facets, and one TypeSchema for each concrete type.
78
+ #
79
+ # Atoms and facets declared at flavor level apply to every type, in the
80
+ # order of declaration; a type adds its own after them. A type token, an
81
+ # atom prefix and a facet name are a lower-case letter followed by
82
+ # lower-case letters and digits, so none can contain the "-" that ends a
83
+ # prefix or the "." that qualifies a token of another flavor, and each
84
+ # has the shape PubidUrn::STRUCTURE reads as structure.
85
+ class FlavorSchema
86
+ NAME = /\A[a-z][a-z0-9]*\z/
87
+ KINDS = %i[string list bool bools int id ids].freeze
88
+ FACET_KINDS = %i[string list bool bools].freeze
89
+ # "f" opens the facet group; "type" is the type-metadata URN
90
+ # (Pubid::IdentifierMetadata, "urn:pubid:<flavor>:type:<key>").
91
+ RESERVED_TOKENS = %w[f type].freeze
92
+
93
+ attr_reader :flavor, :types
94
+
95
+ def initialize(flavor)
96
+ @flavor = flavor
97
+ @atoms = []
98
+ @facets = []
99
+ @types = {}
100
+ @tokens = {}
101
+ end
102
+
103
+ def atom(path, prefix, kind: nil)
104
+ @atoms << build_atom(path, prefix, kind)
105
+ end
106
+
107
+ def facet(path, name, kind: nil)
108
+ @facets << build_facet(path, name, kind)
109
+ end
110
+
111
+ # @param type [String] the +_type+ the class serializes
112
+ # @param token [String] the short type token of the URN
113
+ # @param core [Array<String>] the positional core paths
114
+ # @param atoms [Array<Array>] extra atoms: [path, prefix, kind]
115
+ # @param facets [Array<Array>] extra facets: [path, name, kind]
116
+ def type(type, token, core: %w[number], atoms: [], facets: [])
117
+ check_name!(token, "type token")
118
+ if RESERVED_TOKENS.include?(token) || @tokens.key?(token)
119
+ raise ArgumentError,
120
+ "type token #{token.inspect} is reserved or taken"
121
+ end
122
+
123
+ all_atoms = @atoms + atoms.map { |spec| build_atom(*spec) }
124
+ check_unique!(all_atoms.map(&:prefix), "atom prefix", type)
125
+ all_facets = @facets + facets.map { |spec| build_facet(*spec) }
126
+ check_unique!(all_facets.map(&:name), "facet name", type)
127
+
128
+ schema = TypeSchema.new(flavor: flavor, type: type, token: token,
129
+ core: core, atoms: all_atoms,
130
+ facets: all_facets)
131
+ @types[type] = schema
132
+ @tokens[token] = schema
133
+ end
134
+
135
+ def for_token(token)
136
+ @tokens[token]
137
+ end
138
+
139
+ def for_type(type)
140
+ @types[type]
141
+ end
142
+
143
+ private
144
+
145
+ def build_atom(path, prefix, kind = nil)
146
+ check_name!(prefix, "atom prefix")
147
+ Atom.new(path, prefix, check_kind!(path, kind, KINDS, "atom"))
148
+ end
149
+
150
+ def build_facet(path, name, kind = nil)
151
+ check_name!(name, "facet name")
152
+ Facet.new(path, name, check_kind!(path, kind, FACET_KINDS, "facet"))
153
+ end
154
+
155
+ # A path with "*" reads a list, so its kind must be a list kind.
156
+ def check_kind!(path, kind, kinds, what)
157
+ listed = path.split(".").include?("*")
158
+ kind ||= listed ? :list : :string
159
+ unless kinds.include?(kind)
160
+ raise ArgumentError, "unknown #{what} kind #{kind.inspect}"
161
+ end
162
+ if listed && !LIST_KINDS.key?(kind)
163
+ raise ArgumentError, "#{what} #{path.inspect} reads a list, " \
164
+ "so its kind must be one of #{LIST_KINDS.keys}"
165
+ end
166
+
167
+ kind
168
+ end
169
+
170
+ def check_name!(name, what)
171
+ return if NAME.match?(name)
172
+
173
+ raise ArgumentError, "#{what} #{name.inspect} must match #{NAME.source}"
174
+ end
175
+
176
+ def check_unique!(names, what, type)
177
+ dup = names.tally.find { |_, count| count > 1 }
178
+ return unless dup
179
+
180
+ raise ArgumentError, "#{what} #{dup.first.inspect} repeats in #{type}"
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ # The pubid-scheme URN (issue #656): pubid's own URN namespace, which
5
+ # carries the full typed model and always round-trips —
6
+ # +Pubid.parse_urn(id.to_pubid_urn).to_hash == id.to_hash+.
7
+ #
8
+ # It is a different thing from the official URN (+#to_urn+, e.g.
9
+ # "urn:iso:std:…"), which follows the registered grammar of the
10
+ # organization and may be lossy. relaton uses the pubid-scheme URN as its
11
+ # HTTP path slug (relaton/relaton#441), so the form is also path-safe:
12
+ # no "/", "%", "?", "#" and no empty segment.
13
+ #
14
+ # urn:pubid:iso:is:9001:year-2015:amd:1:year-2016
15
+ #
16
+ # The shape of each identifier type is declared in its flavor's
17
+ # +PubidUrnSchema+ (e.g. +lib/pubid/iso/pubid_urn_schema.rb+), through
18
+ # {define}. A flavor with no schema cannot emit one yet. The design of
19
+ # every flavor is in +docs/URN-SCHEMES.adoc+.
20
+ module PubidUrn
21
+ autoload :Escape, "pubid/pubid_urn/escape"
22
+ autoload :Atom, "pubid/pubid_urn/schema"
23
+ autoload :Facet, "pubid/pubid_urn/schema"
24
+ autoload :TypeSchema, "pubid/pubid_urn/schema"
25
+ autoload :FlavorSchema, "pubid/pubid_urn/schema"
26
+ autoload :Paths, "pubid/pubid_urn/paths"
27
+ autoload :Encoder, "pubid/pubid_urn/encoder"
28
+ autoload :Decoder, "pubid/pubid_urn/decoder"
29
+
30
+ PREFIX = "urn:pubid:"
31
+ # An absent core value in the middle of the core, or an absent element
32
+ # of a list. Distinct from an escape, which always has two hex digits.
33
+ NULL = "~"
34
+
35
+ # A raw segment that reads as structure and not as a core value: a type
36
+ # token or the facet marker ("amd", "f"), an atom ("year-2015"), or a
37
+ # token of another flavor ("iec.is", "3gpp.ts").
38
+ #
39
+ # The rule depends on the SHAPE of the segment only, never on which
40
+ # schemas exist. So a published URN keeps its meaning when a flavor, a
41
+ # type token or an atom is added later. Type tokens, atom prefixes and
42
+ # facet names therefore start with a lower-case letter
43
+ # (FlavorSchema::NAME). Digit-led numbers ("9001", "802.11", "800-53")
44
+ # and upper-case ones ("G.711") never match; a core value that does
45
+ # match gets its first character escaped ("iec.is" -> "~69ec.is").
46
+ STRUCTURE = /
47
+ \A(?:
48
+ [a-z][a-z0-9]*(?:-|\z) # token, "f", atom
49
+ | [a-z0-9]*[a-z][a-z0-9]*\.[a-z][a-z0-9]*\z # token of another flavor
50
+ )
51
+ /x
52
+
53
+ # The longest URN that parse accepts, and that generate emits. An
54
+ # escape is up to 3 characters for each UTF-8 byte (up to 9 for a CJK
55
+ # character), so the 1000-character limit of a printed identifier is too
56
+ # tight here. The parser is linear-time (anchored, non-backtracking
57
+ # patterns), so this bound is a guard against oversized input, not a
58
+ # ReDoS barrier.
59
+ MAX_LENGTH = 16 * Pubid::MAX_INPUT_LENGTH
60
+
61
+ @schemas = {}
62
+
63
+ class << self
64
+ # Declare the schema of a flavor. +flavor+ is the flavor segment of
65
+ # its +_type+ ("iso", "cencenelec", "3gpp").
66
+ #
67
+ # @yield the FlavorSchema, evaluated with instance_eval
68
+ # @return [FlavorSchema]
69
+ def define(flavor, &)
70
+ schema = FlavorSchema.new(flavor)
71
+ schema.instance_eval(&)
72
+ @schemas[flavor] = schema
73
+ end
74
+
75
+ # @param identifier [Pubid::Identifier]
76
+ # @return [String]
77
+ # @raise [Pubid::Errors::InvalidInputError] when its flavor has no
78
+ # schema yet, the schema does not place one of its values, or the URN
79
+ # would be longer than MAX_LENGTH
80
+ def generate(identifier)
81
+ urn = "#{PREFIX}#{Encoder.new(identifier.to_hash).body}"
82
+ if urn.length > MAX_LENGTH
83
+ raise Errors::InvalidInputError,
84
+ "pubid URN exceeds #{MAX_LENGTH} characters"
85
+ end
86
+
87
+ urn
88
+ end
89
+
90
+ # @param string [String] a pubid-scheme URN
91
+ # @return [Pubid::Identifier]
92
+ # @raise [Pubid::Errors::InvalidInputError] when +string+ is not a
93
+ # String or is longer than MAX_LENGTH
94
+ # @raise [Pubid::Errors::UrnParseError] for a malformed, unknown or
95
+ # non-canonical URN
96
+ def parse(string)
97
+ check_input!(string)
98
+ identifier = build(string[PREFIX.length..])
99
+ check_canonical!(identifier, string)
100
+ identifier
101
+ end
102
+
103
+ # True for "urn:pubid:…" (the NID is case-insensitive, RFC 8141).
104
+ # Compares bytes, so it is safe on a string with invalid encoding.
105
+ def pubid_urn?(string)
106
+ string.is_a?(String) && string.b[0, PREFIX.length].downcase == PREFIX
107
+ end
108
+
109
+ # @return [FlavorSchema, nil]
110
+ def flavor_schema(flavor)
111
+ return @schemas[flavor] if @schemas.key?(flavor)
112
+
113
+ mod = TypeResolver.flavor_module(flavor)
114
+ if mod&.const_defined?(:PubidUrnSchema, false)
115
+ mod.const_get(:PubidUrnSchema)
116
+ end
117
+ @schemas[flavor]
118
+ end
119
+
120
+ # @return [TypeSchema]
121
+ def type_schema!(type, error:)
122
+ flavor = flavor_segment(type)
123
+ schema = flavor && flavor_schema(flavor)&.for_type(type)
124
+ return schema if schema
125
+
126
+ raise error, "no pubid URN schema for #{type.inspect}"
127
+ end
128
+
129
+ # "iso" in "pubid:iso:technical-report".
130
+ def flavor_segment(type)
131
+ return nil unless type.is_a?(String)
132
+
133
+ type.split(":")[1]
134
+ end
135
+
136
+ # Does +token+ (a raw, still escaped segment) read as structure? See
137
+ # STRUCTURE.
138
+ def structural?(token)
139
+ STRUCTURE.match?(token)
140
+ end
141
+
142
+ # A type token is "<token>" of the URN's own flavor, or "<flavor>.<token>"
143
+ # for a node of another flavor.
144
+ #
145
+ # @return [Array(String, String)] the owning flavor and the token
146
+ def split_token(token, flavor)
147
+ token.include?(".") ? token.split(".", 2) : [flavor, token]
148
+ end
149
+
150
+ # Split +string+ at +separator+ outside brackets.
151
+ #
152
+ # @param allow_empty [Boolean] true for the items of a list, where an
153
+ # empty item is an empty String; a ":" segment is never empty
154
+ def split_top(string, separator, allow_empty: false)
155
+ parts = []
156
+ depth = 0
157
+ current = +""
158
+ string.each_char do |char|
159
+ depth += 1 if char == "("
160
+ depth -= 1 if char == ")"
161
+ check_depth!(string) if depth.negative?
162
+
163
+ if char == separator && depth.zero?
164
+ parts << current
165
+ current = +""
166
+ else
167
+ current << char
168
+ end
169
+ end
170
+ check_depth!(string) unless depth.zero?
171
+
172
+ parts << current
173
+ if !allow_empty && parts.any?(&:empty?)
174
+ raise Errors::UrnParseError, "empty segment in #{string.inspect}"
175
+ end
176
+
177
+ parts
178
+ end
179
+
180
+ private
181
+
182
+ def check_input!(string)
183
+ unless string.is_a?(String)
184
+ raise Errors::InvalidInputError, Pubid::INPUT_NOT_A_STRING_MESSAGE
185
+ end
186
+ if string.length > MAX_LENGTH
187
+ raise Errors::InvalidInputError,
188
+ "input exceeds #{MAX_LENGTH} characters"
189
+ end
190
+ unless string.valid_encoding? && pubid_urn?(string)
191
+ raise Errors::UrnParseError,
192
+ "not a pubid-scheme URN: #{string.inspect}"
193
+ end
194
+ end
195
+
196
+ # The URN is untrusted input: a value that decodes but cannot be cast
197
+ # into its attribute (a bad boolean, a String where a component goes)
198
+ # is a bad URN, so every failure here is reported as UrnParseError,
199
+ # with the original error as its cause.
200
+ def build(body)
201
+ Pubid.from_hash(Decoder.new(body).to_identifier_hash)
202
+ rescue Errors::UrnParseError
203
+ raise
204
+ rescue StandardError => e
205
+ raise Errors::UrnParseError,
206
+ "pubid URN #{body.inspect} does not build: #{e.message}"
207
+ end
208
+
209
+ def check_depth!(string)
210
+ raise Errors::UrnParseError, "unbalanced bracket in #{string.inspect}"
211
+ end
212
+
213
+ def check_canonical!(identifier, string)
214
+ canonical = identifier.to_pubid_urn
215
+ return if canonical[PREFIX.length..] == string[PREFIX.length..]
216
+
217
+ raise Errors::UrnParseError,
218
+ "#{string.inspect} is not canonical; " \
219
+ "the canonical form is #{canonical.inspect}"
220
+ rescue Errors::InvalidInputError => e
221
+ raise Errors::UrnParseError,
222
+ "#{string.inspect} does not re-encode: #{e.message}"
223
+ end
224
+ end
225
+ end
226
+ end
@@ -29,6 +29,18 @@ module Pubid
29
29
  shared_class(type) || flavor_class(type)
30
30
  end
31
31
 
32
+ # The flavor module that the flavor segment of a +_type+ names
33
+ # ("iso", "cencenelec", "3gpp").
34
+ #
35
+ # @param segment [String]
36
+ # @return [Module, nil]
37
+ def flavor_module(segment)
38
+ return nil unless segment.is_a?(String)
39
+
40
+ ::Pubid.eager_load_flavors!
41
+ ::Pubid::Registry.get(segment) || flavor_by_module_segment(segment)
42
+ end
43
+
32
44
  private
33
45
 
34
46
  def shared_class(type)
data/lib/pubid/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pubid
4
- VERSION = "2.0.6"
4
+ VERSION = "2.0.7"
5
5
  end
data/lib/pubid.rb CHANGED
@@ -160,6 +160,7 @@ module Pubid
160
160
  autoload :Version, "pubid/version"
161
161
  autoload :Core, "pubid/core"
162
162
  autoload :TypeResolver, "pubid/type_resolver"
163
+ autoload :PubidUrn, "pubid/pubid_urn"
163
164
 
164
165
 
165
166
  # Require all flavor modules
@@ -281,8 +282,13 @@ module Pubid
281
282
  #
282
283
  # @param string [String] the URN
283
284
  # @return [Identifier] the parsed identifier
285
+ #
286
+ # A pubid-scheme URN ("urn:pubid:…", see {PubidUrn}) is decoded by the
287
+ # shared codec; every other namespace goes to its flavor's UrnParser.
284
288
  def self.parse_urn(string)
285
289
  eager_load_flavors!
290
+ return PubidUrn.parse(string) if PubidUrn.pubid_urn?(string)
291
+
286
292
  flavor = detect_flavor_from_urn(string)
287
293
  flavor_module = Registry.get(flavor)
288
294
  unless flavor_module
@@ -294,6 +300,18 @@ module Pubid
294
300
  urn_parser.parse(string)
295
301
  end
296
302
 
303
+ # Parse a pubid-scheme URN, and only that: an official URN such as
304
+ # "urn:iso:std:…" raises. For a consumer that takes a slug and must not
305
+ # accept a lossy official URN by mistake (relaton/relaton#441).
306
+ #
307
+ # @param string [String] a "urn:pubid:…" URN
308
+ # @return [Identifier]
309
+ # @raise [Pubid::Errors::UrnParseError]
310
+ def self.parse_pubid_urn(string)
311
+ eager_load_flavors!
312
+ PubidUrn.parse(string)
313
+ end
314
+
297
315
  # Build an identifier from its serialized hash, without naming its flavor —
298
316
  # the hash counterpart of {parse}.
299
317
  #
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pubid
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.6
4
+ version: 2.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-11-03 00:00:00.000000000 Z
11
+ date: 2026-11-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model
@@ -168,6 +168,7 @@ files:
168
168
  - lib/pubid/ansi/identifiers/american_national_standard.rb
169
169
  - lib/pubid/ansi/identifiers/standard.rb
170
170
  - lib/pubid/ansi/parser.rb
171
+ - lib/pubid/ansi/pubid_urn_schema.rb
171
172
  - lib/pubid/ansi/renderer.rb
172
173
  - lib/pubid/ansi/single_identifier.rb
173
174
  - lib/pubid/ansi/urn_generator.rb
@@ -244,6 +245,7 @@ files:
244
245
  - lib/pubid/astm/identifiers/technical_report.rb
245
246
  - lib/pubid/astm/identifiers/work_in_progress.rb
246
247
  - lib/pubid/astm/parser.rb
248
+ - lib/pubid/astm/pubid_urn_schema.rb
247
249
  - lib/pubid/astm/renderer.rb
248
250
  - lib/pubid/astm/single_identifier.rb
249
251
  - lib/pubid/astm/urn_generator.rb
@@ -602,6 +604,7 @@ files:
602
604
  - lib/pubid/iec/identifiers/white_paper.rb
603
605
  - lib/pubid/iec/identifiers/working_document.rb
604
606
  - lib/pubid/iec/parser.rb
607
+ - lib/pubid/iec/pubid_urn_schema.rb
605
608
  - lib/pubid/iec/renderer.rb
606
609
  - lib/pubid/iec/rendering_style.rb
607
610
  - lib/pubid/iec/single_identifier.rb
@@ -657,6 +660,7 @@ files:
657
660
  - lib/pubid/ieee/parser.rb
658
661
  - lib/pubid/ieee/pre_parser.rb
659
662
  - lib/pubid/ieee/project_renderer.rb
663
+ - lib/pubid/ieee/pubid_urn_schema.rb
660
664
  - lib/pubid/ieee/renderer.rb
661
665
  - lib/pubid/ieee/typed_stages.rb
662
666
  - lib/pubid/ieee/urn_generator.rb
@@ -731,6 +735,7 @@ files:
731
735
  - lib/pubid/iso/identifiers/technology_trends_assessments.rb
732
736
  - lib/pubid/iso/normalizer.rb
733
737
  - lib/pubid/iso/parser.rb
738
+ - lib/pubid/iso/pubid_urn_schema.rb
734
739
  - lib/pubid/iso/rendering_style.rb
735
740
  - lib/pubid/iso/single_identifier.rb
736
741
  - lib/pubid/iso/supplement_identifier.rb
@@ -769,6 +774,7 @@ files:
769
774
  - lib/pubid/itu/identifiers/supplement.rb
770
775
  - lib/pubid/itu/model.rb
771
776
  - lib/pubid/itu/parser.rb
777
+ - lib/pubid/itu/pubid_urn_schema.rb
772
778
  - lib/pubid/itu/urn_generator.rb
773
779
  - lib/pubid/itu/urn_parser.rb
774
780
  - lib/pubid/jcgm.rb
@@ -853,6 +859,7 @@ files:
853
859
  - lib/pubid/nist/parser.rb
854
860
  - lib/pubid/nist/parser_output_normalizer.rb
855
861
  - lib/pubid/nist/preprocessor.rb
862
+ - lib/pubid/nist/pubid_urn_schema.rb
856
863
  - lib/pubid/nist/renderer.rb
857
864
  - lib/pubid/nist/router.rb
858
865
  - lib/pubid/nist/series.rb
@@ -944,6 +951,12 @@ files:
944
951
  - lib/pubid/plateau/urn_generator.rb
945
952
  - lib/pubid/plateau/urn_parser.rb
946
953
  - lib/pubid/prefixes_support.rb
954
+ - lib/pubid/pubid_urn.rb
955
+ - lib/pubid/pubid_urn/decoder.rb
956
+ - lib/pubid/pubid_urn/encoder.rb
957
+ - lib/pubid/pubid_urn/escape.rb
958
+ - lib/pubid/pubid_urn/paths.rb
959
+ - lib/pubid/pubid_urn/schema.rb
947
960
  - lib/pubid/renderers.rb
948
961
  - lib/pubid/renderers/annotator.rb
949
962
  - lib/pubid/renderers/base.rb