@graphty/graph-io 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +17 -4
  2. package/dist/chunks/{importer-BELrICWM.js → importer-BOxwmef_.js} +352 -19
  3. package/dist/chunks/importer-BOxwmef_.js.map +1 -0
  4. package/dist/graph-io.js +2 -2
  5. package/dist/graph-io.js.map +1 -1
  6. package/dist/json.js +1 -1
  7. package/dist/src/formats/json/dialect.d.ts +21 -4
  8. package/dist/src/formats/json/dialect.d.ts.map +1 -1
  9. package/dist/src/formats/json/dialect.js +29 -4
  10. package/dist/src/formats/json/dialect.js.map +1 -1
  11. package/dist/src/formats/json/exporter.d.ts.map +1 -1
  12. package/dist/src/formats/json/exporter.js +27 -7
  13. package/dist/src/formats/json/exporter.js.map +1 -1
  14. package/dist/src/formats/json/importer.d.ts +17 -4
  15. package/dist/src/formats/json/importer.d.ts.map +1 -1
  16. package/dist/src/formats/json/importer.js +356 -8
  17. package/dist/src/formats/json/importer.js.map +1 -1
  18. package/dist/src/formats/json/index.d.ts +1 -1
  19. package/dist/src/formats/json/index.d.ts.map +1 -1
  20. package/dist/src/formats/json/index.js +1 -1
  21. package/dist/src/formats/json/index.js.map +1 -1
  22. package/dist/src/index.d.ts +1 -1
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js.map +1 -1
  25. package/dist/src/sniff.d.ts +3 -3
  26. package/dist/src/sniff.d.ts.map +1 -1
  27. package/dist/src/sniff.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/common/escape.ts +5 -4
  30. package/src/formats/json/dialect.ts +40 -6
  31. package/src/formats/json/exporter.ts +28 -7
  32. package/src/formats/json/importer.ts +411 -21
  33. package/src/formats/json/index.ts +7 -1
  34. package/src/formats/neo4j/exporter.ts +43 -6
  35. package/src/formats/neo4j/importer.ts +56 -11
  36. package/src/formats/neo4j/index.ts +10 -3
  37. package/src/formats/pajek/exporter.ts +3 -1
  38. package/src/formats/pajek/importer.ts +284 -60
  39. package/src/formats/pajek/syntax.ts +98 -12
  40. package/src/index.ts +2 -0
  41. package/src/sniff.ts +3 -3
  42. package/dist/chunks/importer-BELrICWM.js.map +0 -1
@@ -11,7 +11,9 @@
11
11
  * mixed-direction policy; the third column of a line is the weight unless `weightFrom` says
12
12
  * otherwise; `*Arcslist` / `*Edgeslist` adjacency lists and `*Matrix` rows are read too; time
13
13
  * interval tokens `[1-5,7-*]` become the spells role. A second network in one file is refused
14
- * with an issue; project-file sections (`*Partition`, `*Vector`, ...) are reported as unsupported.
14
+ * with an issue. A project file's `*Partition` and `*Vector` objects become the node columns
15
+ * `partition` (i32) and `vector` (f64), wherever they stand in the file; other project-file
16
+ * sections (`*Events`, `*Permutation`, ...) are skipped with a warning.
15
17
  */
16
18
 
