@gmod/cram
Version:
read CRAM files with pure Javascript
417 lines (377 loc) • 13.2 kB
text/typescript
import QuickLRU from '@jbrowse/quick-lru'
import crc32 from 'crc/calculators/crc32'
import { CramMalformedError, CramUnimplementedError } from '../errors.ts'
import * as htscodecs from '../htscodecs/index.ts'
import { open } from '../io.ts'
import { parseHeaderText } from '../sam.ts'
import { decodeUtf8, parseItem } from './util.ts'
import { unzip } from '../unzip.ts'
import CramContainer from './container/index.ts'
import {
type BlockHeader,
type CompressionMethod,
cramFileDefinition,
getSectionParsers,
} from './sectionParsers.ts'
import { xzDecompress } from '../xz-decompress/xz-decompress.ts'
import type CramRecord from './record.ts'
import type { GenericFilehandle } from 'generic-filehandle2'
// source: https://abdulapopoola.com/2019/01/20/check-endianness-with-javascript/
let isLittleEndian: boolean | undefined
function checkLittleEndian() {
if (isLittleEndian === undefined) {
isLittleEndian =
new Uint8Array(new Uint32Array([0x11223344]).buffer)[0] === 0x44
}
return isLittleEndian
}
export interface CramFileSource {
filehandle?: GenericFilehandle
url?: string
path?: string
}
export type SeqFetch = (
seqId: number,
start: number,
end: number,
) => Promise<string>
export type CramFileArgs = CramFileSource & {
checkSequenceMD5?: boolean
cacheSize?: number
seqFetch?: SeqFetch
validateChecksums?: boolean
}
export type CramFileBlock = BlockHeader & {
_endPosition: number
contentPosition: number
_size: number
content: Uint8Array
crc32?: number
}
export default class CramFile {
private file: GenericFilehandle
public validateChecksums: boolean
public fetchReferenceSequenceCallback?: SeqFetch
public options: {
checkSequenceMD5?: boolean
cacheSize: number
}
public featureCache: QuickLRU<string, Promise<CramRecord[]>>
private header: string | undefined
private _sectionParsers?: ReturnType<typeof getSectionParsers>
private _definitionResult?: ReturnType<CramFile['_fetchDefinition']>
private _samHeaderResult?: ReturnType<CramFile['_fetchSamHeader']>
constructor(args: CramFileArgs) {
this.file = open(args.url, args.path, args.filehandle)
this.validateChecksums = args.validateChecksums ?? false
this.fetchReferenceSequenceCallback = args.seqFetch
this.options = {
checkSequenceMD5: args.checkSequenceMD5,
cacheSize: args.cacheSize ?? 20000,
}
// cache of features in a slice, keyed by the slice offset. caches all of
// the features in a slice, or none. the cache is actually used by the
// slice object, it's just kept here at the level of the file
this.featureCache = new QuickLRU({
maxSize: this.options.cacheSize,
})
if (!checkLittleEndian()) {
throw new Error('Detected big-endian machine, may be unable to run')
}
}
read(length: number, position: number) {
return this.file.read(length, position)
}
private async _getSectionParsers() {
if (!this._sectionParsers) {
const { majorVersion } = await this.getDefinition()
this._sectionParsers = getSectionParsers(majorVersion)
}
return this._sectionParsers
}
async getDefinition() {
if (this._definitionResult === undefined) {
this._definitionResult = this._fetchDefinition()
this._definitionResult.catch(() => {
this._definitionResult = undefined
})
}
return this._definitionResult
}
private async _fetchDefinition() {
const { maxLength, parser } = cramFileDefinition()
const headbytes = await this.file.read(maxLength, 0)
const definition = parser(headbytes).value
if (definition.magic !== 'CRAM') {
throw new Error('Not a CRAM file, does not match magic string')
} else if (definition.majorVersion !== 2 && definition.majorVersion !== 3) {
throw new CramUnimplementedError(
`CRAM version ${definition.majorVersion} not supported`,
)
} else {
return definition
}
}
async getSamHeader() {
if (this._samHeaderResult === undefined) {
this._samHeaderResult = this._fetchSamHeader()
this._samHeaderResult.catch(() => {
this._samHeaderResult = undefined
})
}
return this._samHeaderResult
}
private async _fetchSamHeader() {
const firstContainer = await this.getContainerById(0)
if (!firstContainer) {
throw new CramMalformedError('file contains no containers')
}
const firstBlock = await firstContainer.getFirstBlock()
const content = firstBlock.content
const dataView = new DataView(
content.buffer,
content.byteOffset,
content.byteLength,
)
const headerLength = dataView.getInt32(0, true)
const textStart = 4
const text = decodeUtf8(
content.subarray(textStart, textStart + headerLength),
)
this.header = text
return parseHeaderText(text)
}
async getHeaderText() {
await this.getSamHeader()
return this.header
}
// Walk containers from the start of the file. Yields each container along
// with its parsed header. The first container's length is recomputed by
// reading all of its blocks because the recorded length cannot be trusted
// (htslib bug); subsequent containers use header._size + header.length.
private async *iterContainers() {
const sectionParsers = await this._getSectionParsers()
let position = sectionParsers.cramFileDefinition.maxLength
let i = 0
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
const container = this.getContainerAtPosition(position)
const header = await container.getHeader()
yield container
if (i === 0) {
position = header._endPosition
for (let j = 0; j < header.numBlocks; j++) {
const block = await this.readBlock(position)
position = block._endPosition
}
} else {
position += header._size + header.length
}
i++
}
}
async getContainerById(containerNumber: number) {
let i = 0
for await (const container of this.iterContainers()) {
if (i === containerNumber) {
return container
}
i++
}
return undefined
}
async checkCrc32(
position: number,
length: number,
recordedCrc32: number,
description: string,
) {
const b = await this.file.read(length, position)
// this shift >>> 0 is equivalent to crc32(b).unsigned but uses the
// internal calculator of crc32 to avoid accidentally importing buffer
// https://github.com/alexgorbatchev/crc/blob/31fc3853e417b5fb5ec83335428805842575f699/src/define_crc.ts#L5
const calculatedCrc32 = crc32(b) >>> 0
if (calculatedCrc32 !== recordedCrc32) {
throw new CramMalformedError(
`crc mismatch in ${description}: recorded CRC32 = ${recordedCrc32}, but calculated CRC32 = ${calculatedCrc32}`,
)
}
}
/**
* @returns {Promise[number]} the number of containers in the file
*
* note: this is currently used only in unit tests, and after removing file
* length check, relies on a try catch to read return an error to break
*/
async containerCount(): Promise<number | undefined> {
let containerCount = 0
try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _container of this.iterContainers()) {
containerCount += 1
}
} catch (e) {
containerCount--
}
return containerCount
}
getContainerAtPosition(position: number) {
return new CramContainer(this, position)
}
async readBlockHeader(
position: number,
): Promise<BlockHeader & { _endPosition: number; _size: number }> {
const { cramBlockHeader } = await this._getSectionParsers()
const buffer = await this.file.read(cramBlockHeader.maxLength, position)
return parseItem(buffer, cramBlockHeader.parser, 0, position)
}
async _parseSection<T>(
section: {
maxLength: number
parser: (
buffer: Uint8Array,
offset: number,
) => { offset: number; value: T }
},
position: number,
size = section.maxLength,
preReadBuffer?: Uint8Array,
): Promise<T & { _endPosition: number; _size: number }> {
const buffer = preReadBuffer ?? (await this.file.read(size, position))
const data = parseItem(buffer, section.parser, 0, position)
if (data._size !== size) {
throw new CramMalformedError(
`section read error: requested size ${size} does not equal parsed size ${data._size}`,
)
}
return data
}
async _uncompress(
compressionMethod: CompressionMethod,
inputBuffer: Uint8Array,
uncompressedSize: number,
): Promise<Uint8Array> {
let buf: Uint8Array
if (compressionMethod === 'gzip') {
buf = await unzip(inputBuffer)
} else if (compressionMethod === 'bzip2') {
buf = await htscodecs.bz2_uncompress(inputBuffer, uncompressedSize)
} else if (compressionMethod === 'lzma') {
buf = await xzDecompress(inputBuffer)
} else if (compressionMethod === 'rans') {
buf = await htscodecs.rans_uncompress(inputBuffer)
} else if (compressionMethod === 'rans4x16') {
buf = await htscodecs.r4x16_uncompress(inputBuffer)
} else if (compressionMethod === 'arith') {
buf = await htscodecs.arith_uncompress(inputBuffer)
} else if (compressionMethod === 'fqzcomp') {
buf = await htscodecs.fqzcomp_uncompress(inputBuffer)
} else if (compressionMethod === 'tok3') {
buf = await htscodecs.tok3_uncompress(inputBuffer)
} else {
throw new CramUnimplementedError(
`${compressionMethod} decompression not yet implemented`,
)
}
if (buf.length !== uncompressedSize) {
throw new CramMalformedError(
`${compressionMethod} decompression produced ${buf.length} bytes, expected ${uncompressedSize}`,
)
}
return buf
}
async readBlock(position: number) {
const { majorVersion } = await this.getDefinition()
const { cramBlockHeader, cramBlockCrc32 } = await this._getSectionParsers()
const headerBuf = await this.file.read(cramBlockHeader.maxLength, position)
const blockHeader = parseItem(
headerBuf,
cramBlockHeader.parser,
0,
position,
)
const totalSize =
blockHeader._size +
blockHeader.compressedSize +
(majorVersion >= 3 ? cramBlockCrc32.maxLength : 0)
const fullBuffer = await this.file.read(totalSize, position)
return this.readBlockFromBuffer(fullBuffer, 0, position)
}
async readBlockFromBuffer(
buffer: Uint8Array,
bufferOffset: number,
filePosition: number,
) {
const { majorVersion } = await this.getDefinition()
const sectionParsers = await this._getSectionParsers()
const { cramBlockHeader } = sectionParsers
const headerBytes = buffer.subarray(
bufferOffset,
bufferOffset + cramBlockHeader.maxLength,
)
const blockHeader = parseItem(
headerBytes,
cramBlockHeader.parser,
0,
filePosition,
)
const blockContentPosition = blockHeader._endPosition
const contentOffset = bufferOffset + blockHeader._size
const d = buffer.subarray(
contentOffset,
contentOffset + blockHeader.compressedSize,
)
// Per CRAM spec (PR #681), blocks with uncompressed size 0 are treated as
// empty regardless of the method byte — htsjdk has produced invalid empty
// RANS blocks that would otherwise fail to decompress.
const uncompressedData =
blockHeader.uncompressedSize === 0
? new Uint8Array(0)
: blockHeader.compressionMethod !== 'raw'
? await this._uncompress(
blockHeader.compressionMethod,
d,
blockHeader.uncompressedSize,
)
: d
const block: CramFileBlock = {
...blockHeader,
_endPosition: blockContentPosition,
contentPosition: blockContentPosition,
content: uncompressedData,
}
if (majorVersion >= 3) {
const crcOffset = contentOffset + blockHeader.compressedSize
const crcBytes = buffer.subarray(
crcOffset,
crcOffset + sectionParsers.cramBlockCrc32.maxLength,
)
const crc = parseItem(
crcBytes,
sectionParsers.cramBlockCrc32.parser,
0,
blockContentPosition + blockHeader.compressedSize,
)
block.crc32 = crc.crc32
if (this.validateChecksums) {
const blockData = buffer.subarray(
bufferOffset,
bufferOffset + blockHeader._size + blockHeader.compressedSize,
)
const calculatedCrc32 = crc32(blockData) >>> 0
if (calculatedCrc32 !== crc.crc32) {
throw new CramMalformedError(
`crc mismatch in block data: recorded CRC32 = ${crc.crc32}, but calculated CRC32 = ${calculatedCrc32}`,
)
}
}
block._endPosition = crc._endPosition
block._size =
block.compressedSize + sectionParsers.cramBlockCrc32.maxLength
} else {
block._endPosition = blockContentPosition + block.compressedSize
block._size = block.compressedSize
}
return block
}
}