pubid 2.0.2 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/data/ieee/update_codes.yaml +2 -2
- data/data/parg/aiee.json +1 -1
- data/data/parg/aiee.parg +99 -9
- data/data/parg/iala.json +1 -1
- data/data/parg/ieee.json +1 -1
- data/data/parg/ire.json +1 -1
- data/data/parg/ire.parg +51 -12
- data/data/parg/iso.json +1 -1
- data/data/parg/iso.parg +751 -0
- data/data/parg/jis.json +1 -1
- data/data/parg/nesc.json +1 -1
- data/data/parg/nesc.parg +97 -12
- data/data/parg/parg.json +1 -1
- data/lib/pubid/iala/builder.rb +48 -39
- data/lib/pubid/iala/identifier.rb +1 -1
- data/lib/pubid/ieee/CLAUDE.md +2 -0
- data/lib/pubid/ieee/aiee/identifier.rb +1 -1
- data/lib/pubid/ieee/builder.rb +175 -28
- data/lib/pubid/ieee/components/code.rb +46 -7
- data/lib/pubid/ieee/identifiers/base.rb +4 -1
- data/lib/pubid/ieee/identifiers/joint_development.rb +7 -3
- data/lib/pubid/ieee/ire/identifier.rb +1 -1
- data/lib/pubid/ieee/nesc/builder.rb +1 -1
- data/lib/pubid/ieee/project_renderer.rb +5 -1
- data/lib/pubid/ieee/renderer.rb +4 -2
- data/lib/pubid/ieee/urn_generator.rb +4 -1
- data/lib/pubid/iho/CLAUDE.md +15 -6
- data/lib/pubid/iho/urn_generator.rb +9 -6
- data/lib/pubid/iho/urn_parser.rb +43 -40
- data/lib/pubid/iso/CLAUDE.md +66 -0
- data/lib/pubid/iso/builder.rb +228 -14
- data/lib/pubid/iso/identifier.rb +22 -12
- data/lib/pubid/iso/normalizer.rb +5 -4
- data/lib/pubid/iso/urn_parser.rb +13 -8
- data/lib/pubid/jis/CLAUDE.md +36 -0
- data/lib/pubid/jis/builder.rb +12 -12
- data/lib/pubid/jis/identifier.rb +11 -1
- data/lib/pubid/jis/identifiers/welding_engineering_standard.rb +35 -0
- data/lib/pubid/jis/identifiers.rb +2 -0
- data/lib/pubid/jis/renderer.rb +1 -1
- data/lib/pubid/jis/supplement_identifier.rb +5 -5
- data/lib/pubid/jis/urn_parser.rb +2 -1
- data/lib/pubid/jis.rb +3 -2
- data/lib/pubid/version.rb +1 -1
- metadata +5 -2
data/lib/pubid/iala/builder.rb
CHANGED
|
@@ -2,20 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module Pubid
|
|
4
4
|
module Iala
|
|
5
|
-
# Builds an IALA identifier object from the
|
|
5
|
+
# Builds an IALA identifier object from the artifact's bound map —
|
|
6
|
+
# the flat attribute hash the grammar's bindings block declares
|
|
7
|
+
# (typed at the engine, one key per capture).
|
|
6
8
|
class Builder
|
|
7
9
|
def build(parsed)
|
|
8
|
-
|
|
10
|
+
return build_annex(parsed) if parsed[:annex_marker]
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
type_letter = stringify(hash[:type_letter])&.upcase
|
|
12
|
+
type_letter = stringify(parsed[:type_letter])&.upcase
|
|
13
13
|
klass = Iala.identifier_klass_for_type_letter(type_letter)
|
|
14
14
|
|
|
15
15
|
attrs = {
|
|
16
|
-
number:
|
|
17
|
-
edition:
|
|
18
|
-
language: stringify(
|
|
16
|
+
number: build_number(parsed, klass),
|
|
17
|
+
edition: stringify(parsed[:edition_value]),
|
|
18
|
+
language: stringify(parsed[:language])&.upcase,
|
|
19
19
|
}.compact
|
|
20
20
|
|
|
21
21
|
klass.new(**attrs)
|
|
@@ -27,31 +27,34 @@ module Pubid
|
|
|
27
27
|
|
|
28
28
|
private
|
|
29
29
|
|
|
30
|
-
# Build the Annex wrapper from the
|
|
31
|
-
#
|
|
32
|
-
#
|
|
30
|
+
# Build the Annex wrapper from the bound map. The base composite in
|
|
31
|
+
# the grammar stays unbound: its captures (type_letter, doc_number,
|
|
32
|
+
# …) are the only occurrence of each name on the annex path, so the
|
|
33
|
+
# harvest hoists them to the top level and this branch reads the
|
|
34
|
+
# same flat keys as the plain identifier.
|
|
33
35
|
def build_annex(hash)
|
|
34
|
-
|
|
35
|
-
base_type = stringify(base_hash[:type_letter])&.upcase
|
|
36
|
+
base_type = stringify(hash[:type_letter])&.upcase
|
|
36
37
|
base_klass = Iala.identifier_klass_for_type_letter(base_type)
|
|
37
|
-
base = base_klass.new(number: build_number(
|
|
38
|
+
base = base_klass.new(number: build_number(hash, base_klass))
|
|
38
39
|
|
|
39
40
|
attrs = {
|
|
40
41
|
base: base,
|
|
41
|
-
annex_form:
|
|
42
|
-
letter:
|
|
43
|
-
edition:
|
|
44
|
-
language:
|
|
42
|
+
annex_form: stringify(hash[:annex_marker]),
|
|
43
|
+
letter: stringify(hash[:annex_letter]),
|
|
44
|
+
edition: stringify(hash[:edition_value]),
|
|
45
|
+
language: stringify(hash[:language])&.upcase,
|
|
45
46
|
}.compact
|
|
46
47
|
|
|
47
48
|
Identifiers::Annex.new(**attrs)
|
|
48
49
|
end
|
|
49
50
|
|
|
50
|
-
# Compose the full number string from the
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
51
|
+
# Compose the full number string from the bound map's three number
|
|
52
|
+
# columns: the leading doc_number, the dotted continuation segments
|
|
53
|
+
# (doc_number_dots, each `{dotted_segment: "01"}`) and the dash
|
|
54
|
+
# subpart runs (subpart, each `{subpart_number: "1"}`). GA01.13 →
|
|
55
|
+
# "01.13", L2.1.11 → "2.1.11", C0103-1 → "0103-1", L2.7.1-2 →
|
|
56
|
+
# "2.7.1-2". Padding is class-driven: the resolved identifier class
|
|
57
|
+
# declares its canonical IALA cover-page widths via
|
|
55
58
|
# `canonical_number_width` / `canonical_dotted_segment_width`, so a
|
|
56
59
|
# new type declares its padding on its own class instead of editing
|
|
57
60
|
# this builder's case statement.
|
|
@@ -61,34 +64,40 @@ module Pubid
|
|
|
61
64
|
|
|
62
65
|
base = canonical_base(base, klass)
|
|
63
66
|
|
|
64
|
-
dots =
|
|
65
|
-
|
|
67
|
+
dots = canonical_dots(
|
|
68
|
+
number_segments(hash[:doc_number_dots], :dotted_segment), klass
|
|
69
|
+
)
|
|
70
|
+
dots_str = dots.empty? ? "" : ".#{dots}"
|
|
71
|
+
|
|
72
|
+
subparts = number_segments(hash[:subpart], :subpart_number)
|
|
73
|
+
subpart_str = subparts.empty? ? "" : "-#{subparts.join('-')}"
|
|
66
74
|
|
|
67
|
-
subpart_str = hash[:subpart] ? "-#{subpart_to_s(hash[:subpart])}" : ""
|
|
68
|
-
subpart_str = "" if subpart_str == "-"
|
|
69
75
|
"#{base}#{dots_str}#{subpart_str}"
|
|
70
76
|
end
|
|
71
77
|
|
|
78
|
+
# The string values of a bound repetition column: doc_number_dots
|
|
79
|
+
# carries `{dotted_segment: …}` elements, subpart carries
|
|
80
|
+
# `{subpart_number: …}` elements.
|
|
81
|
+
def number_segments(column, key)
|
|
82
|
+
Array(column).filter_map do |element|
|
|
83
|
+
stringify(element[key]) if element.is_a?(Hash)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
72
87
|
def canonical_base(base, klass)
|
|
73
88
|
width = klass&.canonical_number_width
|
|
74
89
|
width ? base.rjust(width, "0") : base
|
|
75
90
|
end
|
|
76
91
|
|
|
77
|
-
|
|
78
|
-
|
|
92
|
+
# Pad each dotted continuation segment to the class's canonical
|
|
93
|
+
# width; nil width preserves the segments verbatim (the L series).
|
|
94
|
+
def canonical_dots(segments, klass)
|
|
95
|
+
return "" if segments.empty?
|
|
79
96
|
|
|
80
97
|
width = klass&.canonical_dotted_segment_width
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
str.split(".", -1).map { |seg| seg.empty? ? seg : seg.rjust(width, "0") }.join(".")
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# The subpart capture may be an array of {subpart_number: "1"},
|
|
87
|
-
# {subpart_number: "9"}, … (repeated matches). Join with "-".
|
|
88
|
-
def subpart_to_s(subpart)
|
|
89
|
-
return stringify(subpart).to_s unless subpart.is_a?(Array)
|
|
98
|
+
return segments.join(".") unless width
|
|
90
99
|
|
|
91
|
-
|
|
100
|
+
segments.map { |segment| segment.rjust(width, "0") }.join(".")
|
|
92
101
|
end
|
|
93
102
|
|
|
94
103
|
def stringify(value)
|
data/lib/pubid/ieee/CLAUDE.md
CHANGED
|
@@ -97,3 +97,5 @@ The lesson BSI paid for: a wrapper's hand-written `#number` delegation makes the
|
|
|
97
97
|
- **The project designator format, and the bare-P parse that dropped it (pubid#18).** IEEE's own draft documents are cited by their short designator — "P1201/D0.3", "P10000/D1.2" — and metanorma-ieee had to post-process the full rendering to produce it. Two edits. **(1) `format: :project`** registers `Pubid::Ieee::ProjectRenderer` in the IEEE format registry: the code (with parts), a PREPENDED "P" when the identifier is a project but the code does not already carry it, and "/D<draft>"; no publisher, no type word, no date. "Project" is detected in all three places a marker can live depending on the building path — `code_obj.prefix` (the bare spelling), the "P" type code, or a `typed_stage` with `project_status` (D1-D6). **(2) The bare-P data loss**: `ieee_p_identifier` consumed its leading "P" with a bare `str("P")`, so "P1201/D0.3" parsed WITHOUT its P anywhere (the number was "1201", prefix nil) — the issue's complaint verbatim. The rule now lets `number` capture the P (the #318 pattern), so `code.prefix` survives and the designator has something to read. The DEFAULT rendering is unchanged — the corpus keeps the P in every unapproved-draft canonical, and #318's idempotence work locked that; the issue's suggestion that the default drop the P predates the corpus and is not adopted. A published (non-project) id renders its bare number under `:project` — the format is a draft-designator view.
|
|
98
98
|
|
|
99
99
|
- **Joint stage-first forms are lead ISO, and the ISO face is lossless (pubid#477)**: a stage word printed in the ISO position (`IEEE FCD 15026.3:2010`, `IEEE/IEC CD2 P62582-2017-05`) parses as printed (#469): `Builder#build_joint_development` sets `lead_party "ISO"` whatever the publisher order, so `canonical_format` is `:iso`. PR #472 had made the lead the first printed publisher and rendered an IEEE-first form in the `D=` face — but the grammar artifact cannot parse most of those renders (`D=FCD`, `D=CD.2`, a non-P number, a month), and `D=CD-2020` parses into a *different* id (`ieee_draft`, no `iso_stage`), so 19 published `relaton-data-ieee` rows stopped matching themselves. The `D=` face now prints only for an id that carries a `D=` designator, or on an explicit `to_s(format: :ieee)`. `JointDevelopment#to_iso_format` prints `iso_stage` as stored (`DIS2`, `CD4` — #472's bare `CD` lost the iteration) and the date in the spelling that parses back (`#iso_date_suffix`: `:2018`, `-2018-05`, `, April 2019`). **The test is `parse(to_s) == id`, not a stable `to_s`**: a lossy render reaches a fixed point and round-trips its *hash* (`iso_stage_spellings_spec` held both while dropping `CD4`→`CD`), yet the reparse is a different identifier. Measured over the 118 joint stage rows of `relaton-data-ieee`: `to_s` stable 100 → 118, `==` 51 → 106; the other 12 carry an `ieee_draft` or an edition that the ISO face still drops. **PARG separator-in-capture, again (#471)**: the draft `revision` capture is `".12"`, and `Draft#to_s` adds its own dot, so `handle_draft` strips the leading dot (`D2022.10.12` rendered `D2022.10..12`). Check every capture a renderer re-joins with a separator. Locked by `spec/pubid/ieee/joint_stage_roundtrip_spec.rb` (stored index hashes → `parse(to_s) == row`).
|
|
100
|
+
|
|
101
|
+
- **IEEE parses through the edition-2 artifact's typed model bindings (the last converter wave)**: the three `Backend.parse` sites (`identifiers/base.rb` `parse_single`, `Aiee::Identifier.parse`, `Ire::Identifier.parse`) route through `Pubid::Parg::Model.bound` — the grammar's `bindings` block produces the builder-ready hash at the engine, replacing the tree normalization. The grammar side (pubid-grammar `grammars/{ieee,nesc}.parg` edition 2 + bindings, plus a class-ref inlining pass over the merged `aiee.parg`/`ire.parg`) keeps every capture the builder reads: leaf scalars bind `(string)`; the branch/wrapper captures the builder dispatches on (`draft`, `parameters`, `publishers`, `base`, `relationship_type`, `related_ids`, the aiee/ire/nesc wrappers, …) bind `(raw)` — raw subtrees carry `Parsanol::Slice` leaves whose `to_s`/`==` behave like strings, so `extract_value` and the `.is_a?(Hash)` probes are unchanged. **Load-bearing grammar deviations** (documented in the `.parg` headers): the zero-width markers are re-spelled as real-token captures (`printed_dash_year`/`printed_month_year` capture their branch's leading separator, `iec_label_dash` the dash, `iso_published` the whole joint head choice — every read sits behind a nil-`iso_stage` gate, so the wider presence set is inert exactly where v1's was narrow); the `([dash]) as draft_dash` / `([rev_sep] as rev_sep)` capture-over-OPTIONAL landmines are `[ (x) as y ]` (parsanol-ruby#394); nested-only captures that collide with a top-level name rename with the builder (`draft_date_month/year/day`, `draft_version_dash_*`, `fdraft_month/year`, and `reaffirmed_year` — unbound, because binding it would have surfaced the reaffirmation year v1 kept absent). **Class RULE refs (`digit`/`lower`/`upper`, and multi-char ones like `alnum_char`/`identifier_string`'s `non_nl`) materialize per-token named nodes inside captures when the runtime recompiles the `use`-importing source — the vendored `data/parg/{aiee,ire,nesc}.parg` copies are the live import sources, so they must be re-vendored together with the artifacts** (a stale `.parg` beside a fresh `.json` silently parses through the old grammar). **Builder accommodations** (all dual-key with the v1 spellings as fallback, so HEAD trees and bound maps read alike): the renamed nested keys; and the multi-segment draft-version normalization at the top of `build_single_identifier` — edition-2 trees materialize a "D2.1.1" designator as per-token nodes with no `:draft_version`, where v1 degraded to a whole-clause leaf (reconstructed: the leaf string → `Draft.parse` inverse) or to a date-only hash when a draft date follows (no draft built). **Measured**: fixture-corpus parity (old artifact + tree backend + builder vs new artifact + bound + builder) over all 9,054 pass-fixture inputs → 1 diff, and standalone aiee/ire/nesc sweeps → 0 diffs each. **The one residue is an engine wall, documented not chased**: `IEC/IEEE PC37.60/P62271-111_D6.1` (testsuite case `ieee.project_draft_identifier.0946`, one corpus input, one `draft_compact_convergence` example) — v1's edition-1 tree degraded its whole draft clause ("DP62271-111_D6.1, Aug 2016", two P-numbers plus date) into a single leaf, and edition-2's fully-named trees cannot reproduce that collapse at any binding spelling (isolated rule probes + programmatic branch bisect); the row is one of the two-P-number joint one-offs already recorded as deferred above. The conversion also **fixed** two testsuite rows (`ieee.project_draft_identifier.9263` "ISO/IEC/IEEE P42010.2019/D4" / "P42010.WD4:2019(E)" — the lettered `draft_iso_iteration` capture now binds, so the D= face composes), at the cost of the 0946 residue above.
|
data/lib/pubid/ieee/builder.rb
CHANGED
|
@@ -283,21 +283,90 @@ module Pubid
|
|
|
283
283
|
# Parslet can return array of hashes - merge them
|
|
284
284
|
parsed_hash = parsed.is_a?(Array) ? merge_parsed_array(parsed) : parsed
|
|
285
285
|
|
|
286
|
+
# The grammar binds the bare top-level draft clause as
|
|
287
|
+
# :draft_version_scalar (string) — the only cross-engine-stable
|
|
288
|
+
# scalar — and the composite draft rule as :draft_version_composite
|
|
289
|
+
# (raw). Surface the scalar under the top-level key the rest of the
|
|
290
|
+
# builder has always read, and the composite under the interior key
|
|
291
|
+
# the draft-element walks read.
|
|
292
|
+
if parsed_hash[:draft_version].nil? && parsed_hash[:draft_version_scalar]
|
|
293
|
+
parsed_hash[:draft_version] = parsed_hash.delete(:draft_version_scalar)
|
|
294
|
+
end
|
|
295
|
+
draft_elems = parsed_hash[:draft].is_a?(Array) ? parsed_hash[:draft] : [parsed_hash[:draft]]
|
|
296
|
+
draft_elems.each do |e|
|
|
297
|
+
next unless e.is_a?(Hash) && e[:draft_version].nil? && e[:draft_version_composite]
|
|
298
|
+
|
|
299
|
+
e[:draft_version] = e.delete(:draft_version_composite)
|
|
300
|
+
end
|
|
301
|
+
# The subpart run ("7.4-3-2" carries two) rides the raw
|
|
302
|
+
# part_subpart_year wrapper — the subparts[] array harvest emitted
|
|
303
|
+
# empty hashes for non-firing rep iterations on wasm
|
|
304
|
+
# (parsanol-ruby#399), so the run no longer binds top-level.
|
|
305
|
+
# Hoist the wrapper's part/year and deep-collect its interior
|
|
306
|
+
# subparts so every consumer — and every raw subtree built through
|
|
307
|
+
# here (the CSA dual's ieee_portion, the multi-numbered primary,
|
|
308
|
+
# the supplement bases) — sees the flat keys it has always read.
|
|
309
|
+
parsed_hash = unwrap_part_subpart_year(parsed_hash)
|
|
310
|
+
|
|
286
311
|
# Grammar-captured dash dates (relaton's "/D-3-2017-07" and
|
|
287
312
|
# "/R-x-2011-04" tails) land on the identifier's year/month exactly
|
|
288
313
|
# like the number's own date clause. The revision-suffix form sits at
|
|
289
314
|
# the top level; the draft tail nests under :draft — either as its
|
|
290
315
|
# own element or wrapped in a draft_version hash.
|
|
316
|
+
# A revision element INSIDE the draft array is the draft rule's own
|
|
317
|
+
# split artifact (the version's trailing ".1" segment) — the spine
|
|
318
|
+
# captured the same value, and leaving it there makes the REV-date
|
|
319
|
+
# embed glue the draft date into the designator. A rev_inline
|
|
320
|
+
# capture never lands inside the draft array, so this only ever
|
|
321
|
+
# drops the artifact.
|
|
322
|
+
if parsed_hash[:draft].is_a?(Array) && parsed_hash[:revision]
|
|
323
|
+
artifact = parsed_hash[:draft].any? do |e|
|
|
324
|
+
e.is_a?(Hash) && e.key?(:revision) && e[:revision].to_s == parsed_hash[:revision].to_s
|
|
325
|
+
end
|
|
326
|
+
parsed_hash.delete(:revision) if artifact
|
|
327
|
+
end
|
|
328
|
+
# The edition-2 tree materializes a multi-segment draft version
|
|
329
|
+
# ("D2.1.1") as per-token nodes carrying no :draft_version key. The
|
|
330
|
+
# v1 tree degraded such a clause to a whole-clause LEAF string when
|
|
331
|
+
# no draft date follows (the designator survives through the
|
|
332
|
+
# Draft.parse inverse), and to a date-only hash when it does (the
|
|
333
|
+
# version is dropped, no draft built). Normalize to the v1 shape
|
|
334
|
+
# here — before the stage derivation and draft handling read it —
|
|
335
|
+
# so every consumer sees what v1 saw.
|
|
336
|
+
if parsed_hash[:draft].is_a?(Array)
|
|
337
|
+
draft_elems = parsed_hash[:draft].select { |e| e.is_a?(Hash) }
|
|
338
|
+
if !draft_elems.empty? && draft_elems.none? { |e| e.key?(:draft_version) }
|
|
339
|
+
has_date = draft_elems.any? { |e| e.key?(:draft_date_month) || e.key?(:draft_date_year) }
|
|
340
|
+
if has_date
|
|
341
|
+
date_elems = draft_elems.select do |e|
|
|
342
|
+
e.key?(:draft_date_month) || e.key?(:draft_date_year) || e.key?(:draft_date_day)
|
|
343
|
+
end
|
|
344
|
+
parsed_hash[:draft] = date_elems.inject({}, :merge)
|
|
345
|
+
else
|
|
346
|
+
parsed_hash[:draft] = draft_elems.flat_map { |e| e.values }.join
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
291
350
|
drafts = parsed_hash[:draft].is_a?(Array) ? parsed_hash[:draft] : [parsed_hash[:draft]].compact
|
|
292
351
|
drafts.each do |d|
|
|
293
352
|
next unless d.is_a?(Hash) && d[:draft_version].is_a?(Hash)
|
|
294
353
|
dv = d[:draft_version]
|
|
295
|
-
|
|
296
|
-
|
|
354
|
+
# The edition-2 grammar renames the nested draft-version dash
|
|
355
|
+
# captures (draft_version_dash_year/month) to keep them out of the
|
|
356
|
+
# flat bound map; the v1 tree spelling stays as the fallback.
|
|
357
|
+
d[:dash_year] = (dv[:draft_version_dash_year] || dv[:dash_year]) if
|
|
358
|
+
dv[:draft_version_dash_year] || dv[:dash_year]
|
|
359
|
+
d[:dash_month] = (dv[:draft_version_dash_month] || dv[:dash_month]) if
|
|
360
|
+
dv[:draft_version_dash_month] || dv[:dash_month]
|
|
297
361
|
d.delete(:draft_version)
|
|
298
362
|
end
|
|
299
363
|
dash = parsed_hash[:dash_year] ? parsed_hash : nil
|
|
300
|
-
|
|
364
|
+
# The draft_date captures rename with the draft_date_ prefix on the
|
|
365
|
+
# edition-2 grammar (nested-only, kept off the identity keys); v1
|
|
366
|
+
# spelling stays as the fallback.
|
|
367
|
+
dash ||= drafts.find do |d|
|
|
368
|
+
d.is_a?(Hash) && (d[:draft_date_dash_year] || d[:dash_year])
|
|
369
|
+
end
|
|
301
370
|
if dash.nil? && parsed_hash[:draft]
|
|
302
371
|
# A numeric month trailing a draft is the draft's own date: it
|
|
303
372
|
# embeds in the designator ("D09, 04 2011" -> "D09-2011-04")
|
|
@@ -323,25 +392,32 @@ module Pubid
|
|
|
323
392
|
if dash
|
|
324
393
|
joined = drafts.filter_map { |d| d.is_a?(Hash) ? d[:draft_version] : nil }
|
|
325
394
|
.map { |v| v.is_a?(Hash) ? v[:value] : v.to_s }.join
|
|
395
|
+
# dash is either the top-level spine (dash_year/dash_month) or the
|
|
396
|
+
# draft's own hash (draft_date_dash_year/month on the edition-2
|
|
397
|
+
# grammar, dash_year/month on v1) — read both spellings.
|
|
398
|
+
dash_y = dash[:dash_year] || dash[:draft_date_dash_year]
|
|
399
|
+
dash_m = dash[:dash_month] || dash[:draft_date_dash_month]
|
|
326
400
|
bare_joint_draft = parsed_hash[:joint_publishers] &&
|
|
327
401
|
parsed_hash[:part].nil? &&
|
|
328
402
|
parsed_hash[:iso_stage].nil? &&
|
|
329
403
|
drafts.any? { |d| d.is_a?(Hash) && d[:draft_version] }
|
|
330
404
|
dash_led = joined.start_with?("-")
|
|
331
405
|
if bare_joint_draft && dash_led
|
|
332
|
-
parsed_hash[:part] = { value:
|
|
333
|
-
elsif
|
|
406
|
+
parsed_hash[:part] = { value: dash_y }
|
|
407
|
+
elsif dash_m &&
|
|
334
408
|
(host = drafts.find { |d| d.is_a?(Hash) && d[:draft_version] })
|
|
335
409
|
# The draft's own numeric date embeds in the designator
|
|
336
410
|
# ("D2-2015-04") — the "D2, 04 2015" comma spelling
|
|
337
411
|
# normalizes onto it (pubid ruling 2026-10-13).
|
|
338
412
|
host[:draft_version] =
|
|
339
|
-
[joined,
|
|
413
|
+
[joined, dash_y, dash_m].compact.join("-")
|
|
340
414
|
dash.delete(:dash_year)
|
|
341
415
|
dash.delete(:dash_month)
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
416
|
+
dash.delete(:draft_date_dash_year)
|
|
417
|
+
dash.delete(:draft_date_dash_month)
|
|
418
|
+
elsif (dash_m || dash_led) && parsed_hash[:year].nil?
|
|
419
|
+
parsed_hash[:year] = dash_y
|
|
420
|
+
parsed_hash[:month] = dash_m
|
|
345
421
|
else
|
|
346
422
|
# Year-only tail embeds in the draft face ("D9-2006"), the model
|
|
347
423
|
# the relaton-pinned renders use for corrupted update_codes
|
|
@@ -353,15 +429,17 @@ module Pubid
|
|
|
353
429
|
# ("D9-2006", and with its month "D2-2015-04") — the
|
|
354
430
|
# "D2, 04 2015" comma spelling normalizes onto it.
|
|
355
431
|
host[:draft_version] =
|
|
356
|
-
[joined,
|
|
432
|
+
[joined, dash_y, dash_m].compact.join("-")
|
|
357
433
|
dash.delete(:dash_year)
|
|
358
434
|
dash.delete(:dash_month)
|
|
435
|
+
dash.delete(:draft_date_dash_year)
|
|
436
|
+
dash.delete(:draft_date_dash_month)
|
|
359
437
|
# the hyphenated "/D-2-2007" spelling's year the spine
|
|
360
438
|
# captured at top level is the draft's own — vacate it
|
|
361
|
-
parsed_hash.delete(:year) if parsed_hash[:year] ==
|
|
439
|
+
parsed_hash.delete(:year) if parsed_hash[:year] == dash_y
|
|
362
440
|
else
|
|
363
|
-
parsed_hash[:year] =
|
|
364
|
-
parsed_hash[:month] =
|
|
441
|
+
parsed_hash[:year] = dash_y
|
|
442
|
+
parsed_hash[:month] = dash_m
|
|
365
443
|
end
|
|
366
444
|
end
|
|
367
445
|
end
|
|
@@ -456,8 +534,11 @@ module Pubid
|
|
|
456
534
|
# "IEEE P15026.2/D=FDIS-201008" (pubid#203 ruling). The
|
|
457
535
|
# stage and date ride the designator; the ISO face is not
|
|
458
536
|
# elected.
|
|
459
|
-
year
|
|
460
|
-
|
|
537
|
+
# fdraft's month/year rename with the fdraft_ prefix on the
|
|
538
|
+
# edition-2 grammar (nested-only); v1 spelling stays as the
|
|
539
|
+
# fallback.
|
|
540
|
+
year = extract_value(fd[:fdraft_year] || fd[:year])
|
|
541
|
+
month = extract_value(fd[:fdraft_month] || fd[:month])
|
|
461
542
|
date = ""
|
|
462
543
|
if year
|
|
463
544
|
date = "-#{year}"
|
|
@@ -500,6 +581,11 @@ module Pubid
|
|
|
500
581
|
def reconstruct_base_string(base_data)
|
|
501
582
|
base_parts = []
|
|
502
583
|
|
|
584
|
+
# The part/subpart/year captures sit one level down, inside the
|
|
585
|
+
# raw part_subpart_year wrapper (parsanol-ruby#399) — hoist them
|
|
586
|
+
# before the flat reads below.
|
|
587
|
+
base_data = unwrap_part_subpart_year(base_data)
|
|
588
|
+
|
|
503
589
|
# Extract publisher
|
|
504
590
|
if base_data[:publishers]
|
|
505
591
|
pub_data = base_data[:publishers]
|
|
@@ -536,11 +622,14 @@ module Pubid
|
|
|
536
622
|
number_str += part_val if part_val
|
|
537
623
|
end
|
|
538
624
|
|
|
539
|
-
# Add subpart if present
|
|
625
|
+
# Add subpart if present — the bound run arrives as subparts[]
|
|
626
|
+
# harvest hashes ({subpart: …}); v1's flat spelling stays as the
|
|
627
|
+
# fallback.
|
|
540
628
|
if base_data[:subpart]
|
|
541
629
|
subparts = base_data[:subpart]
|
|
542
630
|
subparts = [subparts] unless subparts.is_a?(Array)
|
|
543
631
|
subparts.each do |sp|
|
|
632
|
+
sp = sp[:subpart] if sp.is_a?(Hash) && sp.key?(:subpart)
|
|
544
633
|
subpart_val = extract_value(sp)
|
|
545
634
|
number_str += subpart_val if subpart_val
|
|
546
635
|
end
|
|
@@ -681,7 +770,7 @@ module Pubid
|
|
|
681
770
|
def build_interpretation_supplement(parsed_hash)
|
|
682
771
|
# Reconstruct base identifier string from parsed components (same logic as corrigendum)
|
|
683
772
|
base_parts = []
|
|
684
|
-
base_data = parsed_hash[:base]
|
|
773
|
+
base_data = unwrap_part_subpart_year(parsed_hash[:base])
|
|
685
774
|
|
|
686
775
|
# Extract publisher
|
|
687
776
|
if base_data[:publishers]
|
|
@@ -714,11 +803,14 @@ module Pubid
|
|
|
714
803
|
number_str += part_val if part_val
|
|
715
804
|
end
|
|
716
805
|
|
|
717
|
-
# Add subpart if present
|
|
806
|
+
# Add subpart if present — the bound run arrives as subparts[]
|
|
807
|
+
# harvest hashes ({subpart: …}); v1's flat spelling stays as the
|
|
808
|
+
# fallback.
|
|
718
809
|
if base_data[:subpart]
|
|
719
810
|
subparts = base_data[:subpart]
|
|
720
811
|
subparts = [subparts] unless subparts.is_a?(Array)
|
|
721
812
|
subparts.each do |sp|
|
|
813
|
+
sp = sp[:subpart] if sp.is_a?(Hash) && sp.key?(:subpart)
|
|
722
814
|
subpart_val = extract_value(sp)
|
|
723
815
|
number_str += subpart_val if subpart_val
|
|
724
816
|
end
|
|
@@ -754,7 +846,7 @@ module Pubid
|
|
|
754
846
|
def build_conformance_supplement(parsed_hash)
|
|
755
847
|
# Reconstruct base identifier string from parsed components (same logic as corrigendum)
|
|
756
848
|
base_parts = []
|
|
757
|
-
base_data = parsed_hash[:base]
|
|
849
|
+
base_data = unwrap_part_subpart_year(parsed_hash[:base])
|
|
758
850
|
|
|
759
851
|
# Extract publisher
|
|
760
852
|
if base_data[:publishers]
|
|
@@ -787,11 +879,14 @@ module Pubid
|
|
|
787
879
|
number_str += part_val if part_val
|
|
788
880
|
end
|
|
789
881
|
|
|
790
|
-
# Add subpart if present
|
|
882
|
+
# Add subpart if present — the bound run arrives as subparts[]
|
|
883
|
+
# harvest hashes ({subpart: …}); v1's flat spelling stays as the
|
|
884
|
+
# fallback.
|
|
791
885
|
if base_data[:subpart]
|
|
792
886
|
subparts = base_data[:subpart]
|
|
793
887
|
subparts = [subparts] unless subparts.is_a?(Array)
|
|
794
888
|
subparts.each do |sp|
|
|
889
|
+
sp = sp[:subpart] if sp.is_a?(Hash) && sp.key?(:subpart)
|
|
795
890
|
subpart_val = extract_value(sp)
|
|
796
891
|
number_str += subpart_val if subpart_val
|
|
797
892
|
end
|
|
@@ -1320,9 +1415,12 @@ module Pubid
|
|
|
1320
1415
|
# Extract SI type (SI or PSI)
|
|
1321
1416
|
si_type = extract_value(parsed[:si_type])
|
|
1322
1417
|
|
|
1323
|
-
# Extract publishers (should be "IEEE/ASTM")
|
|
1324
|
-
|
|
1325
|
-
|
|
1418
|
+
# Extract publishers (should be "IEEE/ASTM") — the SI rule's bare
|
|
1419
|
+
# publishers clause binds as publishers_scalar (string); the
|
|
1420
|
+
# composite joint rows deliver :publishers (raw).
|
|
1421
|
+
pub_src = parsed[:publishers] || parsed[:publishers_scalar]
|
|
1422
|
+
if pub_src
|
|
1423
|
+
publishers_str = extract_value(pub_src)
|
|
1326
1424
|
# Split by slash to get individual publishers
|
|
1327
1425
|
pubs = publishers_str.split("/")
|
|
1328
1426
|
attributes[:publisher] = pubs.first
|
|
@@ -1334,6 +1432,10 @@ module Pubid
|
|
|
1334
1432
|
|
|
1335
1433
|
# For PSI (draft), create proper Draft component object
|
|
1336
1434
|
if si_type == "PSI"
|
|
1435
|
+
# The bare top-level draft clause binds as :draft_version_scalar
|
|
1436
|
+
# (string) — surface it under the key this builder reads.
|
|
1437
|
+
parsed[:draft_version] = parsed.delete(:draft_version_scalar) if
|
|
1438
|
+
parsed[:draft_version].nil? && parsed[:draft_version_scalar]
|
|
1337
1439
|
if parsed[:draft_version]
|
|
1338
1440
|
draft_version = extract_value(parsed[:draft_version])
|
|
1339
1441
|
# Create Draft component (Lutaml::Model object)
|
|
@@ -1904,6 +2006,42 @@ module Pubid
|
|
|
1904
2006
|
str_value.length.positive? ? str_value : nil
|
|
1905
2007
|
end
|
|
1906
2008
|
|
|
2009
|
+
# Deep-first collection of every :subpart node under a raw
|
|
2010
|
+
# part_subpart_year wrapper (Hash or Array, any nesting).
|
|
2011
|
+
def collect_subparts(node, out = [])
|
|
2012
|
+
case node
|
|
2013
|
+
when Hash
|
|
2014
|
+
node.each do |key, value|
|
|
2015
|
+
if key.to_sym == :subpart
|
|
2016
|
+
out << value
|
|
2017
|
+
else
|
|
2018
|
+
collect_subparts(value, out)
|
|
2019
|
+
end
|
|
2020
|
+
end
|
|
2021
|
+
when Array then node.each { |item| collect_subparts(item, out) }
|
|
2022
|
+
end
|
|
2023
|
+
out
|
|
2024
|
+
end
|
|
2025
|
+
|
|
2026
|
+
# The part/subpart/year captures ride the raw part_subpart_year
|
|
2027
|
+
# wrapper inside raw subtrees (parsanol-ruby#399: the subparts[]
|
|
2028
|
+
# array harvest is not cross-engine stable, so the run does not
|
|
2029
|
+
# bind top-level). Hoist the wrapper's part/year onto a copy of the
|
|
2030
|
+
# subtree and surface the interior subpart run under :subpart, so
|
|
2031
|
+
# the flat reads downstream see the shapes they always read.
|
|
2032
|
+
def unwrap_part_subpart_year(node)
|
|
2033
|
+
return node unless node.is_a?(Hash) && (run = node[:part_subpart_year])
|
|
2034
|
+
|
|
2035
|
+
nodes = run.is_a?(Array) ? run : [run]
|
|
2036
|
+
flat = nodes.select { |item| item.is_a?(Hash) }.inject({}, :merge)
|
|
2037
|
+
node = node.merge(flat.slice(:part, :year))
|
|
2038
|
+
unless node[:subpart]
|
|
2039
|
+
collected = collect_subparts(run)
|
|
2040
|
+
node = node.merge(subpart: collected) unless collected.empty?
|
|
2041
|
+
end
|
|
2042
|
+
node
|
|
2043
|
+
end
|
|
2044
|
+
|
|
1907
2045
|
# Extract optional attribute if present
|
|
1908
2046
|
def extract_optional(parsed, attributes, key)
|
|
1909
2047
|
value = extract_value(parsed[key])
|
|
@@ -2004,15 +2142,20 @@ module Pubid
|
|
|
2004
2142
|
iso_stage ||= extract_value(draft_data[:draft_iso_stage]) if draft_data[:draft_iso_stage]
|
|
2005
2143
|
iso_iteration = extract_value(draft_data[:draft_iso_iteration]) if draft_data[:draft_iso_iteration]
|
|
2006
2144
|
|
|
2007
|
-
# Extract date information from draft data
|
|
2008
|
-
|
|
2145
|
+
# Extract date information from draft data. The edition-2 grammar
|
|
2146
|
+
# renames draft_date's nested month/year/day captures with the
|
|
2147
|
+
# draft_date_ prefix (kept off the identity keys); the v1
|
|
2148
|
+
# spellings stay as the fallback.
|
|
2149
|
+
month_slice = draft_data[:draft_date_month] || draft_data[:month]
|
|
2009
2150
|
month = extract_value(month_slice) if month_slice
|
|
2010
|
-
year = extract_value(draft_data[:
|
|
2151
|
+
year = extract_value(draft_data[:draft_date_year] || draft_data[:year]) if
|
|
2152
|
+
draft_data[:draft_date_year] || draft_data[:year]
|
|
2011
2153
|
# The apostrophe-year catalogue convention ("P1484.20.1/D7,
|
|
2012
2154
|
# May 07"): the 2-digit year expands with the <30 pivot so the
|
|
2013
2155
|
# hash carries an unambiguous year.
|
|
2014
2156
|
year = "#{year.to_i < 30 ? 20 : 19}#{year}" if year.to_s.match?(/\A\d{2}\z/)
|
|
2015
|
-
day = extract_value(draft_data[:
|
|
2157
|
+
day = extract_value(draft_data[:draft_date_day] || draft_data[:day]) if
|
|
2158
|
+
draft_data[:draft_date_day] || draft_data[:day]
|
|
2016
2159
|
|
|
2017
2160
|
# A REV-carrying draft embeds its print date into the designator
|
|
2018
2161
|
# with the month canonicalized numeric (pubid ruling 2026-10-14):
|
|
@@ -2103,7 +2246,11 @@ module Pubid
|
|
|
2103
2246
|
def handle_reaffirmed(parsed, attributes)
|
|
2104
2247
|
if parsed[:reaffirmed].is_a?(Hash)
|
|
2105
2248
|
reaf_data = parsed[:reaffirmed]
|
|
2106
|
-
|
|
2249
|
+
# The edition-2 grammar renames the nested year capture
|
|
2250
|
+
# (reaffirmed_year) so the reaffirmation year cannot leak onto
|
|
2251
|
+
# the identity year key; v1 spelling stays as the fallback.
|
|
2252
|
+
attributes[:reaffirmed] =
|
|
2253
|
+
extract_value(reaf_data[:reaffirmed_year] || reaf_data[:year])
|
|
2107
2254
|
elsif parsed[:reaffirmed]
|
|
2108
2255
|
attributes[:reaffirmed] = extract_value(parsed[:reaffirmed])
|
|
2109
2256
|
end
|
|
@@ -23,13 +23,21 @@ module Pubid
|
|
|
23
23
|
attribute :parts, :string, collection: true # Array of part strings
|
|
24
24
|
attribute :original_separator, :string # Original separator (. or -) from parsing
|
|
25
25
|
|
|
26
|
+
# The separator BEFORE each part, when the source mixed them
|
|
27
|
+
# ("7.4-3-2" — dot after the number, dashes before the subparts).
|
|
28
|
+
# Runtime-only (a plain accessor, not a lutaml attribute): the
|
|
29
|
+
# serialized columns keep the single original_separator, and only
|
|
30
|
+
# a fresh parse knows the per-part spelling.
|
|
31
|
+
attr_accessor :original_separators
|
|
32
|
+
|
|
26
33
|
def initialize(prefix: nil, number: nil, parts: nil,
|
|
27
|
-
original_separator: nil)
|
|
34
|
+
original_separator: nil, original_separators: nil)
|
|
28
35
|
super()
|
|
29
36
|
self.prefix = prefix
|
|
30
37
|
self.number = number
|
|
31
38
|
self.parts = parts || []
|
|
32
39
|
self.original_separator = original_separator
|
|
40
|
+
self.original_separators = original_separators
|
|
33
41
|
end
|
|
34
42
|
|
|
35
43
|
# Parse code string into components
|
|
@@ -55,15 +63,45 @@ original_separator: nil)
|
|
|
55
63
|
components = remainder.split(/[.-]/)
|
|
56
64
|
number = components.shift
|
|
57
65
|
|
|
66
|
+
# The separators AS PRINTED, one per part boundary — a code may
|
|
67
|
+
# mix them ("7.4-3-2": dot after the number, dashes before the
|
|
68
|
+
# subparts), and to_s re-joins with these instead of the uniform
|
|
69
|
+
# original_separator when they were captured.
|
|
70
|
+
separators = remainder.scan(/[.-]/)
|
|
71
|
+
per_part = separators.length == components.length ? separators : nil
|
|
72
|
+
|
|
58
73
|
new(
|
|
59
74
|
prefix: prefix,
|
|
60
75
|
number: number,
|
|
61
76
|
parts: components.empty? ? nil : components,
|
|
62
77
|
original_separator: separator,
|
|
78
|
+
original_separators: per_part,
|
|
63
79
|
)
|
|
64
80
|
end
|
|
65
81
|
|
|
82
|
+
# The printed face: keeps the separator each part was printed
|
|
83
|
+
# with ("7.4-3-2"), per Pubid::SubsetMatch. Spellings that differ
|
|
84
|
+
# only in part separators denote the same document, so faces that
|
|
85
|
+
# must be spelling-stable — the URN and the joint IEEE/ISO faces —
|
|
86
|
+
# render through #canonical_to_s instead.
|
|
66
87
|
def to_s
|
|
88
|
+
render_code(per_part: true)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The canonical face: one uniform separator across every part
|
|
92
|
+
# boundary (the first distinct one the source printed, the same
|
|
93
|
+
# face this class rendered before per-part separators were kept).
|
|
94
|
+
def canonical_to_s
|
|
95
|
+
render_code(per_part: false)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def render(context: nil)
|
|
99
|
+
to_s
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def render_code(per_part:)
|
|
67
105
|
result = ""
|
|
68
106
|
result += prefix if prefix
|
|
69
107
|
result += number if number # Guard against nil number (e.g., "ANSI X")
|
|
@@ -95,17 +133,18 @@ original_separator: nil)
|
|
|
95
133
|
separator = "."
|
|
96
134
|
end
|
|
97
135
|
|
|
98
|
-
parts.
|
|
99
|
-
|
|
136
|
+
parts.each_with_index do |part, idx|
|
|
137
|
+
# A fresh parse of mixed-separator codes ("7.4-3-2") carries
|
|
138
|
+
# the separator each part was printed with; the canonical
|
|
139
|
+
# face (and an unknown per-part spelling) falls back to the
|
|
140
|
+
# uniform original separator (the serialized shape).
|
|
141
|
+
sep = (per_part && original_separators&.at(idx)) || separator
|
|
142
|
+
result += sep + part
|
|
100
143
|
end
|
|
101
144
|
end
|
|
102
145
|
|
|
103
146
|
result
|
|
104
147
|
end
|
|
105
|
-
|
|
106
|
-
def render(context: nil)
|
|
107
|
-
to_s
|
|
108
|
-
end
|
|
109
148
|
end
|
|
110
149
|
end
|
|
111
150
|
end
|
|
@@ -403,7 +403,10 @@ module Pubid
|
|
|
403
403
|
return build_dual(result.parts) if result.dispatch == :dual_semicolon
|
|
404
404
|
end
|
|
405
405
|
parser_input = Parser.normalize_input(normalized)
|
|
406
|
-
|
|
406
|
+
# The edition-2 artifact's bindings block produces the builder-ready
|
|
407
|
+
# hash at the engine (typed scalars + raw wrappers); the tree
|
|
408
|
+
# backend's normalization it replaces read the edition-1 shapes.
|
|
409
|
+
parsed = Pubid::Parg::Model.bound(:ieee, parser_input)
|
|
407
410
|
builder = Builder.new(Identifier)
|
|
408
411
|
# Pass the original input string to builder for context
|
|
409
412
|
builder.original_input = input
|
|
@@ -164,7 +164,10 @@ module Pubid
|
|
|
164
164
|
# P11073.10418", #216); a plain ISO/IEC number keeps the
|
|
165
165
|
# printed separator ("IEC/IEEE FDIS 60079-30-2", "ISO/IEC
|
|
166
166
|
# 8802-3").
|
|
167
|
-
|
|
167
|
+
# Canonical (uniform-separator) code: a joint document parses
|
|
168
|
+
# from both the dotted IEEE face and the dashed IEC face, and
|
|
169
|
+
# both spellings must render — and URN — identically.
|
|
170
|
+
code_str = code.canonical_to_s
|
|
168
171
|
code_str = code_str.tr("-", ".") if code_str.start_with?("P")
|
|
169
172
|
code_str += mark unless code_str.empty?
|
|
170
173
|
parts << code_str if code_str && !code_str.empty?
|
|
@@ -213,8 +216,9 @@ module Pubid
|
|
|
213
216
|
parts << draft_status if draft_status
|
|
214
217
|
|
|
215
218
|
# Build code part — the P-state prints as spelled (P = project
|
|
216
|
-
# draft; no P = standard). Never added or stripped.
|
|
217
|
-
|
|
219
|
+
# draft; no P = standard). Never added or stripped. Canonical
|
|
220
|
+
# code for the same both-faces reason as the ISO face above.
|
|
221
|
+
code_str = code.canonical_to_s
|
|
218
222
|
|
|
219
223
|
# Mark after the number, before the draft and the year
|
|
220
224
|
code_str += mark unless code_str.empty?
|