17
19
  import {
@@ -25,7 +27,7 @@ import {
25
27
  type U32,
26
28
  } from "@graphty/graph-format";
27
29
 
28
- import { declareResolved, RENAMED_CODE, ROLE_TAKEN_CODE } from "../../common/attributes.js";
30
+ import { declareResolved, RENAMED_CODE, ROLE_TAKEN_CODE, uniqueColumnName } from "../../common/attributes.js";
29
31
  import {
30
32
  DUPLICATE_NODE_CODE,
31
33
  ENCODING_FALLBACK_CODE,
@@ -52,23 +54,26 @@ import { isNumericText, TextCellWriter, WIDENING_UNSUPPORTED_CODE } from "../../
52
54
  import { isWeightField, parseWeightText } from "../../common/weights.js";
53
55
  import { type CommonImportOptions, type GraphImporter, type ImportInput, type ImportReport } from "../../types.js";
54
56
  import {
57
+ decodeCharacterReferences,
55
58
  isCommentOrBlank,
56
59
  isIntervalToken,
57
60
  isSectionLine,
61
+ isShapeKeyword,
58
62
  isVertexNumber,
59
63
  LABEL_COLUMN,
60
64
  ORIGINAL_ID_KEY,
61
65
  parseIntervals,
62
66
  parseSectionHeader,
67
+ PARTITION_COLUMN,
63
68
  POSITION_COLUMN,
64
69
  RELATION_COLUMN,
65
70
  type SectionHeader,
66
71
  SHAPE_COLUMN,
67
- SHAPES,
68
72
  SPELLS_COLUMN,
69
73
  tokenize,
70
74
  VALUE_COLUMN,
71
75
  VALUE_FIELD,
76
+ VECTOR_COLUMN,
72
77
  } from "./syntax.js";
73
78
 
74
79
  /** The format-specific options of the Pajek importer. */
@@ -92,7 +97,7 @@ export const PAJEK_ISSUE = Object.freeze({
92
97
  UNKNOWN_ENCODING: UNKNOWN_ENCODING_CODE,
93
98
  /** Fatal: no `*Vertices` section (an empty file, or not a Pajek network). */
94
99
  NO_VERTICES: "E_PAJEK_NO_VERTICES",
95
- /** Fatal: `*Vertices` without a vertex count, or one the sink cannot hold. */
100
+ /** Fatal: `*Vertices` without a vertex count, one the sink cannot hold, or a first-mode count outside 0..N. */
96
101
  VERTICES_COUNT: "E_PAJEK_VERTICES_COUNT",
97
102
  /** A project file holds several networks; import() reads the first, importAll() reads every one. */
98
103
  MULTIPLE_GRAPHS: MULTIPLE_GRAPHS_CODE,
@@ -106,20 +111,24 @@ export const PAJEK_ISSUE = Object.freeze({
106
111
  VERTEX_LINE: "E_PAJEK_VERTEX_LINE",
107
112
  /** A vertex number outside the declared range. */
108
113
  VERTEX_RANGE: "E_PAJEK_VERTEX_RANGE",
109
- /** Fewer vertex lines than `*Vertices` declares (vertices without a line have no label). */
110
- VERTEX_COUNT: "E_PAJEK_VERTEX_COUNT",
114
+ /** Fewer vertex lines than `*Vertices` declares, which the Pajek manual allows (vertices without a line have no label). */
115
+ VERTEX_COUNT: "W_PAJEK_VERTEX_COUNT",
111
116
  /** A second line for the same vertex; the later values overwrite. */
112
117
  DUPLICATE_NODE: DUPLICATE_NODE_CODE,
113
- /** A line (arc, edge, list or matrix row) the grammar does not accept. */
118
+ /** A line (arc, edge, list, matrix row, partition or vector value) the grammar does not accept. */
114
119
  LINE: "E_PAJEK_LINE",
115
120
  /** A line endpoint outside the declared vertex range (the core's code, forwarded). */
116
121
  UNKNOWN_NODE: "E_UNKNOWN_NODE",
117
122
  /** A malformed time interval token. */
118
123
  INTERVAL: "E_PAJEK_INTERVAL",
119
- /** A `*Matrix` section with the wrong number of rows. */
124
+ /** A `*Matrix` section with the wrong number of rows (N, or N1 in a two-mode network). */
120
125
  MATRIX_ROWS: "E_PAJEK_MATRIX_ROWS",
121
- /** A project-file section (`*Partition`, `*Vector`, ...) the importer does not read. */
122
- UNSUPPORTED_SECTION: "E_PAJEK_UNSUPPORTED_SECTION",
126
+ /** `*Matrix` rows longer than the column count; the extra values are ignored. */
127
+ MATRIX_EXTRA: "W_PAJEK_MATRIX_EXTRA",
128
+ /** A `*Partition` or `*Vector` whose value count differs from the network's vertex count; values beyond it are dropped, vertices without one are unset. */
129
+ OBJECT_COUNT: "E_PAJEK_OBJECT_COUNT",
130
+ /** A project-file section (`*Events`, `*Permutation`, ...) the importer does not read; its lines are skipped. */
131
+ UNSUPPORTED_SECTION: "W_PAJEK_UNSUPPORTED_SECTION",
123
132
  /** Tokens after a section header the grammar does not account for. */
124
133
  HEADER_EXTRA: "W_PAJEK_HEADER_EXTRA",
125
134
  /** The file declares vertices but no line section. */
@@ -258,7 +267,38 @@ interface RowExtras {
258
267
  }
259
268
 
260
269
  /** The section the parser is in. */
261
- type Section = "none" | "vertices" | "lines" | "list" | "matrix" | "skip";
270
+ type Section = "none" | "vertices" | "lines" | "list" | "matrix" | "object" | "skip";
271
+
272
+ /**
273
+ * A `*Partition` or `*Vector` object of a project file, buffered until the end of the network:
274
+ * one may come before the network whose vertices it describes.
275
+ */
276
+ interface ProjectObject {
277
+ readonly kind: "partition" | "vector";
278
+ /** The object's name from its header. */
279
+ readonly name: string;
280
+ /** The header's line number. */
281
+ readonly line: number;
282
+ /** The object's own `*Vertices N`, or null without one. */
283
+ count: number | null;
284
+ /** The value per vertex position; null for a line that could not be read. */
285
+ readonly values: (number | null)[];
286
+ }
287
+
288
+ /**
289
+ * Whether a number is an integer the i32 partition column can hold.
290
+ * @param value - the number
291
+ * @returns true for an i32 integer
292
+ */
293
+ function isI32(value: number): boolean {
294
+ return Number.isInteger(value) && value >= -2147483648 && value <= 2147483647;
295
+ }
296
+
297
+ /** A vertex number of an adjacency list: Pajek reads a negative one as its absolute value. */
298
+ const LIST_VERTEX = /^-?[0-9]+$/;
299
+
300
+ /** The relation prefix `k:` of a line in a line section. */
301
+ const RELATION_PREFIX = /^([0-9]+):$/;
262
302
 
263
303
  /**
264
304
  * One import call's state machine: the sections, the vertex range, the columns declared so far
@@ -303,6 +343,21 @@ class PajekParser {
303
343
 
304
344
  private sawLineSection = false;
305
345
 
346
+ /** The direction of the first line section, for a network whose line sections are all empty. */
347
+ private firstKind: EdgeKind = "directed";
348
+
349
+ /** Set once the first line has fixed the sink's direction. */
350
+ private directionSet = false;
351
+
352
+ /** The names of the numbered relations (`*Arcs :k "name"`), for the `k:` line prefix. */
353
+ private readonly relationNames = new Map<number, string>();
354
+
355
+ /** The `*Partition` / `*Vector` objects read so far, written by finish(). */
356
+ private readonly objects: ProjectObject[] = [];
357
+
358
+ /** Set when the previous line was a project-object header, whose `*Vertices N` line comes next. */
359
+ private objectHeader = false;
360
+
306
361
  /** Node index per vertex position (vertex number minus base). */
307
362
  private indexOfPos: U32 = new Uint32Array(0);
308
363
 
@@ -380,9 +435,11 @@ class PajekParser {
380
435
  if (isCommentOrBlank(text)) {
381
436
  return false;
382
437
  }
438
+ const afterObjectHeader = this.objectHeader;
439
+ this.objectHeader = false;
383
440
  try {
384
441
  if (isSectionLine(text)) {
385
- this.header(text, line);
442
+ this.header(text, line, afterObjectHeader);
386
443
  return this.ended;
387
444
  }
388
445
  switch (this.section) {
@@ -404,6 +461,9 @@ class PajekParser {
404
461
  case "matrix":
405
462
  this.matrixLine(text, line);
406
463
  return false;
464
+ case "object":
465
+ this.objectLine(text);
466
+ return false;
407
467
  case "skip":
408
468
  return false;
409
469
  default: {
@@ -438,7 +498,11 @@ class PajekParser {
438
498
  "the file declares vertices but no *Arcs, *Edges, *Arcslist, *Edgeslist or *Matrix section",
439
499
  );
440
500
  this.resolver.setHeader(this.options.defaultDirected);
501
+ } else if (!this.directionSet) {
502
+ // every line section was empty: the first header still says which way the network goes
503
+ this.resolver.setHeader(this.firstKind === "directed");
441
504
  }
505
+ const objects = this.writeObjects();
442
506
  if (this.coercer.mergeCount > 0) {
443
507
  this.report.warning(
444
508
  "coercion",
@@ -446,23 +510,75 @@ class PajekParser {
446
510
  `${this.coercer.mergeCount} label(s) merged into an id another label already produced under ids "number"`,
447
511
  );
448
512
  }
513
+ const pajek = {
514
+ ...(this.firstMode === null ? {} : { firstMode: this.firstMode }),
515
+ ...(objects.length === 0 ? {} : { objects }),
516
+ };
449
517
  const meta: GraphMetaPatch = {
450
518
  sourceFormat: "pajek",
451
519
  ...(this.networkName === null ? {} : { name: this.networkName }),
452
- ...(this.firstMode === null ? {} : { extra: { pajek: { firstMode: this.firstMode } } }),
520
+ ...(Object.keys(pajek).length === 0 ? {} : { extra: { pajek } }),
453
521
  };
454
522
  this.sink.setMeta(meta);
455
523
  }
456
524
 
525
+ /**
526
+ * Write every `*Partition` / `*Vector` object into its node column: `partition` (i32) or
527
+ * `vector` (f64), a later object of the same kind in `partition#2`, `vector#2`, ... The value
528
+ * of vertex k is the object's k-th value.
529
+ * @returns the objects' Pajek names and the columns they went to, for meta.extra.pajek.objects
530
+ */
531
+ private writeObjects(): { kind: string; name: string; column: string }[] {
532
+ const used = new Set<string>();
533
+ const written: { kind: string; name: string; column: string }[] = [];
534
+ for (const object of this.objects) {
535
+ const where = { line: object.line };
536
+ const partition = object.kind === "partition";
537
+ const name = uniqueColumnName(partition ? PARTITION_COLUMN : VECTOR_COLUMN, null, (n) => used.has(n));
538
+ used.add(name);
539
+ const { handle, decl } = declareResolved(
540
+ this.sink,
541
+ "node",
542
+ {
543
+ name,
544
+ dtype: partition ? "i32" : "f64",
545
+ nullable: true,
546
+ origin: { format: "pajek", id: object.kind },
547
+ },
548
+ this.report,
549
+ where,
550
+ );
551
+ const n = Math.min(object.values.length, this.vertexCount);
552
+ for (let pos = 0; pos < n; pos++) {
553
+ const value = object.values[pos];
554
+ if (value !== null) {
555
+ this.sink.setNodeValue(handle, this.indexOfPos[pos], value);
556
+ }
557
+ }
558
+ const declared = object.count ?? object.values.length;
559
+ if (declared !== this.vertexCount || object.values.length !== declared) {
560
+ this.report.error(
561
+ "validation-error",
562
+ PAJEK_ISSUE.OBJECT_COUNT,
563
+ `*${partition ? "Partition" : "Vector"} "${object.name}" declares ${declared} vertices and holds ${object.values.length} value(s); the network has ${this.vertexCount}`,
564
+ where,
565
+ );
566
+ }
567
+ written.push({ kind: object.kind, name: object.name, column: decl.name });
568
+ }
569
+ return written;
570
+ }
571
+
457
572
  // ============================================================ headers
458
573
 
459
574
  /**
460
575
  * A section header line.
461
576
  * @param text - the line
462
577
  * @param line - the line number
578
+ * @param afterObjectHeader - the previous line was a `*Partition`, `*Vector` or skipped section header
463
579
  */
464
- private header(text: string, line: number): void {
465
- const skipping = this.section === "skip";
580
+ private header(text: string, line: number, afterObjectHeader: boolean): void {
581
+ const previous = this.section;
466
582
  this.endSection();
467
583
  const h = parseSectionHeader(text);
468
584
  if (h === null) {
@@ -487,9 +603,13 @@ class PajekParser {
487
603
  this.section = "none";
488
604
  return;
489
605
  case "vertices":
490
- if (skipping && this.vertexCount >= 0) {
491
- // the `*Vertices N` line of a skipped project-file section (*Partition, *Vector)
492
- this.section = "skip";
606
+ if (afterObjectHeader) {
607
+ // the `*Vertices N` line of a project object (*Partition, *Vector, a skipped
608
+ // section), wherever it stands: never the network's own
609
+ this.section = previous;
610
+ if (previous === "object") {
611
+ this.objects[this.objects.length - 1].count = h.count;
612
+ }
493
613
  return;
494
614
  }
495
615
  if (this.vertexCount >= 0) {
@@ -511,9 +631,16 @@ class PajekParser {
511
631
  this.lineSection(h, line, "matrix", "directed");
512
632
  this.matrixRows = 0;
513
633
  return;
634
+ case "partition":
635
+ case "vector":
636
+ this.objects.push({ kind: h.kind, name: h.name ?? "", line, count: null, values: [] });
637
+ this.section = "object";
638
+ this.objectHeader = true;
639
+ return;
514
640
  case "unsupported":
515
641
  this.section = "skip";
516
- this.report.error(
642
+ this.objectHeader = true;
643
+ this.report.warning(
517
644
  "unsupported",
518
645
  PAJEK_ISSUE.UNSUPPORTED_SECTION,
519
646
  `the *${h.keyword} section is not supported; its lines are skipped`,
@@ -539,6 +666,13 @@ class PajekParser {
539
666
  if (!Number.isSafeInteger(h.count)) {
540
667
  this.report.fail(PAJEK_ISSUE.VERTICES_COUNT, `*Vertices ${h.count}: not a count`, { line });
541
668
  }
669
+ if (h.secondCount !== null && (h.secondCount < 0 || h.secondCount > h.count)) {
670
+ this.report.fail(
671
+ PAJEK_ISSUE.VERTICES_COUNT,
672
+ `*Vertices ${h.count} ${h.secondCount}: the first-mode count is not within 0..${h.count}`,
673
+ { line },
674
+ );
675
+ }
542
676
  try {
543
677
  // the declared count must be reservable (the sink is the authority on what it can hold,
544
678
  // E_TOO_LARGE beyond MAX_COUNT) before any vertex is materialised
@@ -570,8 +704,9 @@ class PajekParser {
570
704
  }
571
705
 
572
706
  /**
573
- * A line section header: the vertex set is completed, the first one sets the sink's direction
574
- * (rule 1 of design section 8.4), later ones only switch the kind.
707
+ * A line section header: the vertex set is completed and the kind switched. The sink's
708
+ * direction (rule 1 of design section 8.4) is set by the first LINE, not the first header, so
709
+ * an empty `*Arcs` before the `*Edges` of an undirected network does not make it directed.
575
710
  * @param h - the header
576
711
  * @param line - the line number
577
712
  * @param section - the parser state for the section
@@ -588,22 +723,26 @@ class PajekParser {
588
723
  this.finishVertices();
589
724
  if (!this.sawLineSection) {
590
725
  this.sawLineSection = true;
591
- this.resolver.setHeader(kind === "directed", { line });
726
+ this.firstKind = kind;
592
727
  }
593
728
  this.section = section;
594
729
  this.kind = kind;
595
730
  this.relation = h.relation === null ? null : (h.name ?? String(h.relation));
731
+ if (h.relation !== null) {
732
+ this.relationNames.set(h.relation, this.relation ?? String(h.relation));
733
+ }
596
734
  }
597
735
 
598
736
  /**
599
737
  * Checks that run when a section ends.
600
738
  */
601
739
  private endSection(): void {
602
- if (this.section === "matrix" && this.matrixRows !== this.vertexCount) {
740
+ const rows = this.firstMode ?? this.vertexCount;
741
+ if (this.section === "matrix" && this.matrixRows !== rows) {
603
742
  this.report.error(
604
743
  "validation-error",
605
744
  PAJEK_ISSUE.MATRIX_ROWS,
606
- `*Matrix has ${this.matrixRows} row(s); *Vertices declares ${this.vertexCount}`,
745
+ `*Matrix has ${this.matrixRows} row(s); *Vertices declares ${rows}`,
607
746
  );
608
747
  }
609
748
  this.section = "none";
@@ -707,7 +846,7 @@ class PajekParser {
707
846
  // a partial list: fewer lines than declared, counting the lines skipped after an error
708
847
  const lines = this.seenCount + this.report.counts.skippedNodes;
709
848
  if (this.seenCount > 0 && lines < n) {
710
- this.report.error(
849
+ this.report.warning(
711
850
  "validation-error",
712
851
  PAJEK_ISSUE.VERTEX_COUNT,
713
852
  `*Vertices declares ${n} vertices but ${lines} vertex line(s) were read; the others have no label`,
@@ -745,8 +884,9 @@ class PajekParser {
745
884
  }
746
885
  // parse everything before writing anything
747
886
  let i = 1;
748
- const label = tokens.length > 1 ? tokens[1] : null;
749
- if (label !== null) {
887
+ let label: string | null = null;
888
+ if (tokens.length > 1 && !startsWithCoordinates(text, tokens)) {
889
+ label = decodeCharacterReferences(tokens[1]);
750
890
  i = 2;
751
891
  }
752
892
  let x = 0;
@@ -772,8 +912,8 @@ class PajekParser {
772
912
  );
773
913
  }
774
914
  let shape: string | null = null;
775
- if (i < tokens.length && SHAPES.has(tokens[i])) {
776
- shape = tokens[i];
915
+ if (i < tokens.length && isShapeKeyword(tokens[i])) {
916
+ shape = tokens[i].toLowerCase();
777
917
  i++;
778
918
  }
779
919
  const extras = this.extras(tokens, i, "node");
@@ -954,7 +1094,15 @@ class PajekParser {
954
1094
  * @param line - the line number
955
1095
  */
956
1096
  private edgeLine(text: string, line: number): void {
957
- const tokens = this.tokens(text, "edge");
1097
+ let tokens = this.tokens(text, "edge");
1098
+ // a `k:` prefix puts this one line in relation k
1099
+ let { relation } = this;
1100
+ const prefix = RELATION_PREFIX.exec(tokens[0]);
1101
+ if (prefix !== null) {
1102
+ const k = Number(prefix[1]);
1103
+ relation = this.relationNames.get(k) ?? String(k);
1104
+ tokens = tokens.slice(1);
1105
+ }
958
1106
  if (tokens.length < 2 || !isVertexNumber(tokens[0]) || !isVertexNumber(tokens[1])) {
959
1107
  this.report.counts.skippedEdges++;
960
1108
  throw new LineError(
@@ -993,18 +1141,19 @@ class PajekParser {
993
1141
  // inferred like every parameter, so a re-import of the exporter's `value` parameter agrees
994
1142
  this.writeParameter("edge", VALUE_COLUMN, e, valueText);
995
1143
  }
996
- this.writeEdgeExtras(e, extras);
1144
+ this.writeEdgeExtras(e, extras, relation);
997
1145
  }
998
1146
 
999
1147
  /**
1000
- * An adjacency list line of `*Arcslist` / `*Edgeslist`: `v n1 n2 ...`.
1148
+ * An adjacency list line of `*Arcslist` / `*Edgeslist`: `v n1 n2 ...`; a negative vertex
1149
+ * number is read as its absolute value, as Pajek does.
1001
1150
  * @param text - the line
1002
1151
  * @param line - the line number
1003
1152
  */
1004
1153
  private listLine(text: string, line: number): void {
1005
1154
  const tokens = this.tokens(text, "edge");
1006
1155
  for (const token of tokens) {
1007
- if (!isVertexNumber(token)) {
1156
+ if (!LIST_VERTEX.test(token)) {
1008
1157
  this.report.counts.skippedEdges++;
1009
1158
  throw new LineError(
1010
1159
  "parse-error",
@@ -1013,16 +1162,18 @@ class PajekParser {
1013
1162
  );
1014
1163
  }
1015
1164
  }
1016
- const u = this.endpoint(Number(tokens[0]));
1165
+ const u = this.endpoint(Math.abs(Number(tokens[0])));
1017
1166
  for (let i = 1; i < tokens.length; i++) {
1018
- const v = this.endpoint(Number(tokens[i]));
1167
+ const v = this.endpoint(Math.abs(Number(tokens[i])));
1019
1168
  const e = this.pushEdge(u, v, undefined, line);
1020
1169
  this.writeRelation(e);
1021
1170
  }
1022
1171
  }
1023
1172
 
1024
1173
  /**
1025
- * A `*Matrix` row: N values, every non-zero entry an arc from the row's vertex with that value.
1174
+ * A `*Matrix` row, every non-zero entry an arc from the row's vertex with that value: N values
1175
+ * in a one-mode network; in a two-mode network (`*Vertices N N1`) the matrix is N1 rows of
1176
+ * N - N1 values, entry j of row i an arc from vertex i to vertex N1 + j.
1026
1177
  * @param text - the line
1027
1178
  * @param line - the line number
1028
1179
  */
@@ -1030,39 +1181,74 @@ class PajekParser {
1030
1181
  const tokens = this.tokens(text, "edge");
1031
1182
  const row = this.matrixRows;
1032
1183
  this.matrixRows++;
1033
- if (row >= this.vertexCount) {
1184
+ const offset = this.firstMode ?? 0;
1185
+ const rows = this.firstMode ?? this.vertexCount;
1186
+ const columns = this.vertexCount - offset;
1187
+ if (row >= rows) {
1034
1188
  throw new LineError(
1035
1189
  "validation-error",
1036
1190
  PAJEK_ISSUE.MATRIX_ROWS,
1037
- `*Matrix row ${row + 1} is beyond the ${this.vertexCount} declared vertices`,
1191
+ `*Matrix row ${row + 1} is beyond the ${rows} declared row(s)`,
1038
1192
  );
1039
1193
  }
1040
- if (tokens.length !== this.vertexCount) {
1194
+ if (tokens.length < columns) {
1041
1195
  throw new LineError(
1042
1196
  "parse-error",
1043
1197
  PAJEK_ISSUE.LINE,
1044
- `*Matrix row ${row + 1} has ${tokens.length} value(s); *Vertices declares ${this.vertexCount}`,
1198
+ `*Matrix row ${row + 1} has ${tokens.length} value(s); the matrix has ${columns} column(s)`,
1045
1199
  );
1046
1200
  }
1047
- for (const token of tokens) {
1048
- if (!isNumericText(token)) {
1201
+ for (let j = 0; j < columns; j++) {
1202
+ if (!isNumericText(tokens[j])) {
1049
1203
  throw new LineError(
1050
1204
  "parse-error",
1051
1205
  PAJEK_ISSUE.LINE,
1052
- `*Matrix row ${row + 1}: "${token}" is not a number`,
1206
+ `*Matrix row ${row + 1}: "${tokens[j]}" is not a number`,
1053
1207
  );
1054
1208
  }
1055
1209
  }
1056
- for (let j = 0; j < tokens.length; j++) {
1210
+ if (tokens.length > columns) {
1211
+ this.report.warnOnce(
1212
+ "validation-error",
1213
+ PAJEK_ISSUE.MATRIX_EXTRA,
1214
+ `*Matrix row ${row + 1} has ${tokens.length} value(s) for ${columns} column(s); the extra values are ignored`,
1215
+ { line },
1216
+ );
1217
+ }
1218
+ for (let j = 0; j < columns; j++) {
1057
1219
  const value = Number(tokens[j]);
1058
1220
  if (value === 0) {
1059
1221
  continue;
1060
1222
  }
1061
- const e = this.pushEdge(row, j, value, line);
1223
+ const e = this.pushEdge(row, offset + j, value, line);
1062
1224
  this.writeRelation(e);
1063
1225
  }
1064
1226
  }
1065
1227
 
1228
+ /**
1229
+ * A value line of a `*Partition` (integers) or `*Vector` (numbers): the values of the next
1230
+ * vertices, usually one per line.
1231
+ * @param text - the line
1232
+ */
1233
+ private objectLine(text: string): void {
1234
+ const object = this.objects[this.objects.length - 1];
1235
+ const tokens = this.tokens(text, "node");
1236
+ for (const token of tokens) {
1237
+ if (!isNumericText(token) || (object.kind === "partition" && !isI32(Number(token)))) {
1238
+ // keep the later values on their vertices
1239
+ object.values.push(null);
1240
+ throw new LineError(
1241
+ "parse-error",
1242
+ PAJEK_ISSUE.LINE,
1243
+ `*${object.kind === "partition" ? "Partition" : "Vector"} "${object.name}": "${token}" is not ${object.kind === "partition" ? "an integer" : "a number"}`,
1244
+ );
1245
+ }
1246
+ }
1247
+ for (const token of tokens) {
1248
+ object.values.push(Number(token));
1249
+ }
1250
+ }
1251
+
1066
1252
  /**
1067
1253
  * Push one line through the direction resolver, counting the logical edges it produced.
1068
1254
  * @param u - the source position
@@ -1073,6 +1259,10 @@ class PajekParser {
1073
1259
  */
1074
1260
  private pushEdge(u: number, v: number, weight: number | undefined, line: number): number {
1075
1261
  const { sink } = this;
1262
+ if (!this.directionSet) {
1263
+ this.directionSet = true;
1264
+ this.resolver.setHeader(this.kind === "directed", { line });
1265
+ }
1076
1266
  const before = sink.edgeCount;
1077
1267
  const e = this.resolver.addEdge(this.idAt(u), this.idAt(v), this.kind, weight, { line });
1078
1268
  this.report.counts.edges += sink.edgeCount - before;
@@ -1083,10 +1273,11 @@ class PajekParser {
1083
1273
  * Write the relation, parameters and spells of a line on its primary half.
1084
1274
  * @param e - the edge index
1085
1275
  * @param extras - the parsed extras
1276
+ * @param relation - the line's relation name, or null
1086
1277
  */
1087
- private writeEdgeExtras(e: number, extras: RowExtras): void {
1278
+ private writeEdgeExtras(e: number, extras: RowExtras, relation: string | null): void {
1088
1279
  const { sink } = this;
1089
- this.writeRelation(e);
1280
+ this.writeRelation(e, relation);
1090
1281
  for (let p = 0; p < extras.keys.length; p++) {
1091
1282
  this.writeParameter("edge", extras.keys[p], e, extras.values[p]);
1092
1283
  }
@@ -1099,17 +1290,18 @@ class PajekParser {
1099
1290
  }
1100
1291
 
1101
1292
  /**
1102
- * Write the current section's relation name on an edge, when the section has one.
1293
+ * Write a relation name on an edge: the current section's, unless a line prefix named another.
1103
1294
  * @param e - the edge index
1295
+ * @param relation - the relation name, or null for none
1104
1296
  */
1105
- private writeRelation(e: number): void {
1106
- if (this.relation === null) {
1297
+ private writeRelation(e: number, relation: string | null = this.relation): void {
1298
+ if (relation === null) {
1107
1299
  return;
1108
1300
  }
1109
1301
  if (this.relationHandle === INVALID_INDEX) {
1110
1302
  this.relationHandle = declareResolved(this.sink, "edge", RELATION_DECL, this.report).handle;
1111
1303
  }
1112
- this.sink.setEdgeValue(this.relationHandle, e, this.relation);
1304
+ this.sink.setEdgeValue(this.relationHandle, e, relation);
1113
1305
  }
1114
1306
 
1115
1307
  /**
@@ -1181,7 +1373,9 @@ class PajekParser {
1181
1373
  const token = tokens[i];
1182
1374
  if (isIntervalToken(token)) {
1183
1375
  try {
1184
- extras.spells = parseIntervals(token);
1376
+ const spells = parseIntervals(token);
1377
+ // an empty `[]` is no spell at all
1378
+ extras.spells = spells.length === 0 ? null : spells;
1185
1379
  } catch (err) {
1186
1380
  this.skip(domain);
1187
1381
  throw new LineError(
@@ -1245,6 +1439,26 @@ class PajekParser {
1245
1439
  }
1246
1440
  }
1247
1441
 
1442
+ /**
1443
+ * Whether a vertex line has coordinates but no label: the token after the vertex number is bare
1444
+ * (not quoted) and starts a run of exactly two numbers, which cannot be a label and one
1445
+ * coordinate. A run of three stays a numeric label and x y (the NetworkX writer's `1 0 0.5 0.5`),
1446
+ * the one reading of it that keeps real files' labels.
1447
+ * @param text - the line
1448
+ * @param tokens - its tokens
1449
+ * @returns true when tokens[1] is the first coordinate
1450
+ */
1451
+ function startsWithCoordinates(text: string, tokens: readonly string[]): boolean {
1452
+ if (text.trimStart().slice(tokens[0].length).trimStart().startsWith('"')) {
1453
+ return false;
1454
+ }
1455
+ let run = 0;
1456
+ while (1 + run < tokens.length && isNumericText(tokens[1 + run])) {
1457
+ run++;
1458
+ }
1459
+ return run === 2;
1460
+ }
1461
+
1248
1462
  /**
1249
1463
  * Resolve the firstVertex option.
1250
1464
  * @param value - the caller's value
@@ -1340,7 +1554,8 @@ export const pajekImporter: GraphImporter<PajekImportOptions> = Object.freeze({
1340
1554
 
1341
1555
  /**
1342
1556
  * Read every network of a Pajek project file (`.paj`), each into its own sink. A network starts
1343
- * at `*Network` or at a `*Vertices` that does not belong to a `*Partition` / `*Vector` section.
1557
+ * at `*Network` or at a `*Vertices` that does not belong to a `*Partition` / `*Vector` section
1558
+ * (the `*Vertices N` line right after an object's header).
1344
1559
  * @param input - the text, bytes or stream
1345
1560
  * @param sinkFor - the sink of the network with this index, called before its first push
1346
1561
  * @param options - common and Pajek options
@@ -1386,7 +1601,8 @@ export const pajekImporter: GraphImporter<PajekImportOptions> = Object.freeze({
1386
1601
  /**
1387
1602
  * Counts the networks of the rest of a project file by their headers alone, with the parser's
1388
1603
  * rule: a network starts at `*Network` after a named or populated one, or at a `*Vertices` after a
1389
- * populated one unless it belongs to a skipped section (`*Partition`, `*Vector`, ...).
1604
+ * populated one unless it is the line right after a project object's header (`*Partition`,
1605
+ * `*Vector`, a skipped section).
1390
1606
  */
1391
1607
  class NetworkCounter {
1392
1608
  /** Networks started so far (the first header fed opens the first). */
@@ -1396,27 +1612,35 @@ class NetworkCounter {
1396
1612
 
1397
1613
  private populated = false;
1398
1614
 
1399
- private skipping = false;
1615
+ private afterObjectHeader = false;
1400
1616
 
1401
1617
  /**
1402
1618
  * Feed one line.
1403
1619
  * @param text - the line
1404
1620
  */
1405
1621
  line(text: string): void {
1622
+ if (isCommentOrBlank(text)) {
1623
+ return;
1624
+ }
1625
+ const { afterObjectHeader } = this;
1626
+ this.afterObjectHeader = false;
1406
1627
  if (!isSectionLine(text)) {
1407
1628
  return;
1408
1629
  }
1409
1630
  const h = parseSectionHeader(text);
1410
- const kind = h === null ? "unsupported" : h.kind;
1411
- const { skipping } = this;
1412
- this.skipping = kind === "unsupported" || (kind === "vertices" && skipping && this.populated);
1413
- if (kind === "network") {
1631
+ if (h === null) {
1632
+ return;
1633
+ }
1634
+ const { kind } = h;
1635
+ if (kind === "partition" || kind === "vector" || kind === "unsupported") {
1636
+ this.afterObjectHeader = true;
1637
+ } else if (kind === "network") {
1414
1638
  if (this.count === 0 || this.named || this.populated) {
1415
1639
  this.count++;
1416
1640
  this.populated = false;
1417
1641
  }
1418
1642
  this.named = true;
1419
- } else if (kind === "vertices" && !this.skipping) {
1643
+ } else if (kind === "vertices" && !afterObjectHeader) {
1420
1644
  if (this.count === 0 || this.populated) {
1421
1645
  this.count++;
1422
1646
  this.named = false;