Files
LWLT-AIBOT/control-plane/src/passenger-roster-workbook.ts
2026-09-07 20:22:00 +08:00

672 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { extname } from 'node:path';
import ExcelJS from 'exceljs';
import { fromBufferPromise, type Entry, type ZipFile } from 'yauzl';
import type { DocumentConversionErrorCode } from './document-converter.js';
const XLSX_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
const XLS_MAGIC = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
const DEFAULT_MAX_INPUT_BYTES = 10 * 1024 * 1024;
const DEFAULT_MAX_UNCOMPRESSED_BYTES = 64 * 1024 * 1024;
const DEFAULT_MAX_ARCHIVE_ENTRIES = 2_048;
const DEFAULT_MAX_DATA_ROWS = 5_000;
const ABSOLUTE_MAX_DATA_ROWS = 5_000;
const MAX_PASSENGER_SEQUENCE = 5_000;
const MAX_HEADER_ROW = 100;
export const PASSENGER_ROSTER_WORKBOOK_VERSION = 'ltjt-passenger-roster-workbook-v1.4.0';
const REQUIRED_SOURCE_FIELDS = [
'序号', '姓名', '英文姓名', '性别', '出生日期', '出生地', '护照号码',
'签发地', '签发日期', '有效期', '电话', '备注'
] as const;
type RequiredSourceField = typeof REQUIRED_SOURCE_FIELDS[number];
type SourceColumnMap = Record<RequiredSourceField, number>;
/**
* Header recognition is intentionally exact and finite. It accepts the
* required customer-facing labels, canonical ERP labels, and approved aliases.
* Non-import columns are deliberately absent from this map and are ignored.
*/
const HEADER_FIELD_ENTRIES: ReadonlyArray<readonly [string, RequiredSourceField]> = [
['序号', '序号'],
['姓名', '姓名'],
['英文姓名', '英文姓名'],
['NAME', '英文姓名'],
['性别', '性别'],
['出生日期', '出生日期'],
['出生地', '出生地'],
['护照号码', '护照号码'],
['证件号码', '护照号码'],
['签发地', '签发地'],
['签发日期', '签发日期'],
['签发日', '签发日期'],
['有效期', '有效期'],
['电话', '电话'],
['备注', '备注']
];
const SOURCE_FIELD_BY_HEADER = new Map<string, RequiredSourceField>(HEADER_FIELD_ENTRIES);
export const CANONICAL_PASSENGER_HEADERS = [
'序号', '姓名', 'NAME', '性别', '出生日期', '出生地', '证件类型', '证件号码',
'签发地', '签发日', '有效期', '电话', '备注'
] as const;
export type PassengerRosterWorkbookErrorCode =
| 'roster_workbook_empty'
| 'roster_workbook_too_large'
| 'roster_workbook_unsupported_format'
| 'roster_workbook_format_mismatch'
| 'roster_workbook_converter_unavailable'
| 'roster_workbook_conversion_failed'
| 'roster_workbook_invalid_xlsx'
| 'roster_workbook_archive_limit'
| 'roster_workbook_unsafe_content'
| 'roster_workbook_ambiguous_sheet'
| 'roster_workbook_hidden_data'
| 'roster_workbook_header_not_found'
| 'roster_workbook_ambiguous_header'
| 'roster_workbook_formula_not_allowed'
| 'roster_workbook_formula_result_missing'
| 'roster_workbook_non_contiguous_rows'
| 'roster_workbook_row_limit'
| 'roster_workbook_no_data'
| 'roster_workbook_invalid_sequence'
| 'roster_workbook_sequence_limit'
| 'roster_workbook_duplicate_sequence'
| 'roster_workbook_invalid_date'
| 'roster_workbook_invalid_identifier'
| 'roster_workbook_invalid_cell';
const ERROR_MESSAGES: Record<PassengerRosterWorkbookErrorCode, string> = {
roster_workbook_empty: '名单工作簿为空',
roster_workbook_too_large: '名单工作簿超过大小限制',
roster_workbook_unsupported_format: '名单附件格式不受支持',
roster_workbook_format_mismatch: '名单附件扩展名、类型或文件内容不匹配',
roster_workbook_converter_unavailable: '旧版名单工作簿转换器不可用',
roster_workbook_conversion_failed: '旧版名单工作簿转换失败',
roster_workbook_invalid_xlsx: '名单工作簿不是有效的 XLSX',
roster_workbook_archive_limit: '名单工作簿解压规模超过限制',
roster_workbook_unsafe_content: '名单工作簿包含不允许的宏、外部链接或嵌入内容',
roster_workbook_ambiguous_sheet: '名单工作簿必须且只能包含一个工作表',
roster_workbook_hidden_data: '名单工作簿包含隐藏的必需名单字段数据',
roster_workbook_header_not_found: '未找到可映射到 ERP 名单字段的完整表头',
roster_workbook_ambiguous_header: '名单工作簿包含多个可识别的名单表头',
roster_workbook_formula_not_allowed: '名单必需字段包含不允许的公式',
roster_workbook_formula_result_missing: '名单必需字段公式没有可验证的缓存结果',
roster_workbook_non_contiguous_rows: '名单数据行不连续',
roster_workbook_row_limit: '名单数据行数超过技术限制',
roster_workbook_no_data: '名单工作簿没有数据行',
roster_workbook_invalid_sequence: '名单序号必须是正整数',
roster_workbook_sequence_limit: '名单序号超过技术安全上限',
roster_workbook_duplicate_sequence: '名单序号不能重复',
roster_workbook_invalid_date: '名单日期无效',
roster_workbook_invalid_identifier: '名单证件号或电话无法安全转换为文本',
roster_workbook_invalid_cell: '名单单元格值无效'
};
export class PassengerRosterWorkbookError extends Error {
readonly code: PassengerRosterWorkbookErrorCode;
readonly row?: number;
readonly column?: number;
readonly conversionErrorCode?: DocumentConversionErrorCode;
constructor(
code: PassengerRosterWorkbookErrorCode,
location: { row?: number; column?: number; conversionErrorCode?: DocumentConversionErrorCode } = {}
) {
super(ERROR_MESSAGES[code]);
this.name = 'PassengerRosterWorkbookError';
this.code = code;
this.row = location.row;
this.column = location.column;
this.conversionErrorCode = location.conversionErrorCode;
}
}
export interface PassengerRosterWorkbookInput {
content: Buffer;
fileName: string;
contentType: string;
}
/**
* The caller must run legacy conversion in an isolated, macro-disabled
* process and must reject converter fallback to the original source bytes.
* This normalizer independently verifies that the callback returned XLSX.
*/
export type LegacyXlsConverter = (input: PassengerRosterWorkbookInput) => Promise<Buffer>;
export interface PassengerRosterWorkbookOptions {
convertLegacyXls?: LegacyXlsConverter;
maxInputBytes?: number;
maxUncompressedBytes?: number;
maxArchiveEntries?: number;
maxDataRows?: number;
}
export interface NormalizedPassengerRosterWorkbook {
canonicalTsv: string;
rowCount: number;
sourceFormat: 'xls' | 'xlsx';
worksheetName: string;
headerRow: number;
}
type ScalarCellValue = string | number | boolean | Date | null;
function fail(
code: PassengerRosterWorkbookErrorCode,
location: { row?: number; column?: number; conversionErrorCode?: DocumentConversionErrorCode } = {}
): never {
throw new PassengerRosterWorkbookError(code, location);
}
function startsWithMagic(content: Buffer, magic: Buffer): boolean {
return content.byteLength >= magic.byteLength
&& content.subarray(0, magic.byteLength).equals(magic);
}
function normalizedMimeType(contentType: string): string {
return contentType.toLowerCase().split(';', 1)[0].trim();
}
function sourceFormat(input: PassengerRosterWorkbookInput): 'xls' | 'xlsx' {
const extension = extname(input.fileName).toLowerCase();
const mimeType = normalizedMimeType(input.contentType);
const xlsxMagic = startsWithMagic(input.content, XLSX_MAGIC);
const xlsMagic = startsWithMagic(input.content, XLS_MAGIC);
const genericMime = mimeType === ''
|| mimeType === 'application/octet-stream'
|| mimeType === 'application/zip';
const xlsxMime = mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
const xlsMime = [
'application/vnd.ms-excel',
'application/msexcel',
'application/x-msexcel',
'application/xls',
'application/vnd.ms-office'
].includes(mimeType);
if (extension !== '.xls' && extension !== '.xlsx') {
fail('roster_workbook_unsupported_format');
}
if (extension === '.xlsx') {
if (!xlsxMagic || xlsMagic || (!genericMime && !xlsxMime)) {
fail('roster_workbook_format_mismatch');
}
return 'xlsx';
}
if (!xlsMagic || xlsxMagic || (!genericMime && !xlsMime)) {
fail('roster_workbook_format_mismatch');
}
return 'xls';
}
function legacyWorkbookHasMacroMarkers(content: Buffer): boolean {
const markers = ['_VBA_PROJECT_CUR', 'PROJECTwm', 'VBA/dir', 'VBA\\dir', 'Macros'];
return markers.some((marker) => (
content.indexOf(Buffer.from(marker, 'latin1')) >= 0
|| content.indexOf(Buffer.from(marker, 'utf16le')) >= 0
));
}
const UNSAFE_ARCHIVE_PATH = /(?:^|\/)(?:vbaProject\.bin|macrosheets\/|activex\/|embeddings\/|externalLinks\/|connections\.xml$|queryTables\/|webextensions\/|model\/)/i;
const RELATIONSHIP_OR_CONTENT_TYPE_PATH = /(?:\.rels|\[Content_Types\]\.xml)$/i;
const EXTERNAL_RELATIONSHIP_PATTERN = /\bTargetMode\s*=\s*["']External["']/i;
const MACRO_CONTENT_TYPE_PATTERN = /(?:macroEnabled|vbaProject|activeX|oleObject)/i;
async function readArchiveEntryText(zipFile: ZipFile, entry: Entry): Promise<string> {
const stream = await zipFile.openReadStreamPromise(entry);
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString('utf8');
}
async function inspectXlsxArchive(
content: Buffer,
options: Required<Pick<PassengerRosterWorkbookOptions, 'maxUncompressedBytes' | 'maxArchiveEntries'>>
): Promise<void> {
let zipFile: ZipFile;
try {
zipFile = await fromBufferPromise(content, {
lazyEntries: true,
decodeStrings: true,
validateEntrySizes: true,
strictFileNames: true
});
} catch {
fail('roster_workbook_invalid_xlsx');
}
let entryCount = 0;
let uncompressedBytes = 0;
try {
for await (const entry of zipFile.eachEntry()) {
entryCount += 1;
uncompressedBytes += entry.uncompressedSize;
if (
entryCount > options.maxArchiveEntries
|| entry.uncompressedSize > options.maxUncompressedBytes
|| uncompressedBytes > options.maxUncompressedBytes
) {
fail('roster_workbook_archive_limit');
}
if (entry.isEncrypted() || !entry.canDecodeFileData()) {
fail('roster_workbook_unsafe_content');
}
const normalizedPath = entry.fileName.replace(/\\/g, '/');
if (UNSAFE_ARCHIVE_PATH.test(normalizedPath)) {
fail('roster_workbook_unsafe_content');
}
if (RELATIONSHIP_OR_CONTENT_TYPE_PATH.test(normalizedPath) && entry.uncompressedSize > 0) {
const source = await readArchiveEntryText(zipFile, entry);
if (
EXTERNAL_RELATIONSHIP_PATTERN.test(source)
|| (normalizedPath.endsWith('[Content_Types].xml') && MACRO_CONTENT_TYPE_PATTERN.test(source))
) {
fail('roster_workbook_unsafe_content');
}
}
}
} catch (error) {
if (error instanceof PassengerRosterWorkbookError) throw error;
fail('roster_workbook_invalid_xlsx');
} finally {
zipFile.close();
}
}
function formulaText(cell: ExcelJS.Cell): string | undefined {
if (cell.type !== ExcelJS.ValueType.Formula) return undefined;
const formula = cell.formula;
return typeof formula === 'string' ? formula : undefined;
}
function containsExternalFormulaReference(formula: string): boolean {
return /(?:\[[^\]]+\]|https?:|ftp:|file:|\\\\|WEBSERVICE\s*\(|HYPERLINK\s*\(|DDE\s*\()/i.test(formula);
}
function normalizedFormula(formula: string): string {
return formula
.replace(/^=/, '')
.replace(/\$/g, '')
.replace(/;/g, ',')
.replace(/\s+/g, '')
.toUpperCase();
}
function cellRow(cell: ExcelJS.Cell): number {
return cell.fullAddress.row;
}
function cellColumn(cell: ExcelJS.Cell): number {
return cell.fullAddress.col;
}
function excelColumnName(column: number): string {
let value = column;
let result = '';
while (value > 0) {
const remainder = (value - 1) % 26;
result = String.fromCharCode(65 + remainder) + result;
value = Math.floor((value - 1) / 26);
}
return result;
}
function formulaAllowed(
cell: ExcelJS.Cell,
headerRow: number,
sourceColumns: SourceColumnMap
): boolean {
const row = cellRow(cell);
const column = cellColumn(cell);
if (row <= headerRow) return false;
const formula = normalizedFormula(cell.formula);
if (column === sourceColumns['签发日期']) {
return formula === `EDATE(${excelColumnName(sourceColumns['有效期'])}${row},-10*12)+1`;
}
if (column === sourceColumns['有效期']) {
return formula === `EDATE(${excelColumnName(sourceColumns['签发日期'])}${row},10*12)-1`;
}
return false;
}
function scalarValue(cell: ExcelJS.Cell, allowFormulaResult = false): ScalarCellValue {
if (cell.type === ExcelJS.ValueType.Formula) {
if (!allowFormulaResult) fail('roster_workbook_formula_not_allowed', { row: cellRow(cell), column: cellColumn(cell) });
const result = cell.result;
if (result === undefined || result === null) {
fail('roster_workbook_formula_result_missing', { row: cellRow(cell), column: cellColumn(cell) });
}
if (typeof result === 'object' && !(result instanceof Date)) {
fail('roster_workbook_invalid_cell', { row: cellRow(cell), column: cellColumn(cell) });
}
return result;
}
const value = cell.value;
if (value === undefined || value === null) return null;
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value instanceof Date) {
return value;
}
if ('hyperlink' in value) {
fail('roster_workbook_unsafe_content', { row: cellRow(cell), column: cellColumn(cell) });
}
if ('richText' in value) {
return value.richText.map((part) => part.text).join('');
}
fail('roster_workbook_invalid_cell', { row: cellRow(cell), column: cellColumn(cell) });
}
function normalizedText(value: ScalarCellValue): string {
if (value === null) return '';
if (value instanceof Date) return value.toISOString();
return String(value)
.replace(/[\t\r\n\u3000]+/g, ' ')
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function headerText(cell: ExcelJS.Cell): string {
if (cell.type === ExcelJS.ValueType.Formula) return '';
return normalizedText(scalarValue(cell));
}
function cellHasData(cell: ExcelJS.Cell): boolean {
if (cell.type === ExcelJS.ValueType.Formula) return true;
return normalizedText(scalarValue(cell)) !== '';
}
function rowHasData(row: ExcelJS.Row, sourceColumns: readonly number[]): boolean {
for (const column of sourceColumns) {
if (cellHasData(row.getCell(column))) return true;
}
return false;
}
function validateFormulasAndHiddenData(
sheet: ExcelJS.Worksheet,
headerRow: number,
sourceColumns: SourceColumnMap
): void {
const selectedColumns = new Set(Object.values(sourceColumns));
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
let hasSelectedData = false;
row.eachCell({ includeEmpty: false }, (cell, columnNumber) => {
if (cell.type === ExcelJS.ValueType.Merge && cell.master.address !== cell.address) return;
const formula = formulaText(cell);
if (formula) {
if (containsExternalFormulaReference(formula)) {
fail('roster_workbook_unsafe_content', { row: rowNumber, column: columnNumber });
}
if (selectedColumns.has(columnNumber) && !formulaAllowed(cell, headerRow, sourceColumns)) {
fail('roster_workbook_formula_not_allowed', { row: rowNumber, column: columnNumber });
}
}
if (cell.type === ExcelJS.ValueType.Hyperlink || cell.isHyperlink) {
fail('roster_workbook_unsafe_content', { row: rowNumber, column: columnNumber });
}
if (selectedColumns.has(columnNumber) && cellHasData(cell)) {
hasSelectedData = true;
}
});
if (row.hidden && hasSelectedData) fail('roster_workbook_hidden_data', { row: rowNumber });
});
for (const columnNumber of selectedColumns) {
const column = sheet.getColumn(columnNumber);
if (!column.hidden) continue;
let hasData = false;
column.eachCell({ includeEmpty: false }, (cell) => {
if (cellHasData(cell)) hasData = true;
});
if (hasData) fail('roster_workbook_hidden_data', { column: columnNumber });
}
}
function headerColumnsForRow(sheet: ExcelJS.Worksheet, rowNumber: number): SourceColumnMap | null {
const columns = new Map<RequiredSourceField, number>();
const row = sheet.getRow(rowNumber);
for (let columnNumber = 1; columnNumber <= sheet.columnCount; columnNumber += 1) {
const cell = row.getCell(columnNumber);
if (cell.type === ExcelJS.ValueType.Merge && cell.master.address !== cell.address) continue;
const value = headerText(cell);
if (value === '') continue;
const field = SOURCE_FIELD_BY_HEADER.get(value);
if (!field) continue;
if (columns.has(field)) return null;
columns.set(field, columnNumber);
}
if (!REQUIRED_SOURCE_FIELDS.every((field) => columns.has(field))) return null;
const result: Partial<SourceColumnMap> = {};
for (const field of REQUIRED_SOURCE_FIELDS) {
result[field] = columns.get(field);
}
return result as SourceColumnMap;
}
function findHeaderLayout(sheet: ExcelJS.Worksheet): { row: number; columns: SourceColumnMap } {
const candidates: Array<{ row: number; columns: SourceColumnMap }> = [];
const lastCandidate = Math.min(sheet.rowCount, MAX_HEADER_ROW);
for (let rowNumber = 1; rowNumber <= lastCandidate; rowNumber += 1) {
const columns = headerColumnsForRow(sheet, rowNumber);
if (columns) candidates.push({ row: rowNumber, columns });
}
if (candidates.length === 0) fail('roster_workbook_header_not_found');
if (candidates.length !== 1) fail('roster_workbook_ambiguous_header');
return candidates[0];
}
function excelSerialToDate(value: number, date1904: boolean): Date | undefined {
if (!Number.isFinite(value) || value < 0) return undefined;
const millisecondsPerDay = 86_400_000;
const epoch = date1904
? Date.UTC(1904, 0, 1)
: Date.UTC(1899, 11, 30);
const result = new Date(epoch + Math.floor(value) * millisecondsPerDay);
return Number.isNaN(result.getTime()) ? undefined : result;
}
function canonicalDate(cell: ExcelJS.Cell, date1904: boolean, formulaResult = false): string {
const value = scalarValue(cell, formulaResult);
if (value === null || value === '') return '';
let date: Date | undefined;
if (value instanceof Date) {
date = value;
} else if (typeof value === 'number') {
date = excelSerialToDate(value, date1904);
} else if (typeof value === 'string') {
const match = value.trim().match(/^(\d{4})[-/.\u5e74](\d{1,2})[-/.\u6708](\d{1,2})(?:\u65e5)?$/);
if (match) {
date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
if (
date.getUTCFullYear() !== Number(match[1])
|| date.getUTCMonth() !== Number(match[2]) - 1
|| date.getUTCDate() !== Number(match[3])
) {
date = undefined;
}
}
}
if (!date || Number.isNaN(date.getTime())) {
fail('roster_workbook_invalid_date', { row: cellRow(cell), column: cellColumn(cell) });
}
const year = date.getUTCFullYear();
if (year < 1900 || year > 2200) {
fail('roster_workbook_invalid_date', { row: cellRow(cell), column: cellColumn(cell) });
}
return `${String(year).padStart(4, '0')}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`;
}
function canonicalIdentifier(cell: ExcelJS.Cell): string {
const value = scalarValue(cell);
if (value === null || value === '') return '';
if (typeof value === 'number') {
if (!Number.isSafeInteger(value) || value < 0) {
fail('roster_workbook_invalid_identifier', { row: cellRow(cell), column: cellColumn(cell) });
}
return String(value);
}
if (typeof value !== 'string') {
fail('roster_workbook_invalid_identifier', { row: cellRow(cell), column: cellColumn(cell) });
}
return normalizedText(value);
}
function canonicalSequence(cell: ExcelJS.Cell): number {
const value = scalarValue(cell);
let sequence: number;
if (typeof value === 'number') {
sequence = value;
} else if (typeof value === 'string' && /^[1-9]\d*$/.test(value.trim())) {
sequence = Number(value.trim());
} else {
fail('roster_workbook_invalid_sequence', { row: cellRow(cell), column: cellColumn(cell) });
}
if (!Number.isSafeInteger(sequence) || sequence <= 0) {
fail('roster_workbook_invalid_sequence', { row: cellRow(cell), column: cellColumn(cell) });
}
if (sequence > MAX_PASSENGER_SEQUENCE) {
fail('roster_workbook_sequence_limit', { row: cellRow(cell), column: cellColumn(cell) });
}
return sequence;
}
function canonicalTextCell(cell: ExcelJS.Cell): string {
return normalizedText(scalarValue(cell));
}
function dataRows(
sheet: ExcelJS.Worksheet,
headerRow: number,
sourceColumns: SourceColumnMap,
maxDataRows: number
): ExcelJS.Row[] {
const rows: ExcelJS.Row[] = [];
let gapAfterData = false;
const selectedColumns = Object.values(sourceColumns);
for (let rowNumber = headerRow + 1; rowNumber <= sheet.rowCount; rowNumber += 1) {
const row = sheet.getRow(rowNumber);
const hasData = rowHasData(row, selectedColumns);
if (!hasData) {
if (rows.length > 0) gapAfterData = true;
continue;
}
if (gapAfterData) fail('roster_workbook_non_contiguous_rows', { row: rowNumber });
rows.push(row);
if (rows.length > maxDataRows) fail('roster_workbook_row_limit');
}
if (rows.length === 0) fail('roster_workbook_no_data');
return rows;
}
function canonicalRow(
row: ExcelJS.Row,
date1904: boolean,
seenSequences: Set<number>,
sourceColumns: SourceColumnMap
): string[] {
const cell = (field: RequiredSourceField): ExcelJS.Cell => row.getCell(sourceColumns[field]);
const sequence = canonicalSequence(cell('序号'));
if (seenSequences.has(sequence)) {
fail('roster_workbook_duplicate_sequence', {
row: row.number,
column: sourceColumns['序号']
});
}
seenSequences.add(sequence);
const issueDateCell = cell('签发日期');
const expiryCell = cell('有效期');
const englishName = canonicalTextCell(cell('英文姓名')).replace(/,/g, ',');
return [
String(sequence),
canonicalTextCell(cell('姓名')),
englishName,
canonicalTextCell(cell('性别')),
canonicalDate(cell('出生日期'), date1904),
canonicalTextCell(cell('出生地')),
'护照',
canonicalIdentifier(cell('护照号码')),
canonicalTextCell(cell('签发地')),
canonicalDate(issueDateCell, date1904, issueDateCell.type === ExcelJS.ValueType.Formula),
canonicalDate(expiryCell, date1904, expiryCell.type === ExcelJS.ValueType.Formula),
canonicalIdentifier(cell('电话')),
canonicalTextCell(cell('备注'))
];
}
export async function normalizePassengerRosterWorkbook(
input: PassengerRosterWorkbookInput,
options: PassengerRosterWorkbookOptions = {}
): Promise<NormalizedPassengerRosterWorkbook> {
const maxInputBytes = options.maxInputBytes ?? DEFAULT_MAX_INPUT_BYTES;
const maxUncompressedBytes = options.maxUncompressedBytes ?? DEFAULT_MAX_UNCOMPRESSED_BYTES;
const maxArchiveEntries = options.maxArchiveEntries ?? DEFAULT_MAX_ARCHIVE_ENTRIES;
const requestedMaxDataRows = options.maxDataRows ?? DEFAULT_MAX_DATA_ROWS;
const maxDataRows = Math.min(requestedMaxDataRows, ABSOLUTE_MAX_DATA_ROWS);
if (!Number.isSafeInteger(maxInputBytes) || maxInputBytes <= 0) fail('roster_workbook_too_large');
if (!Number.isSafeInteger(maxUncompressedBytes) || maxUncompressedBytes <= 0) fail('roster_workbook_archive_limit');
if (!Number.isSafeInteger(maxArchiveEntries) || maxArchiveEntries <= 0) fail('roster_workbook_archive_limit');
if (!Number.isSafeInteger(requestedMaxDataRows) || requestedMaxDataRows <= 0) fail('roster_workbook_row_limit');
if (input.content.byteLength === 0) fail('roster_workbook_empty');
if (input.content.byteLength > maxInputBytes) fail('roster_workbook_too_large');
const format = sourceFormat(input);
let xlsxContent = input.content;
if (format === 'xls') {
if (legacyWorkbookHasMacroMarkers(input.content)) fail('roster_workbook_unsafe_content');
if (!options.convertLegacyXls) fail('roster_workbook_converter_unavailable');
try {
xlsxContent = await options.convertLegacyXls(input);
} catch (error) {
if (error instanceof PassengerRosterWorkbookError) throw error;
fail('roster_workbook_conversion_failed');
}
if (
!Buffer.isBuffer(xlsxContent)
|| xlsxContent.byteLength === 0
|| xlsxContent.byteLength > maxInputBytes
|| !startsWithMagic(xlsxContent, XLSX_MAGIC)
) {
fail('roster_workbook_conversion_failed');
}
}
await inspectXlsxArchive(xlsxContent, { maxUncompressedBytes, maxArchiveEntries });
const workbook = new ExcelJS.Workbook();
try {
const excelJsBuffer = xlsxContent as unknown as Parameters<typeof workbook.xlsx.load>[0];
await workbook.xlsx.load(excelJsBuffer);
} catch {
fail('roster_workbook_invalid_xlsx');
}
if (workbook.worksheets.length !== 1) fail('roster_workbook_ambiguous_sheet');
const sheet = workbook.worksheets[0];
if (sheet.state !== 'visible') fail('roster_workbook_hidden_data');
const header = findHeaderLayout(sheet);
const headerRow = header.row;
validateFormulasAndHiddenData(sheet, headerRow, header.columns);
const rows = dataRows(sheet, headerRow, header.columns, maxDataRows);
const date1904 = workbook.properties.date1904 === true;
const seenSequences = new Set<number>();
const canonicalRows = rows.map((row) => canonicalRow(row, date1904, seenSequences, header.columns));
return {
canonicalTsv: [CANONICAL_PASSENGER_HEADERS, ...canonicalRows]
.map((row) => row.join('\t'))
.join('\n'),
rowCount: canonicalRows.length,
sourceFormat: format,
worksheetName: sheet.name,
headerRow
};
}