jtc-utils
Version:
Utilities for Japanese Traditional Companies
377 lines (353 loc) • 11 kB
text/typescript
import type { FileHandle } from "node:fs/promises";
import type { Readable } from "node:stream";
import type { Charset, CharsetDecoder } from "./charset/charset.ts";
import { utf8 } from "./charset/utf8.ts";
export declare type FixlenReaderColumn = {
start: number;
length?: number;
shift?: boolean;
trim?: "left" | "right" | "both";
type?:
| "decimal"
| "int-le"
| "int-be"
| "uint-le"
| "uint-be"
| "zoned"
| "uzoned"
| "packed"
| "upacked";
};
export interface FixlenLineDecoder {
decode(column: FixlenReaderColumn): string | number;
}
export class FixlenReader {
private reader: Promise<ReadableStreamDefaultReader<Uint8Array>>;
private decoder: CharsetDecoder;
private lineLength: number;
private columns:
| (FixlenReaderColumn & { end: number })[]
| ((line: FixlenLineDecoder) => FixlenReaderColumn[]);
private shift: boolean;
private fatal: boolean;
private buf = new Uint8Array();
private index = 0;
constructor(
src:
| string
| Uint8Array
| Blob
| ReadableStream<Uint8Array>
| FileHandle
| Readable,
config: {
lineLength: number;
columns:
| FixlenReaderColumn[]
| ((line: FixlenLineDecoder) => FixlenReaderColumn[]);
charset?: Charset;
bom?: boolean;
shift?: boolean;
fatal?: boolean;
},
) {
const charset = config.charset ?? utf8;
this.fatal = config.fatal ?? true;
this.shift = config.shift ?? false;
this.lineLength = config.lineLength;
this.columns = Array.isArray(config.columns)
? this.normalizeColumns(this.lineLength, config.columns, this.shift)
: config.columns;
let stream: Promise<ReadableStream<Uint8Array>>;
if (typeof src === "string") {
stream = Promise.resolve(
new ReadableStream<Uint8Array>({
start(controller) {
const encoder = charset.createEncoder();
controller.enqueue(encoder.encode(src));
controller.close();
},
}),
);
} else if (src instanceof Uint8Array) {
stream = Promise.resolve(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(src);
controller.close();
},
}),
);
} else if (src instanceof Blob) {
stream = Promise.resolve(src.stream());
} else if (src instanceof ReadableStream) {
stream = Promise.resolve(src);
} else {
const readable = "createReadStream" in src ? src.createReadStream() : src;
if (
"constructor" in readable &&
"toWeb" in readable.constructor &&
typeof readable.constructor.toWeb === "function"
) {
stream = Promise.resolve(readable.constructor.toWeb(readable));
} else {
throw new TypeError(`Unsuppoted source: ${src}`);
}
}
this.decoder = charset.createDecoder({
fatal: this.fatal,
ignoreBOM: config.bom != null ? !config.bom : false,
});
this.reader = stream.then((value) => value.getReader());
}
async read(options?: {
lineLength: number;
columns:
| FixlenReaderColumn[]
| ((line: FixlenLineDecoder) => FixlenReaderColumn[]);
shift?: boolean;
}): Promise<(string | number)[] | undefined> {
const items = new Array<string | number>();
const lineLength = options?.lineLength ?? this.lineLength;
const shift = options?.shift ?? this.shift;
let done = false;
let buf = this.buf;
const reader = await this.reader;
do {
const readed = await reader.read();
done = readed.done;
if (readed.value) {
buf = buf.length > 0 ? this.concat(buf, readed.value) : readed.value;
}
} while (!done && buf.length < lineLength);
let line: Uint8Array;
if (buf.length === 0) {
this.buf = buf;
return;
} else if (buf.length < lineLength) {
if (this.fatal) {
throw new TypeError("Insufficient data.");
}
line = buf;
this.buf = buf.subarray(buf.length);
} else {
line = buf.subarray(0, lineLength);
this.buf = buf.subarray(lineLength);
}
let columns = !options?.columns
? this.columns
: Array.isArray(options.columns)
? this.normalizeColumns(lineLength, options.columns, shift)
: options.columns;
if (!Array.isArray(columns)) {
columns = this.normalizeColumns(
lineLength,
columns({
decode: (column: FixlenReaderColumn) => {
const col: FixlenReaderColumn & { end: number } = {
...column,
end:
column.length != null
? column.start + column.length
: lineLength,
};
return this.decode(col, line);
},
}),
shift,
);
}
for (let i = 0; i < columns.length; i++) {
items.push(this.decode(columns[i], line));
}
this.index++;
return items;
}
async *[Symbol.asyncIterator](): AsyncGenerator<(string | number)[]> {
let record: (string | number)[] | undefined;
while ((record = await this.read()) != null) {
yield record;
}
}
get count() {
return this.index;
}
async close() {
const reader = await this.reader;
await reader.cancel();
}
private normalizeColumns(
lineLength: number,
columns: FixlenReaderColumn[],
shift: boolean,
): (FixlenReaderColumn & { end: number })[] {
return columns.map((col, index, array) => {
if (col.start < 0) {
throw new RangeError(`columns[${index}].start must be positive.`);
}
if (col.start >= lineLength) {
throw new RangeError(`columns[${index}].start is too large.`);
}
if (col.length != null && col.length <= 0) {
throw new RangeError(`columns[${index}].length must be positive.`);
}
let end: number;
if (col.length != null) {
end = col.start + col.length;
if (end > lineLength) {
throw new RangeError(`columns[${index}].length is too large.`);
}
} else if (index + 1 === array.length) {
end = lineLength;
} else if (col.start < array[index + 1].start) {
end = array[index + 1].start;
} else {
throw new RangeError(`columns[${index}].length is required.`);
}
return {
start: col.start,
end,
length: col.length,
shift: col.shift ?? shift,
trim: col.trim,
type: col.type,
};
});
}
private decode(col: FixlenReaderColumn & { end: number }, line: Uint8Array) {
if (!col.type || col.type === "decimal") {
let text = "";
const minEnd = Math.min(col.end, line.length);
if (col.start < minEnd) {
text = this.decoder.decode(line.subarray(col.start, minEnd), {
shift: col.shift,
});
switch (col.trim) {
case "left":
text = text.trimStart();
break;
case "right":
text = text.trimEnd();
break;
case "both":
text = text.trim();
break;
}
}
if (col.type === "decimal") {
const num = Number.parseFloat(text);
if (this.fatal && Number.isNaN(num)) {
throw new RangeError(`Invalid number: ${text}`);
}
return num;
}
return text;
} else if (col.type.startsWith("int-")) {
const view = new DataView(line.buffer, line.byteOffset, line.byteLength);
const littleEndien = col.type === "int-le";
const len = col.end - col.start;
if (len === 4) {
return view.getInt32(col.start, littleEndien);
} else if (len === 2) {
return view.getInt16(col.start, littleEndien);
} else if (len === 1) {
return view.getInt8(col.start);
} else {
if (this.fatal) {
throw new RangeError(
`byte length of ${col.type} type must be 1, 2 or 4.`,
);
}
return Number.NaN;
}
} else if (col.type.startsWith("uint-")) {
const view = new DataView(line.buffer, line.byteOffset, line.byteLength);
const littleEndien = col.type === "uint-le";
const len = col.end - col.start;
if (len === 4) {
return view.getUint32(col.start, littleEndien);
} else if (len === 2) {
return view.getUint16(col.start, littleEndien);
} else if (len === 1) {
return view.getUint8(col.start);
} else {
if (this.fatal) {
throw new RangeError(
`byte length of ${col.type} type must be 1, 2 or 4.`,
);
}
return Number.NaN;
}
} else if (col.type === "zoned" || col.type === "uzoned") {
try {
let num = 0;
for (let i = col.start; i < col.end; i++) {
const l4 = line[i] & 0xf;
if (l4 > 0x9) {
throw new RangeError(
`low 4 bits of ${col.type} type must be 0-9: 0x${l4.toString(16).padStart(2, "0")}`,
);
}
num = num * 10 + l4;
if (col.type === "zoned" && i + 1 === col.end) {
const h4 = (line[i] >>> 4) & 0xf;
if (h4 === 0xb || h4 === 0xd || h4 === 0x7) {
num *= -1;
}
}
}
return num;
} catch (err) {
if (this.fatal) {
throw err;
}
return Number.NaN;
}
} else if (col.type === "packed" || col.type === "upacked") {
try {
let num = 0;
for (let i = col.start; i < col.end; i++) {
const h4 = (line[i] >> 4) & 0xf;
if (h4 > 0x9) {
throw new RangeError(
`high 4 bits of ${col.type} type must be 0-9: 0x${h4.toString(16).padStart(2, "0")}`,
);
}
num = num * 10 + h4;
const l4 = line[i] & 0xf;
if (col.type === "packed" && i + 1 === col.end) {
if (l4 <= 0x9) {
throw new RangeError(
`last low 4 bits of ${col.type} type must be A-F: 0x${l4.toString(16).padStart(2, "0")}`,
);
}
if (l4 === 0xb || l4 === 0xd) {
num *= -1;
}
} else {
if (l4 > 0x9) {
throw new RangeError(
`low 4 bits of ${col.type} type must be 0-9: 0x${l4.toString(16).padStart(2, "0")}`,
);
}
num = num * 10 + l4;
}
}
return num;
} catch (err) {
if (this.fatal) {
throw err;
}
return Number.NaN;
}
} else {
throw new RangeError(`unknown column type: ${col.type}`);
}
}
private concat(a1: Uint8Array, a2: Uint8Array) {
const result = new Uint8Array(a1.length + a2.length);
result.set(a1, 0);
result.set(a2, a1.length);
return result;
}
}