@fishka/seqio 0.4.0 → 0.5.0

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
@@ -1,13 +1,16 @@
1
1
  # @fishka/seqio
2
2
 
3
- Sequencing I/Oparsers and writers for bioinformatics file formats.
3
+ Part of [fishka.bio](https://fishka.bio) free browser-based bioinformatics tools.
4
4
 
5
- Currently supports:
5
+ Browser- and Node-compatible sequencing file I/O.
6
6
 
7
- - **ABIF** (`.ab1` / `.abi`) — chromatogram traces produced by ABI Sanger / fragment analysis instruments. Meaning-lossless round-trip (every entry preserved), browser- and Node-compatible.
8
- - **FASTA / FASTQ / .qual** — text writers (Phred+33), pure and format-agnostic.
7
+ Current API:
9
8
 
10
- Planned: SCF; FASTA/FASTQ reading.
9
+ - **ABIF** (`.ab1` / `.abi`) parser, typed chromatogram view, raw reader/writer,
10
+ and mutation helpers.
11
+ - **FASTA / FASTQ / .qual** text writers with Phred+33 quality encoding.
12
+
13
+ Planned: SCF and FASTA/FASTQ readers.
11
14
 
12
15
  ## Install
13
16
 
@@ -15,71 +18,79 @@ Planned: SCF; FASTA/FASTQ reading.
15
18
  npm install @fishka/seqio
16
19
  ```
17
20
 
18
- ## Use
21
+ ## ABIF
19
22
 
20
- ### Quick parse (typed view + metadata)
23
+ Use `parseAbif()` when you want a ready-to-render chromatogram, base calls, and
24
+ metadata from an `.ab1` file.
21
25
 
22
26
  ```ts
23
27
  import { parseAbif } from '@fishka/seqio/abif';
24
- // or: import { parseAbif } from '@fishka/seqio';
25
28
 
26
29
  const result = parseAbif(uint8ArrayOrArrayBuffer, 'sample.ab1');
27
30
 
28
- result.baseCalls?.sequence; // "ACGT..." (preferred/called version, upper-cased)
29
- result.baseCalls?.confidences; // [40, 38, 41, ...]
30
- result.baseCalls?.positions; // [13, 25, 38, ...] sample-point peaks
31
- result.baseCallVariants; // every PBAS version present: [{ version, role: 'called'|'edited', ... }]
32
- result.chromatogram.data9To12.A; // A-channel int16 trace of the DATA9..12 block
33
- result.chromatogram.data1To4.A; // A-channel int16 trace of the DATA1..4 block
34
- result.metadata.sampleName; // SMPL tag
35
- result.metadata.samplingRate; // SPAC tag (falls back to PLOC-derived spacing)
31
+ result.baseCalls?.sequence; // preferred called sequence, upper-cased
32
+ result.baseCalls?.confidences; // PCON quality scores
33
+ result.baseCalls?.positions; // PLOC peak positions
34
+ result.baseCallVariants; // all PBAS/PCON/PLOC versions found
35
+ result.chromatogram.data9To12.A; // A trace mapped through FWO_
36
+ result.metadata.sampleName;
36
37
  ```
37
38
 
38
- ### Low-level (entry-by-entry, round-trip)
39
+ Use `readAbif()` / `writeAbif()` when you need entry-level access or want to
40
+ preserve unknown vendor tags during a round trip.
39
41
 
40
42
  ```ts
41
43
  import { readAbif, writeAbif, findEntry, upsertEntry } from '@fishka/seqio/abif';
42
- import { setSequence, setConfidences, setPositions, setAveragePeakSpacing } from '@fishka/seqio/abif';
44
+ import { setAveragePeakSpacing, setConfidences, setPositions, setSequence } from '@fishka/seqio/abif';
43
45
 
44
46
  const file = readAbif(bytes);
47
+
48
+ findEntry(file, 'SMPL', 1);
49
+ const commentPayload = new TextEncoder().encode('basecalled');
50
+ upsertEntry(file, 'CMNT', 1, commentPayload, {
51
+ elementType: 2,
52
+ elementSize: 1,
53
+ elementCount: commentPayload.byteLength,
54
+ });
55
+
45
56
  setSequence(file, 'ACGT...');
46
- setConfidences(file, [40, 38, 41, ...]);
47
- setPositions(file, [13, 25, 38, ...]);
57
+ setConfidences(file, [40, 38, 41]);
58
+ setPositions(file, [13, 25, 38]);
48
59
  setAveragePeakSpacing(file, 12.5, 'my-basecaller');
60
+
49
61
  const out = writeAbif(file);
50
62
  ```
51
63
 
52
- ### Text export (FASTA / FASTQ / .qual)
64
+ ## FASTA / FASTQ / .qual
53
65
 
54
66
  ```ts
55
67
  import { formatFasta, formatFastq, formatQual, hasUsableQuality } from '@fishka/seqio';
56
68
 
57
69
  const record = { id: 'sample.ab1', sequence, qualities };
58
70
 
59
- formatFasta(record); // ">sample.ab1\nACGT...\n" (wrapped at 60)
71
+ formatFasta(record); // wrapped at 60 residues by default
60
72
  formatFasta(record, { lineWidth: 0 }); // single sequence line
61
73
 
62
- // Phred+33; scores clamped to [0, 93]. Only emit quality when it exists —
63
- // missing/all-255 PCON should stay FASTA-only rather than invent perfect Q.
64
74
  if (hasUsableQuality(qualities)) {
65
- formatFastq(record); // "@sample.ab1\nACGT...\n+\n..."
66
- formatQual(record); // ">sample.ab1\n40 38 41 ...\n"
75
+ formatFastq(record); // Phred+33, scores clamped to [0, 93]
76
+ formatQual(record);
67
77
  }
68
78
  ```
69
79
 
70
- ## Features
71
-
72
- - Browser- and Node-compatible (Uint8Array + DataView, no Node Buffer dependency).
73
- - Meaning-lossless round-trip: every directory entry preserved as a raw payload, so
74
- `readAbif(writeAbif(f))` reproduces the same entries. The output is not byte-for-byte
75
- identical to the input (payloads are repacked, `dataSize` normalized, MacBinary/padding
76
- dropped); a byte-exact layout-preserving writer is a possible future opt-in.
77
- - MacBinary preamble support.
78
- - BioPython-compatible declared-vs-computed dataSize clamp.
79
- - PLOC read/written as unsigned int16 (preserves traces > 32k scans).
80
- - SPAC accepts both float32 (spec) and long (legacy) element types.
81
- - PCON/PLOC version fallback when PBAS2 ships without matching PCON2/PLOC2.
82
- - `ensureRawDataChannels()` helper for older DATA1..8-only files.
80
+ ## API
81
+
82
+ - `parseAbif(input, fileName?)`
83
+ - `readAbif(bytes)`, `writeAbif(file)`, `findEntry()`, `findEntries()`,
84
+ `upsertEntry()`
85
+ - `getSequence()`, `getConfidences()`, `getPositions()`, `getDataChannel()`,
86
+ `getChannelMap()`, `getSamplingRate()`
87
+ - `setSequence()`, `setConfidences()`, `setPositions()`,
88
+ `setAveragePeakSpacing()`, `ensureRawDataChannels()`
89
+ - `formatFasta()`, `formatFastq()`, `formatQual()`, `hasUsableQuality()`
90
+
91
+ The library uses `Uint8Array` and `DataView`, with no Node `Buffer` dependency.
92
+ ABIF writing is meaning-lossless rather than byte-for-byte layout preserving:
93
+ unknown entries are kept, but payloads may be repacked and padding normalized.
83
94
 
84
95
  ## License
85
96
 
@@ -1,3 +1,5 @@
1
+ declare function tagNameFromInt32(n: number): string;
2
+
1
3
  /**
2
4
  * Low-level ABIF file model: a flat list of directory entries with raw
3
5
  * payloads. Lossless round-trip is preserved: every entry (including unknown
@@ -60,7 +62,10 @@ interface AbifEntry {
60
62
  /**
61
63
  * The directory fields exactly as they were on disk — present only when this entry was read from a
62
64
  * file (absent for entries synthesized by setters/writeAbif). Lets consumers inspect the real record
63
- * without our reconciliation.
65
+ * without our reconciliation, and lets writeAbif() reuse this entry's original directory slot/offset
66
+ * verbatim on an unmodified round-trip. Mutate entries only through upsertEntry() — it clears this
67
+ * field, since a hand-edited entry (payload/elementType/elementSize/elementCount set directly) can no
68
+ * longer be reproduced from its stale on-disk shape.
64
69
  */
65
70
  raw?: AbifEntryRaw;
66
71
  }
@@ -292,14 +297,13 @@ type AbifDecodedValue = {
292
297
  value: Uint8Array;
293
298
  };
294
299
 
295
- declare function tagNameFromInt32(n: number): string;
296
-
297
300
  /**
298
- * Low-level ABIF reader/writer.
299
- *
300
- * Round-trip without loss: every directory entry (including unknown vendor
301
- * tags) is preserved as a raw payload (Uint8Array). The high-level layer
302
- * (view.ts, parser.ts) provides typed access on top of this.
301
+ * Raw layer: the ABIF byte<->struct codec. Every directory entry (including unknown
302
+ * vendor tags) is preserved as a raw payload (Uint8Array), and writeAbif() reproduces
303
+ * the original bytes exactly for an unmodified round-trip (see its own doc comment).
304
+ * The domain layer (view.ts, setters.ts, parser.ts, abif-op-*.ts) provides typed,
305
+ * format-aware access on top of this — this file knows nothing about what any tag
306
+ * means.
303
307
  *
304
308
  * ABIF file layout:
305
309
  * [0..3] "ABIF" magic
@@ -350,14 +354,35 @@ declare function readAbif(bytes: Uint8Array): AbifFile;
350
354
  /**
351
355
  * Serialize an AbifFile back to a Uint8Array.
352
356
  *
353
- * Layout produced: header (128 B) + directory (N*28 B) + payload block.
354
- * External payloads are packed tightly in entry order. Payloads <= 4 bytes are
355
- * stored inline inside their directory entry.
356
- *
357
- * Meaning-lossless, not byte-exact: the payload bytes, tag fields and dataSize
358
- * (= payload length) round-trip, but physical layout does not — payloads are
359
- * repacked, directory/header padding and any MacBinary preamble are dropped. A
360
- * byte-exact layout-preserving mode is a possible future opt-in.
357
+ * Byte-exact on an unmodified round-trip for well-formed records and the tolerated
358
+ * count/dataSize desyncs readAbif() accepts: readAbif() followed right back by
359
+ * writeAbif(), with nothing touched in between, reproduces the original bytes
360
+ * exactly (a MacBinary-wrapped input round-trips its ABIF payload byte-exact, but
361
+ * the preamble itself is never re-added — see the AGENTS.md note on why). The one
362
+ * exception is a genuinely invalid negative `dataSize`/`numElements` field (readAbif
363
+ * tolerates these defensively, falling back to a computed size, but never treats them
364
+ * as verbatim-eligible on write see the byteLength check below) — such an entry
365
+ * round-trips meaning-lossless (correct payload/content) but its declared dataSize
366
+ * field gets normalized to the real payload length rather than reproducing the
367
+ * original garbage value.
368
+ *
369
+ * This works by keeping every entry whose payload size hasn't changed since it was
370
+ * read (`entry.raw` present — upsertEntry() clears it on any mutation — and
371
+ * `payload.byteLength === raw.dataSize`) at its exact original directory slot and
372
+ * file offset, writing the *original* on-disk numElements/dataSize/offset-or-inline-
373
+ * padding instead of the reconciled/current ones. A new or resized entry is appended
374
+ * past the highest byte offset still known to hold preserved content (every verbatim
375
+ * entry's span, the directory, and any unreferenced range); only its own directory
376
+ * record changes, so every other entry's bytes stay untouched. A resized/removed
377
+ * entry's *old* span is not itself reserved — nothing points at it anymore, so it is
378
+ * free to be reused by whatever gets appended next; the output is not padded out to
379
+ * the original file's true end.
380
+ *
381
+ * The directory is kept at its original file offset as long as the entry count
382
+ * didn't grow (removing entries just shortens it in place; the original directory
383
+ * padding is only reused when the count is exactly unchanged, since a shorter
384
+ * directory can't reuse padding sized for more entries). Only a growing entry count
385
+ * relocates the whole directory to freshly appended space.
361
386
  */
362
387
  declare function writeAbif(file: AbifFile): Uint8Array;
363
388
  /** Find an entry by name+number, or undefined. */
@@ -367,6 +392,11 @@ declare function findEntries(file: AbifFile, name: string): AbifEntry[];
367
392
  /**
368
393
  * Replace the payload of an existing entry, or append a new one.
369
394
  * elementType/elementSize/elementCount must be supplied for new entries.
395
+ *
396
+ * This is the required way to mutate an entry read from a file — it clears `.raw`
397
+ * on replacement so writeAbif() never reuses a now-stale on-disk shape. Setting
398
+ * `entry.payload`/`elementType`/`elementSize`/`elementCount` directly instead
399
+ * leaves `.raw` behind and can corrupt the written directory record.
370
400
  */
371
401
  declare function upsertEntry(file: AbifFile, name: string, number: number, payload: Uint8Array, defaults: {
372
402
  elementType: number;
@@ -374,6 +404,86 @@ declare function upsertEntry(file: AbifFile, name: string, number: number, paylo
374
404
  elementCount: number;
375
405
  }): void;
376
406
 
407
+ /**
408
+ * High-level "parse everything" wrapper around the raw + view layers.
409
+ *
410
+ * Returns a single ParsedAbif object with metadata, the FWO_-aware
411
+ * DATA1..4 / DATA9..12 channel split, basecalls, and decoded directory entries
412
+ * for diagnostics. Mirrors what typical viewers (chromatogram UIs, sample
413
+ * inspectors) need from a single file.
414
+ *
415
+ * For round-trip authoring (basecallers), prefer {@link readAbif} +
416
+ * setters from ./setters which operate directly on the raw AbifFile.
417
+ */
418
+
419
+ /**
420
+ * Parse an ABIF file into a high-level view: metadata, channels, basecalls,
421
+ * and decoded directory entries.
422
+ *
423
+ * This is the INTERPRETING layer — it makes convenience choices (FWO_ → "GATC"
424
+ * fallback, derived samplingRate, preferred/upper-cased PBAS2 baseCalls). It is
425
+ * not the raw structural truth: for that use {@link readAbif}, which reads the
426
+ * directory verbatim (raw dataSize/offset/counts, tdir, inline bytes) and makes
427
+ * no interpretation. `parseAbif` builds on top of it.
428
+ *
429
+ * Accepts ArrayBuffer or Uint8Array (Buffer in Node works too — it extends
430
+ * Uint8Array). The `fileName` argument is informational only; it's preserved
431
+ * in the result.
432
+ */
433
+ declare function parseAbif(input: ArrayBuffer | Uint8Array, fileName?: string): ParsedAbif;
434
+ /** True when any channel has at least one signal value. */
435
+ declare function hasSignals(s: ChannelSignals): boolean;
436
+ /** Length of the longest channel in a ChannelSignals bundle. */
437
+ declare function channelMaxLength(s: ChannelSignals): number;
438
+
439
+ /**
440
+ * Mutation helpers for AbifFile: write PBAS/PCON/PLOC/SPAC and ensure DATA9..12
441
+ * are present. Used by basecallers to author ABIF output.
442
+ */
443
+
444
+ /** Set PBAS2 (replaces if present). */
445
+ declare function setSequence(file: AbifFile, sequence: string): void;
446
+ /** Set PCON2 (replaces if present). Values clamped to [0, 255]. */
447
+ declare function setConfidences(file: AbifFile, q: ArrayLike<number>): void;
448
+ /**
449
+ * Set PLOC2 (replaces if present).
450
+ *
451
+ * Written as UNSIGNED int16 so positions up to 65535 are preserved on disk
452
+ * (signed int16 would wrap on traces with > 32k scans).
453
+ */
454
+ declare function setPositions(file: AbifFile, positions: ArrayLike<number>): void;
455
+ /**
456
+ * Set SPAC/1, SPAC/2, SPAC/3 — the standard ABIF "average peak spacing" trio
457
+ * that KB-basecaller writes and downstream tools (BioPython, Sequencher,
458
+ * KB-aware viewers) expect:
459
+ *
460
+ * - SPAC/1 (float32) — average peak spacing used in last analysis.
461
+ * - SPAC/2 (pString) — basecaller name / identifier.
462
+ * - SPAC/3 (float32) — average peak spacing computed by the basecaller.
463
+ *
464
+ * Both float fields are written with the same value.
465
+ */
466
+ declare function setAveragePeakSpacing(file: AbifFile, spacing: number, basecallerName: string): void;
467
+ /**
468
+ * Mean consecutive peak-to-peak distance in scans, from a positions array.
469
+ * Returns 0 when fewer than 2 positions are present.
470
+ */
471
+ declare function averagePeakSpacing(positions: ArrayLike<number>): number;
472
+ /**
473
+ * Ensure DATA9..12 are present in the file. Many downstream consumers
474
+ * (BioPython-style readers, viewers) read the chromatogram signal from
475
+ * DATA9..12 by convention — these tags are the raw fluorescence on newer
476
+ * instruments. Older files that only carry DATA1..8 break those consumers
477
+ * with a "no signal" error after re-basecalling.
478
+ *
479
+ * We populate DATA9..12 by copying DATA1..4 (which on DATA1..8-only files IS
480
+ * the raw signal). The DATA1..4 tags are left untouched so any tool that
481
+ * reads them keeps working.
482
+ *
483
+ * No-op when DATA9..12 already exist.
484
+ */
485
+ declare function ensureRawDataChannels(file: AbifFile): void;
486
+
377
487
  /**
378
488
  * High-level typed view over an AbifFile.
379
489
  *
@@ -461,84 +571,4 @@ declare function getPositions(file: AbifFile): number[] | undefined;
461
571
  */
462
572
  declare function getSamplingRate(file: AbifFile): number | undefined;
463
573
 
464
- /**
465
- * Mutation helpers for AbifFile: write PBAS/PCON/PLOC/SPAC and ensure DATA9..12
466
- * are present. Used by basecallers to author ABIF output.
467
- */
468
-
469
- /** Set PBAS2 (replaces if present). */
470
- declare function setSequence(file: AbifFile, sequence: string): void;
471
- /** Set PCON2 (replaces if present). Values clamped to [0, 255]. */
472
- declare function setConfidences(file: AbifFile, q: ArrayLike<number>): void;
473
- /**
474
- * Set PLOC2 (replaces if present).
475
- *
476
- * Written as UNSIGNED int16 so positions up to 65535 are preserved on disk
477
- * (signed int16 would wrap on traces with > 32k scans).
478
- */
479
- declare function setPositions(file: AbifFile, positions: ArrayLike<number>): void;
480
- /**
481
- * Set SPAC/1, SPAC/2, SPAC/3 — the standard ABIF "average peak spacing" trio
482
- * that KB-basecaller writes and downstream tools (BioPython, Sequencher,
483
- * KB-aware viewers) expect:
484
- *
485
- * - SPAC/1 (float32) — average peak spacing used in last analysis.
486
- * - SPAC/2 (pString) — basecaller name / identifier.
487
- * - SPAC/3 (float32) — average peak spacing computed by the basecaller.
488
- *
489
- * Both float fields are written with the same value.
490
- */
491
- declare function setAveragePeakSpacing(file: AbifFile, spacing: number, basecallerName: string): void;
492
- /**
493
- * Mean consecutive peak-to-peak distance in scans, from a positions array.
494
- * Returns 0 when fewer than 2 positions are present.
495
- */
496
- declare function averagePeakSpacing(positions: ArrayLike<number>): number;
497
- /**
498
- * Ensure DATA9..12 are present in the file. Many downstream consumers
499
- * (BioPython-style readers, viewers) read the chromatogram signal from
500
- * DATA9..12 by convention — these tags are the raw fluorescence on newer
501
- * instruments. Older files that only carry DATA1..8 break those consumers
502
- * with a "no signal" error after re-basecalling.
503
- *
504
- * We populate DATA9..12 by copying DATA1..4 (which on DATA1..8-only files IS
505
- * the raw signal). The DATA1..4 tags are left untouched so any tool that
506
- * reads them keeps working.
507
- *
508
- * No-op when DATA9..12 already exist.
509
- */
510
- declare function ensureRawDataChannels(file: AbifFile): void;
511
-
512
- /**
513
- * High-level "parse everything" wrapper around the raw + view layers.
514
- *
515
- * Returns a single ParsedAbif object with metadata, the FWO_-aware
516
- * DATA1..4 / DATA9..12 channel split, basecalls, and decoded directory entries
517
- * for diagnostics. Mirrors what typical viewers (chromatogram UIs, sample
518
- * inspectors) need from a single file.
519
- *
520
- * For round-trip authoring (basecallers), prefer {@link readAbif} +
521
- * setters from ./setters which operate directly on the raw AbifFile.
522
- */
523
-
524
- /**
525
- * Parse an ABIF file into a high-level view: metadata, channels, basecalls,
526
- * and decoded directory entries.
527
- *
528
- * This is the INTERPRETING layer — it makes convenience choices (FWO_ → "GATC"
529
- * fallback, derived samplingRate, preferred/upper-cased PBAS2 baseCalls). It is
530
- * not the raw structural truth: for that use {@link readAbif}, which reads the
531
- * directory verbatim (raw dataSize/offset/counts, tdir, inline bytes) and makes
532
- * no interpretation. `parseAbif` builds on top of it.
533
- *
534
- * Accepts ArrayBuffer or Uint8Array (Buffer in Node works too — it extends
535
- * Uint8Array). The `fileName` argument is informational only; it's preserved
536
- * in the result.
537
- */
538
- declare function parseAbif(input: ArrayBuffer | Uint8Array, fileName?: string): ParsedAbif;
539
- /** True when any channel has at least one signal value. */
540
- declare function hasSignals(s: ChannelSignals): boolean;
541
- /** Length of the longest channel in a ChannelSignals bundle. */
542
- declare function channelMaxLength(s: ChannelSignals): number;
543
-
544
574
  export { type AbifBaseCallRole, type AbifBaseCallVariant, type AbifBaseCalls, type AbifByteRange, type AbifChromatogramBundle, type AbifDataChannelRole, type AbifDecodedValue, type AbifDirEntry, type AbifDirectory, type AbifEntry, type AbifEntryRaw, type AbifFile, type AbifMetadata, type ChannelSignals, type Chromatogram, ENTRY_SIZE, HEADER_SIZE, type ParsedAbif, averagePeakSpacing, channelMaxLength, dataChannelRole, ensureRawDataChannels, findEntries, findEntry, getChannelMap, getConfidences, getDataChannel, getFwo, getPositions, getReverseComplemented, getSamplingRate, getSequence, hasData9To12Block, hasSignals, isFwoPermutation, parseAbif, readAbif, setAveragePeakSpacing, setConfidences, setPositions, setSequence, tagNameFromInt32, upsertEntry, writeAbif };