@pubid/pubid 0.1.4 → 0.1.6

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.
package/README.md CHANGED
@@ -87,12 +87,28 @@ The conformance gate enforces corpus mode over the whole testsuite on
87
87
  every CI run: 42/42 flavors, canonical hash, human form, URN, alias
88
88
  normalization, negative rejection and deserialize idempotency.
89
89
 
90
- Grammar-backed parsing (novel identifiers beyond the published corpus)
91
- lands wave by wave and takes over per flavor — the same gate then
92
- enforces parity, so a wave can never regress below the floor corpus
93
- mode set (waves: core model → parser engine → builder → normalizer →
94
- renderer → urn → iso → iec → …). **No wave has shipped yet**; until one
95
- does, every flavor is corpus-only.
90
+ ## Grammar mode: open-universe runtime parsing
91
+
92
+ All 42 flavors are grammar-backed and ship the wave plan end to end
93
+ (core model → parser engine → builder → normalizer → renderer → urn →
94
+ per-flavor grammars). Grammar implementations parse real-world
95
+ identifiers far beyond the published corpus — novel spellings, unseen
96
+ numbers, live catalogues — and the conformance gate still enforces
97
+ parity with the corpus floor on every run, so a grammar can never
98
+ regress below what corpus mode set:
99
+
100
+ ```ts
101
+ import { grammarImplementation } from "@pubid/pubid";
102
+
103
+ const oiml = grammarImplementation("oiml")!;
104
+ oiml.parse("OIML R 60-1:2021"); // any published identifier
105
+ oiml.parse(oiml.parse("OIML R 117-1").toUrn()!); // URNs ingest back
106
+ ```
107
+
108
+ `grammarImplementation` is exported from the package root. URN ingestion
109
+ ships per flavor where the Ruby reference implements a URN parser
110
+ (oiml today; the remaining flavors throw a clear "not yet supported"
111
+ error naming this issue until their ports land).
96
112
 
97
113
  ## Development
98
114
 
Binary file
@@ -7,6 +7,9 @@
7
7
  export interface FlavorImplementation {
8
8
  /** Parse a canonical human form; must throw on a bad input. */
9
9
  parse(input: string): Identifier;
10
+ /** Inverse of toUrn for flavors whose reference implements a URN
11
+ * parser; when absent, "urn:" inputs throw a not-supported error. */
12
+ parseUrn?(urn: string): Identifier;
10
13
  }
11
14
  export interface Identifier {
12
15
  /** The canonical serialized form - compared byte-exactly against the corpus identifier record. */
@@ -422,6 +422,45 @@ class BipmBuilder {
422
422
  export function bipmGrammarImplementation() {
423
423
  const builder = new BipmBuilder();
424
424
  return {
425
+ // Inverse of BipmUrnGenerator (lib/pubid/bipm/urn_parser.rb): rebuild
426
+ // the identifier DIRECTLY from the URN fields — the printed forms carry
427
+ // surface detail (meeting ordinals, French wording) the URN omits.
428
+ parseUrn(urn) {
429
+ const body = urn.replace(/^urn:bipm:/, "");
430
+ // Keep trailing empty segments (gem splits with -1).
431
+ const parts = body.split(":");
432
+ const presence = (v) => v === undefined || v === "" ? undefined : v;
433
+ if (parts[0] === "metrologia") {
434
+ return new BipmMetrologiaArticle({
435
+ number: presence(parts[1]),
436
+ issue: parts[2],
437
+ article: parts[3],
438
+ });
439
+ }
440
+ if (parts[0] === "si-brochure") {
441
+ return new BipmSiBrochure({
442
+ number: presence(parts[2]),
443
+ language: (parts[1] ?? "").toUpperCase(),
444
+ edition: parts[2],
445
+ version: parts[3],
446
+ years: parts[4],
447
+ });
448
+ }
449
+ const group = (parts[0] ?? "").toUpperCase();
450
+ if (parts[1] === "meeting") {
451
+ return new BipmMeeting({
452
+ group,
453
+ number: parts[2] ?? "",
454
+ year: parts[3] ? Number(parts[3]) : undefined,
455
+ });
456
+ }
457
+ return new BipmCommitteeDocument({
458
+ group,
459
+ type_code: presence(parts[1])?.toUpperCase(),
460
+ number: presence(parts[2]),
461
+ year: parts[3] ? Number(parts[3]) : undefined,
462
+ });
463
+ },
425
464
  parse(input) {
426
465
  const normalized = UPDATE_CODES[input] ?? input;
427
466
  const tree = parseGrammar(bipmGrammar, normalized);
@@ -18,7 +18,14 @@ export declare class GbIdentifier extends BaseIdentifier {
18
18
  readonly number: string;
19
19
  readonly part: string | undefined;
20
20
  readonly date: PubidDate | undefined;
21
- readonly all_parts: boolean | undefined;
22
21
  render(): string;
23
22
  }
23
+ export declare class GbAllPartsIdentifier extends BaseIdentifier {
24
+ static polymorphicName: string;
25
+ static get attributes(): import("../../model/attribute.js").AttributeTable;
26
+ static get mappings(): never[];
27
+ readonly identifiers: GbIdentifier[];
28
+ render(): string;
29
+ toUrn(): string;
30
+ }
24
31
  export declare function gbGrammarImplementation(): FlavorImplementation;
@@ -53,9 +53,17 @@ export class GbIdentifier extends BaseIdentifier {
53
53
  mandate: { type: "string" },
54
54
  number: { type: "string" },
55
55
  part: { type: "string" },
56
- all_parts: { type: "boolean", default: false },
57
56
  });
58
- static mappings = keyValue({ wire: "publisher", to: "publisher" }, { wire: "mandate", to: "mandate" }, { wire: "number", to: "number" }, { wire: "part", to: "part" }, { wire: "date", to: "date" }, { wire: "all_parts", to: "all_parts" });
57
+ static mappings = keyValue({
58
+ wire: "publisher",
59
+ to: "publisher",
60
+ // The flat wire form is the Publisher's body scalar; rebuild the
61
+ // component on deserialize (lutaml coerces it on the Ruby side).
62
+ fromWire: (hash) => {
63
+ const value = hash["publisher"];
64
+ return typeof value === "string" ? new Publisher({ body: value }) : value;
65
+ },
66
+ }, { wire: "mandate", to: "mandate" }, { wire: "number", to: "number" }, { wire: "part", to: "part" }, { wire: "date", to: "date" });
59
67
  // GB's flat-scalar entry: the Publisher serializes as its body scalar.
60
68
  static flatScalarComponents = { publisher: "publisher" };
61
69
  static flatScalarFields = { publisher: "body" };
@@ -68,17 +76,56 @@ export class GbIdentifier extends BaseIdentifier {
68
76
  numberPortion += `.${this.part}`;
69
77
  if (this.date !== undefined && this.date.year !== undefined)
70
78
  numberPortion += `-${this.date.year}`;
71
- let result = `${code} ${numberPortion}`;
72
- if (this.all_parts)
73
- result += " (all parts)";
74
- return result;
79
+ return `${code} ${numberPortion}`;
75
80
  }
76
81
  }
77
82
  registerType(GbIdentifier);
83
+ // The document a member names: without its part and its edition (the
84
+ // attributes an all-parts reference ignores).
85
+ function gbDocumentOf(member) {
86
+ const r = member;
87
+ return new GbIdentifier({
88
+ publisher: r["publisher"],
89
+ mandate: r["mandate"],
90
+ number: r["number"],
91
+ });
92
+ }
93
+ // "GB/T 5606 (all parts)" names every part of one document: the wrapper
94
+ // holds the member identifiers (gem #433 - the mark is a class, not a
95
+ // flag), renders the document plus the suffix, and URNs as the document.
96
+ export class GbAllPartsIdentifier extends BaseIdentifier {
97
+ static polymorphicName = "pubid:gb:all-parts";
98
+ static get attributes() {
99
+ return extendAttributes(BaseIdentifier, {
100
+ identifiers: { type: GbIdentifier, collection: true },
101
+ });
102
+ }
103
+ static get mappings() {
104
+ return [{ wire: "identifiers", to: "identifiers" }];
105
+ }
106
+ render() {
107
+ const first = this.identifiers[0];
108
+ return `${first === undefined ? "" : gbDocumentOf(first).render()} (all parts)`;
109
+ }
110
+ toUrn() {
111
+ const first = this.identifiers[0];
112
+ return gbDocumentOf(first).toUrn();
113
+ }
114
+ }
115
+ registerType(GbAllPartsIdentifier);
78
116
  class GbBuilder extends BaseBuilder {
79
117
  defaultIdentifierClass() {
80
118
  return GbIdentifier;
81
119
  }
120
+ build(tree) {
121
+ const id = super.build(tree);
122
+ if (tree["all_parts"] === undefined)
123
+ return id;
124
+ // "(all parts)" wraps the document it parsed.
125
+ return new GbAllPartsIdentifier({
126
+ identifiers: [id],
127
+ });
128
+ }
82
129
  cast(key, value) {
83
130
  if (key === "publisher_code") {
84
131
  // Split an inline /T or /Z mandate out of the code.
@@ -92,7 +139,8 @@ class GbBuilder extends BaseBuilder {
92
139
  return value === undefined || value === null ? null : { date: new PubidDate({ year: String(value) }) };
93
140
  }
94
141
  if (key === "all_parts") {
95
- return { all_parts: String(value) !== "" };
142
+ // Presence marker only: the wrapper carries the members.
143
+ return value;
96
144
  }
97
145
  return value;
98
146
  }
@@ -1,4 +1,5 @@
1
1
  import { ParseFailed, parseGrammar } from "../../grammar/engine.js";
2
+ import { TYPED_STAGES } from "./model.js";
2
3
  import { Language, PubidDate } from "../../model/component.js";
3
4
  import { iecGrammar, preprocessIec } from "./grammar.js";
4
5
  import { locateStage, IEC_CLASSES, } from "./model.js";
@@ -493,6 +494,27 @@ for (const klass of [
493
494
  export function iecGrammarImplementation() {
494
495
  const builder = new IecBuilder();
495
496
  return {
497
+ // Inverse of the IEC UrnGenerator (lib/pubid/iec/urn_parser.rb):
498
+ // reassemble the positional URN fields into a text code, parse it, then
499
+ // attach languages and the all-parts wrap.
500
+ parseUrn(urn) {
501
+ const parsed = urnToCode(urn);
502
+ if (!parsed)
503
+ throw new Error(`Invalid IEC URN: ${urn}`);
504
+ const [code, lang, allParts] = parsed;
505
+ // The reference parses the unprefixed rebuild and its grammar
506
+ // defaults the IEC publisher; the ts grammar needs it explicit.
507
+ const id = this.parse(code);
508
+ if (lang && lang !== "" && "languages" in id) {
509
+ id.languages = lang
510
+ .split("-")
511
+ .map((c) => new Language({ code: c }));
512
+ }
513
+ if (allParts) {
514
+ id.all_parts = true;
515
+ }
516
+ return id;
517
+ },
496
518
  parse(input) {
497
519
  let tree;
498
520
  try {
@@ -505,3 +527,61 @@ export function iecGrammarImplementation() {
505
527
  },
506
528
  };
507
529
  }
530
+ // Port of Relaton::Iec.urn_to_code + the slot helpers
531
+ // (lib/pubid/iec/urn_parser.rb): reassemble the positional URN fields into
532
+ // a text code. Returns [code, language, allParts].
533
+ function urnToCode(urn) {
534
+ const fields = urn.toUpperCase().split(":");
535
+ if (fields.length < 5)
536
+ return undefined;
537
+ const [head, num, date, type, deliv, lang] = fields.slice(3, 9);
538
+ let allParts = false;
539
+ let code = head.replaceAll("-", "/");
540
+ const typeCode = typeSlotToCode(type);
541
+ if (typeCode !== "")
542
+ code += ` ${typeCode}`;
543
+ code += ` ${num}`;
544
+ if (date !== undefined && date !== "")
545
+ code += `:${date}`;
546
+ code += adjunctToCode(fields.slice(9));
547
+ if ((deliv ?? "").toLowerCase() === "ser") {
548
+ allParts = true;
549
+ }
550
+ else if (deliv !== undefined) {
551
+ const edition = editionSlotToCode(deliv);
552
+ if (edition)
553
+ code += ` ${edition}`;
554
+ else if (deliv !== "")
555
+ code += ` ${deliv}`;
556
+ }
557
+ return [code, lang?.toLowerCase(), allParts];
558
+ }
559
+ // The type slot holds a legacy type token ("TS"), a stage
560
+ // ("STAGE-10.20"), or both ("TS-STAGE-50.00"); a stage is written back as
561
+ // the abbreviation resolved from the typed-stage registry.
562
+ function typeSlotToCode(type) {
563
+ if (type === undefined || type === "")
564
+ return "";
565
+ const match = type.match(/^([A-Z]+-)?STAGE-([\d.]+)$/);
566
+ if (!match)
567
+ return type;
568
+ const stageType = (match[1] ?? "IS-").replace("-", "").toLowerCase();
569
+ const stage = TYPED_STAGES.find((t) => t.typeCode === stageType && (t.harmonized ?? "") === match[2]);
570
+ return stage ? stage.abbr : "";
571
+ }
572
+ // "ED-7" in the deliverable slot is an edition, not a deliverable code.
573
+ function editionSlotToCode(deliv) {
574
+ const match = deliv?.match(/^ED-(.+)$/);
575
+ return match ? `ED${match[1]}` : undefined;
576
+ }
577
+ // Adjuncts are (relation, type, number, date) quartets; a "PLUS" relation
578
+ // yields "+", otherwise "/".
579
+ function adjunctToCode(fields) {
580
+ if (!fields.length)
581
+ return "";
582
+ const [rel = "", type = "", num = "", date = ""] = fields.slice(0, 4);
583
+ let code = (rel === "" ? "/" : "+") + type + num;
584
+ if (date !== "")
585
+ code += `:${date}`;
586
+ return code + adjunctToCode(fields.slice(4));
587
+ }
@@ -42,6 +42,10 @@ function buildRules() {
42
42
  const dashMaybe = () => dash.maybe();
43
43
  const monthName = () => literalAlternation(MONTHS);
44
44
  const absent = (p) => p.absent();
45
+ // The ISO/IEC draft-stage vocabulary of the D= notation
46
+ // (docs/IEEE-DRAFT-STAGES.md §1.2). Ordered so the longest token wins
47
+ // ("FDIS" over "DIS").
48
+ const stageVocab = () => literalAlternation(["FDIS", "CDV", "PWI", "DIS", "WD", "NP", "CD"]);
45
49
  rule("year_digits", () => str("19").or(str("20")).then(digit.repeat(2, 2)).then(lower.repeat(0, 2)).then(absent(digits)));
46
50
  rule("month_numeric", () => str("0").then(match("[1-9]")).or(str("1").then(match("[0-2]"))));
47
51
  rule("date_with_month_text", () => monthName().as("month").then(space).then(R("year_digits").as("year")));
@@ -128,6 +132,16 @@ function buildRules() {
128
132
  rule("draft", () => (ref(rules, "draft_prefix")
129
133
  .then(ref(rules, "draft_version").repeat(1, 2))
130
134
  .then(dot.then(digits.as("revision")).maybe())
135
+ .then(
136
+ // The compound both-systems form (docs/IEEE-DRAFT-STAGES.md
137
+ // §1.3): "=DDIS.3" - IEEE draft ordinal = draft of the ISO/IEC
138
+ // stage, with its iteration. The glued "=DDIS3" / "=DDIS-3"
139
+ // spellings are accepted aliases of "=DDIS.3".
140
+ str("=")
141
+ .then(str("D").then(stageVocab().as("draft_iso_stage"))
142
+ .or(stageVocab().as("draft_iso_stage")))
143
+ .then((dot.or(dash)).maybe().then(digits.as("draft_iso_iteration")).maybe())
144
+ .maybe())
131
145
  .then(ref(rules, "draft_date").maybe())).as("draft"));
132
146
  rule("revision_suffix", () => slash.then(str("R")).then(dash).then(match("[0-9A-Za-z]").repeat(1, Infinity).as("revision")));
133
147
  rule("edition", () => (comma.then(R("year_digits").as("edition_year")).then(str(" Edition"))).or(space.or(dash)
@@ -230,13 +244,38 @@ function buildRules() {
230
244
  .then(match("[^\\n]").repeat(1, Infinity).as("content")));
231
245
  rule("joint_development_ieee_format", () => literalAlternation(["ISO/IEC/IEEE", "ISO/IEEE", "IEC/IEEE", "IEEE/CSA"]).as("joint_publishers")
232
246
  .then(space)
233
- .then(str("P"))
247
+ // P = project (the document is a draft): identity-bearing, so it
248
+ // is captured and preserved, never silently consumed.
249
+ .then(str("P").as("project_marker"))
234
250
  .then(digits.as("number"))
235
251
  .then((dot.or(dash)).then(digits.as("part")).maybe())
236
252
  .then(slash.then(digits).then(dot.then(digits).maybe())
237
253
  .then(dash.then(digits.as("draft_version")).maybe()).maybe())
238
- .then(slash.then(str("D")).then(digits.as("draft_version"))
239
- .or(comma.then(literalAlternation(["CDV", "FDIS", "CD", "DIS"]).as("iec_stage")).then(digits.maybe().as("stage_iteration"))).maybe())
254
+ .then(
255
+ // Variant 1b: the ordinal-less stage draft "D=CDV[:2020]" -
256
+ // D (draft) = CDV (the IEC stage it drafts). The year rides in
257
+ // the draft clause (a distinct key, so the builder keeps the
258
+ // date inside the designator).
259
+ slash.then(str("D")).then(str("="))
260
+ .then(stageVocab().as("draft_iso_stage"))
261
+ .then(str(":").then(R("year_digits").as("draft_stage_year")).maybe())
262
+ .or(
263
+ // Variant 1: /D8 notation (original), with the compound
264
+ // both-systems suffix "=DDIS.3" (docs/IEEE-DRAFT-STAGES.md
265
+ // §1.3). The doubled-D spelling is consumed and echoed: the
266
+ // joint form prints the stage as spelled.
267
+ slash.then(str("D")).then(digits.as("draft_version"))
268
+ .then(str("=")
269
+ .then(str("D").as("draft_stage_d").then(stageVocab().as("draft_iso_stage"))
270
+ .or(stageVocab().as("draft_iso_stage")))
271
+ .then((dot.or(dash)).maybe().then(digits.as("draft_iso_iteration")).maybe())
272
+ .maybe()))
273
+ .or(
274
+ // Variant 2: , CDV1 notation (comma before stage code) —
275
+ // the stage draft of the named ISO/IEC stage, its iteration
276
+ comma.then(literalAlternation(["CDV", "FDIS", "CD", "DIS"]).as("iec_stage"))
277
+ .then(digits.maybe().as("stage_iteration")))
278
+ .maybe())
240
279
  .then(ref(rules, "edition").maybe())
241
280
  .then((dash.then(R("year_digits").as("year"))).or(comma.maybe().then(space).then(monthName().as("month")).then(spaceMaybe())
242
281
  .then(R("year_digits").as("year"))).maybe())
@@ -270,14 +309,27 @@ function buildRules() {
270
309
  .then(isoStage().then(stdNoise()).then(space)
271
310
  .or(str("").as("iso_published").then(spaceMaybe())))
272
311
  .or(stagedOnly.as("joint_publishers").then(space).then(isoStage()).then(stdNoise()).then(space)))
273
- .then(str("P").maybe())
312
+ .then(str("P").as("project_marker").maybe())
274
313
  .then(digits.as("number"))
275
314
  .then((dot.then(absent(R("year_digits"))).then(digits.as("part")))
276
315
  .or(dash.then(str("").as("part_dash")).then(absent(R("year_digits")))
277
316
  .then(digits.as("part")))
278
317
  .maybe())
279
318
  .then(dateClause())
280
- .then((slash.then(str("D")).then(dashMaybe())
319
+ .then(
320
+ // The ISO/IEC edition marker may sit between the year and an
321
+ // amendment/draft tail ("8802.11:2012 (E)/Amd 1-2014").
322
+ (spaceMaybe().then(str("("))
323
+ .then(match("[^)]").repeat(1, Infinity).as("edition_marker"))
324
+ .then(str(")"))).maybe())
325
+ .then(
326
+ // The stage-draft clause of the ISO-led print: "/D=WD.5" - D
327
+ // (draft) = the ISO/IEC stage it drafts, with its iteration
328
+ // (docs/IEEE-DRAFT-STAGES.md §1.2).
329
+ (slash.then(str("D")).then(str("="))
330
+ .then(stageVocab().as("draft_iso_stage"))
331
+ .then(dot.then(digits.then(match("[A-Za-z]").repeat(0, 1)).as("draft_iso_iteration")).maybe()))
332
+ .or(slash.then(str("D")).then(dashMaybe())
281
333
  .then(match("[0-9.]").repeat(1, Infinity).as("draft_version"))
282
334
  .then(((comma.or(space)).then(monthName().as("draft_month")).then(space)
283
335
  .then(R("year_digits").as("draft_year"))).or(comma.then(R("year_digits").as("draft_year"))).maybe())).maybe())
@@ -288,12 +340,30 @@ function buildRules() {
288
340
  .then(ref(rules, "revision_suffix").maybe())
289
341
  .then(ref(rules, "parenthetical").maybe());
290
342
  });
343
+ // The stage-less ISO/IEC label with a colon year and the ISO/IEC
344
+ // edition marker: "ISO/IEC 13210:1994 (E)" - the ISO/IEC portion of a
345
+ // double-labeled standard. The (E) parenthetical is REQUIRED: without it
346
+ // this spelling is the ISO flavor's own form and must not be stolen.
347
+ rule("joint_development_iso_iec_edition", () => str("ISO/IEC").as("joint_publishers")
348
+ .then(space)
349
+ .then(str("").as("iso_published"))
350
+ .then(str("P").as("project_marker").maybe())
351
+ .then(digits.as("number"))
352
+ // The part may be dot- or dash-joined ("8802-9"); a dash-year is
353
+ // not a part. A distinct key: the iso-format route's part_dash
354
+ // prints as a dot in the joint code; only the label keeps its dash.
355
+ .then((dot.then(digits.as("part")))
356
+ .or(dash.then(str("").as("iec_label_dash")).then(absent(R("year_digits"))).then(digits.as("part")))
357
+ .maybe())
358
+ .then(str(":").then(spaceMaybe()).then(R("year_digits").as("year")))
359
+ .then(spaceMaybe())
360
+ .then(ref(rules, "parenthetical")));
291
361
  rule("joint_development_embedded_stage", () => literalAlternation([
292
362
  "ISO/IEC/IEEE", "IEEE/ISO/IEC", "IEEE/IEC/ISO", "ISO/IEEE",
293
363
  "IEC/IEEE", "IEEE/IEC", "ISO/IEC", "IEEE",
294
364
  ]).as("joint_publishers")
295
365
  .then(space)
296
- .then(str("P").maybe())
366
+ .then(str("P").as("project_marker").maybe())
297
367
  .then(digits.as("number"))
298
368
  .then((dot.or(dash)).then(absent(R("year_digits"))).then(digits.as("part")).maybe())
299
369
  .then(dot)
@@ -577,6 +647,7 @@ function buildRules() {
577
647
  .or(ref(rules, "conformance_identifier"))
578
648
  .or(ref(rules, "joint_development_ieee_format"))
579
649
  .or(ref(rules, "joint_development_iso_format"))
650
+ .or(ref(rules, "joint_development_iso_iec_edition"))
580
651
  .or(ref(rules, "joint_development_embedded_stage"))
581
652
  .or(ref(rules, "iec_ieee_copublished"))
582
653
  .or(ref(rules, "number_first_identifier"))
@@ -121,6 +121,10 @@ class IeeeBuilder {
121
121
  attributes["parts"] = codeObj.parts;
122
122
  if (codeObj?.originalSeparator !== undefined)
123
123
  attributes["separator"] = codeObj.originalSeparator;
124
+ // P = project (the document is a draft): the marker is
125
+ // identity-bearing, so stripping the letter must not strip the
126
+ // fact (docs/IEEE-DRAFT-STAGES.md §1.1).
127
+ attributes["project_marker"] = true;
124
128
  }
125
129
  }
126
130
  delete attributes["__code"];
@@ -298,11 +302,23 @@ class IeeeBuilder {
298
302
  return attributes;
299
303
  }
300
304
  determineStageAbbr(typeValue, parsed) {
305
+ // An explicit joint stage on the draft ("=DFDIS.3") IS the stage —
306
+ // it outranks the ordinal ladder, which classifies IEEE-internal
307
+ // drafts only (docs/IEEE-DRAFT-STAGES.md §1.3).
301
308
  if (parsed["draft"] !== undefined) {
302
309
  let draftData = parsed["draft"];
303
310
  if (Array.isArray(draftData)) {
304
311
  draftData = mergeParsedArray(draftData);
305
312
  }
313
+ if (isObj(draftData) && draftData["draft_iso_stage"] !== undefined) {
314
+ const stage = extractValue(draftData["draft_iso_stage"]) ?? "";
315
+ // The doubled-D alias ("DFDIS") carries its own draft marker;
316
+ // the stage is what remains.
317
+ const rest = stage.slice(1);
318
+ return stage.startsWith("D") && ["PWI", "NP", "WD", "CD", "CDV", "DIS", "FDIS"].includes(rest)
319
+ ? rest
320
+ : stage;
321
+ }
306
322
  if (isObj(draftData) && draftData["draft_version"] !== undefined) {
307
323
  const dv = draftData["draft_version"];
308
324
  const version = Array.isArray(dv) ? dv.map((v) => extractValue(v) ?? "").join("") : extractValue(dv);
@@ -343,6 +359,8 @@ class IeeeBuilder {
343
359
  }
344
360
  let version;
345
361
  let revision;
362
+ let isoStage;
363
+ let isoIteration;
346
364
  let month;
347
365
  let year;
348
366
  let day;
@@ -355,6 +373,8 @@ class IeeeBuilder {
355
373
  version = version.replace(/^-/, "");
356
374
  }
357
375
  revision = extractValue(draftData["revision"]);
376
+ isoStage = extractValue(draftData["draft_iso_stage"]);
377
+ isoIteration = extractValue(draftData["draft_iso_iteration"]);
358
378
  month = extractValue(draftData["month"]);
359
379
  year = extractValue(draftData["year"]);
360
380
  day = extractValue(draftData["day"]);
@@ -370,10 +390,12 @@ class IeeeBuilder {
370
390
  else {
371
391
  version = extractValue(draftData);
372
392
  }
373
- if (version !== undefined) {
393
+ if (version !== undefined || (isObj(draftData) && draftData["draft_iso_stage"] !== undefined)) {
374
394
  const draftObj = new IeeeDraft({
375
395
  version,
376
396
  revision,
397
+ iso_stage: isoStage,
398
+ iso_iteration: isoIteration,
377
399
  month,
378
400
  year,
379
401
  day,
@@ -601,7 +623,14 @@ class IeeeBuilder {
601
623
  codeParts.push(extractValue(parsed["part"]) ?? "");
602
624
  let codeStr = extractValue(parsed["number"]);
603
625
  if (codeStr !== undefined && codeParts.length > 0) {
604
- codeStr += `.${codeParts.join(".")}`;
626
+ // The ISO/IEC label's part may be dash-joined ("8802-9") - keep the
627
+ // printed separator.
628
+ const sep = parsed["iec_label_dash"] !== undefined ? "-" : ".";
629
+ codeStr += `${sep}${codeParts.join(sep)}`;
630
+ }
631
+ // P = project (a draft): identity-bearing, preserved as spelled.
632
+ if (parsed["project_marker"] !== undefined && codeStr !== undefined) {
633
+ codeStr = `P${codeStr}`;
605
634
  }
606
635
  if (codeStr !== undefined) {
607
636
  const codeObj = IeeeCode.parse(codeStr);
@@ -639,6 +668,37 @@ class IeeeBuilder {
639
668
  if (draftVer !== "")
640
669
  attributes["ieee_draft"] = `D${draftVer}`;
641
670
  }
671
+ // The "(E)" edition marker of an ISO/IEC label rides on the joint
672
+ // reference as parenthetical_content - either from the trailing
673
+ // parenthetical slot (the double-label form) or the mid-rule slot
674
+ // before an amendment tail.
675
+ const parenthetical = parsed["parameters"];
676
+ if (parenthetical !== undefined && typeof parenthetical === "object" && !Array.isArray(parenthetical)) {
677
+ const content = parenthetical["parenthetical_content"];
678
+ if (content !== undefined)
679
+ attributes["parenthetical_content"] = extractValue(content);
680
+ }
681
+ if (attributes["parenthetical_content"] === undefined && parsed["edition_marker"] !== undefined) {
682
+ attributes["parenthetical_content"] = extractValue(parsed["edition_marker"]);
683
+ }
684
+ // The joint stage-draft clause (docs/IEEE-DRAFT-STAGES.md §1.3):
685
+ // variant 1's compound tail composes onto the IEEE ordinal
686
+ // ("D5=DIS.3"); variant 1b is the ordinal-less stage draft whose
687
+ // date rides inside the designator ("D=CDV:2020"). The doubled-D
688
+ // alias spelling echoes as spelled.
689
+ if (parsed["draft_iso_stage"] !== undefined) {
690
+ const stageText = `${extractValue(parsed["draft_stage_d"]) ?? ""}${extractValue(parsed["draft_iso_stage"]) ?? ""}`;
691
+ let jointDraft = parsed["draft_version"] !== undefined
692
+ ? `D${(extractValue(parsed["draft_version"]) ?? "").replace(/^D/, "")}=${stageText}`
693
+ : `D=${stageText}`;
694
+ if (parsed["draft_iso_iteration"] !== undefined) {
695
+ jointDraft += `.${extractValue(parsed["draft_iso_iteration"])}`;
696
+ }
697
+ if (parsed["draft_stage_year"] !== undefined) {
698
+ jointDraft += `:${extractValue(parsed["draft_stage_year"])}`;
699
+ }
700
+ attributes["ieee_draft"] = jointDraft;
701
+ }
642
702
  // The catalogue-PRINTED joint form (dash-year / ", Month YYYY" /
643
703
  // date-trailing-the-draft / date-less parenthetical): a Standard
644
704
  // carrying publisher/copublisher renders it as printed; the colon-year
@@ -654,8 +714,11 @@ class IeeeBuilder {
654
714
  typeof parsed["parameters"] === "object" && !Array.isArray(parsed["parameters"]) &&
655
715
  parsed["parameters"]["parenthetical_content"] !== undefined))) {
656
716
  const sep = parsed["part_dash"] !== undefined ? "-" : ".";
657
- const printedCode = [extractValue(parsed["number"]), extractValue(parsed["part"])]
717
+ let printedCode = [extractValue(parsed["number"]), extractValue(parsed["part"])]
658
718
  .filter((x) => x !== undefined).join(sep);
719
+ // P = project: the marker is identity, preserved as spelled.
720
+ if (parsed["project_marker"] !== undefined)
721
+ printedCode = `P${printedCode}`;
659
722
  const printedAttrs = {
660
723
  publisher: attributes["publisher"],
661
724
  copublisher: attributes["copublisher"],
@@ -1022,7 +1085,16 @@ function copublishedStructured(copublishedNumber) {
1022
1085
  }
1023
1086
  // --- parse orchestration -------------------------------------------------------
1024
1087
  function ieeeParseSingle(input) {
1025
- const cleaned = preprocessIeee(applyUpdateCodes(input));
1088
+ const normalized = applyUpdateCodes(input);
1089
+ // UpdateCodes may introduce the "; " double-label separator (the
1090
+ // ISO/IEC and IEEE labels of one document), but the dispatch ran on
1091
+ // the raw input - re-dispatch so the dual construction sees it.
1092
+ if (normalized !== input && normalized.includes("; ")) {
1093
+ const re = preParse(normalized);
1094
+ if (re.dispatch === "dual_semicolon")
1095
+ return buildDual(re.parts);
1096
+ }
1097
+ const cleaned = preprocessIeee(normalized);
1026
1098
  const tree = parseGrammar(ieeeGrammar, cleaned);
1027
1099
  const builder = new IeeeBuilder();
1028
1100
  builder.originalInput = input;
@@ -43,6 +43,8 @@ export declare class IeeeCode {
43
43
  export declare class IeeeDraft {
44
44
  readonly version: string;
45
45
  readonly revision: string | undefined;
46
+ readonly isoStage: string | undefined;
47
+ readonly isoIteration: string | undefined;
46
48
  readonly year: string | undefined;
47
49
  readonly month: string | undefined;
48
50
  readonly day: string | undefined;