Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

20.0 kB Ruw bestand
import { looksLikeZip, readZip, ZipError } from './zip.js';

/**
 * Turning a real spreadsheet file into a grid of strings.
 *
 * "Export it as CSV first" is a sentence that loses customers. The file people
 * actually have is an `.xlsx` off a colleague's machine or an `.ods` out of
 * LibreOffice, and asking them to convert it before they can find out whether
 * this product works is asking them to do work in order to be sold to.
 *
 * What comes out is deliberately *strings* — the same shape a CSV parses to —
 * so that everything downstream (finding the header, inferring types, coercing
 * values, reporting what was skipped) stays one code path with one set of
 * tests. A workbook reader that had its own type inference would be a second
 * importer to keep honest, and the two would drift.
 *
 * The two formats are the same idea twice: a ZIP of XML where the cells are
 * numbers and a style tells you which of them are dates. That last part is the
 * whole difficulty. Excel stores 2026-08-08 as `46242`, and a reader that
 * ignores the format writes a column of five-digit integers into what the
 * operator can plainly see is a column of dates.
 *
 * Not supported, and said so out loud rather than half-attempted: the pre-2007
 * binary `.xls`, which is a different format entirely — an OLE2 container of
 * BIFF records — and worth a clear message instead of a shaky parser.
 */

export interface Sheet {
  name: string;
  /** Rows of raw cell text, padded so a row's index matches the spreadsheet's. */
  grid: string[][];
}

export interface Workbook {
  format: WorkbookFormat;
  sheets: Sheet[];
}

export type WorkbookFormat = 'xlsx' | 'ods';

export class WorkbookError extends Error {}

/**
 * A ceiling on how tall a sheet may be.
 *
 * Only ever reached by a file nobody meant to import by hand: the per-import
 * row cap is a small fraction of this, so anything near it is a database
 * export that wants a different tool. Refused rather than truncated, because a
 * silent cut here would make the "rows past the limit" count a lie.
 */
const MAX_SHEET_ROWS = 200_000;

/** The widest a row may get, which is also Excel's own column ceiling. */
const MAX_SHEET_COLUMNS = 16_384;

/** A run of empty rows longer than this is padding, not a gap in the data. */
const EMPTY_ROW_RUN = 1_000;

/** Whether a file name is one of the workbook formats, rather than delimited text. */
export function looksLikeWorkbook(filename: string): boolean {
  return /\.(xlsx|xlsm|xltx|ods|fods|xls|numbers)$/i.test(filename.trim());
}

/**
 * Read a workbook, or say precisely why not.
 *
 * The format is decided by the bytes rather than by the extension, because a
 * file called `report.xlsx` that a system actually wrote as CSV is common
 * enough to be worth getting right — and because the reverse, a real workbook
 * saved as `.txt`, would otherwise import as one giant column.
 */
export function readWorkbook(bytes: Buffer): Workbook {
  if (isOle2(bytes)) {
    throw new WorkbookError(
      'That is the pre-2007 Excel format (.xls). Open it and save as .xlsx, .ods or CSV, and it will import.',
    );
  }
  if (!looksLikeZip(bytes)) {
    throw new WorkbookError('That file is not a spreadsheet this can read.');
  }

  let files: Map<string, Buffer>;
  try {
    files = readZip(bytes);
  } catch (error) {
    throw new WorkbookError(error instanceof ZipError ? error.message : 'That file could not be unpacked.');
  }

  if (files.has('xl/workbook.xml')) return { format: 'xlsx', sheets: readXlsx(files) };
  if (files.has('content.xml')) return { format: 'ods', sheets: readOds(files) };

  // A ZIP that is neither is most often a `.numbers` file or a folder of CSVs
  // someone compressed, and both deserve to be told apart from a broken file.
  throw new WorkbookError('That archive holds no spreadsheet this can read.');
}

/** The OLE2 compound-file signature every `.xls` and old `.doc` opens with. */
function isOle2(bytes: Buffer): boolean {
  return bytes.length > 8 && bytes.readUInt32BE(0) === 0xd0cf11e0 && bytes.readUInt32BE(4) === 0xa1b11ae1;
}

// ---- XLSX -----------------------------------------------------------------

function readXlsx(files: Map<string, Buffer>): Sheet[] {
  const strings = readSharedStrings(files.get('xl/sharedStrings.xml'));
  const dateStyles = readDateStyles(files.get('xl/styles.xml'));
  const rels = readRelationships(files.get('xl/_rels/workbook.xml.rels'));

  const sheets: Sheet[] = [];
  for (const entry of listXlsxSheets(files.get('xl/workbook.xml')!, rels)) {
    const xml = files.get(entry.path);
    if (!xml) continue;
    sheets.push({ name: entry.name, grid: readXlsxSheet(xml.toString('utf8'), strings, dateStyles) });
  }

  if (sheets.length === 0) throw new WorkbookError('That workbook has no sheets in it.');
  return sheets;
}

/**
 * The sheets in the order the tabs are in.
 *
 * Order matters more than it looks: "the first sheet" is what an import
 * defaults to, and a workbook whose sheets were read in ZIP order would open
 * on whichever one happened to be written first.
 */
function listXlsxSheets(
  workbook: Buffer,
  rels: Map<string, string>,
): { name: string; path: string }[] {
  const found: { name: string; path: string }[] = [];

  for (const tag of tagsNamed(workbook.toString('utf8'), 'sheet')) {
    const id = tag.attrs['r:id'] ?? tag.attrs['relationship:id'];
    const target = id ? rels.get(id) : undefined;
    const path = target ? resolveTarget(target) : `xl/worksheets/sheet${found.length + 1}.xml`;
    found.push({ name: decodeXml(tag.attrs.name ?? `Sheet ${found.length + 1}`), path });
  }
  return found;
}

/** A relationship target, as a path from the archive root. */
function resolveTarget(target: string): string {
  const clean = target.replace(/^\.\//, '');
  return clean.startsWith('/') ? clean.slice(1) : `xl/${clean}`;
}

function readRelationships(xml: Buffer | undefined): Map<string, string> {
  const rels = new Map<string, string>();
  if (!xml) return rels;
  for (const tag of tagsNamed(xml.toString('utf8'), 'Relationship')) {
    if (tag.attrs.Id && tag.attrs.Target) rels.set(tag.attrs.Id, decodeXml(tag.attrs.Target));
  }
  return rels;
}

/**
 * The shared string table, which is where nearly every text cell lives.
 *
 * A string can be split into runs — one for each stretch of different
 * formatting — so the text is the concatenation of the `<t>` elements rather
 * than the first of them. Phonetic guides (`<rPh>`, on Japanese workbooks)
 * carry their own `<t>` and are not part of the value.
 */
function readSharedStrings(xml: Buffer | undefined): string[] {
  if (!xml) return [];
  const body = xml.toString('utf8');
  const strings: string[] = [];

  for (const item of body.split('</si>').slice(0, -1)) {
    const text = item.replace(/<rPh[\s\S]*?<\/rPh>/g, '');
    let value = '';
    for (const match of text.matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>|<t\s*\/>/g)) {
      value += decodeXml(match[1] ?? '');
    }
    strings.push(value);
  }
  return strings;
}

/**
 * Which style indexes mean "this number is a date".
 *
 * A cell points at a style, the style points at a number format, and only the
 * format knows that 46242 is a day rather than a quantity. Both halves matter:
 * the built-in formats (ids under 164, defined by the spec and never written
 * into the file) and the custom ones, which are recognised by having date
 * fields in their code.
 */
function readDateStyles(xml: Buffer | undefined): Set<number> {
  const dates = new Set<number>();
  if (!xml) return dates;
  const body = xml.toString('utf8');

  const custom = new Map<number, string>();
  for (const tag of tagsNamed(body, 'numFmt')) {
    const id = Number(tag.attrs.numFmtId);
    if (Number.isInteger(id)) custom.set(id, decodeXml(tag.attrs.formatCode ?? ''));
  }

  // Only the `cellXfs` block is what a cell's `s` attribute indexes into;
  // `cellStyleXfs` above it looks identical and would shift every index.
  const cellXfs = between(body, '<cellXfs', '</cellXfs>');
  if (!cellXfs) return dates;

  let index = 0;
  for (const tag of tagsNamed(cellXfs, 'xf')) {
    const id = Number(tag.attrs.numFmtId ?? 0);
    if (isDateFormat(id, custom.get(id))) dates.add(index);
    index++;
  }
  return dates;
}

/** The built-in number formats that are dates, times or both. */
const BUILTIN_DATE_FORMATS = new Set([14, 15, 16, 17, 18, 19, 20, 21, 22, 45, 46, 47]);

function isDateFormat(id: number, code: string | undefined): boolean {
  if (BUILTIN_DATE_FORMATS.has(id)) return true;
  if (!code) return false;

  // A custom code is a date when it has date fields outside of the parts that
  // are not fields: quoted literals, colour and condition brackets, and the
  // escapes. `#,##0.00 "m"` is money and must not count as minutes.
  const fields = code
    .replace(/\\./g, '')
    .replace(/"[^"]*"/g, '')
    .replace(/\[[^\]]*\]/g, '');
  return /[ymdhs]/i.test(fields);
}

function readXlsxSheet(xml: string, strings: string[], dateStyles: Set<number>): string[][] {
  const grid: string[][] = [];
  let row: string[] = [];
  let rowNumber = 0;
  let column = 0;

  const scanner = /<row(\s[^>]*?)?(\/?)>|<\/row>|<c(\s[^>]*?)?\/>|<c(\s[^>]*?)?>([\s\S]*?)<\/c>/g;

  for (let match = scanner.exec(xml); match; match = scanner.exec(xml)) {
    const token = match[0];

    if (token === '</row>') {
      place(grid, rowNumber, row);
      row = [];
      continue;
    }

    if (token.startsWith('<row')) {
      // Rows carry their spreadsheet number, and a run of empty ones is simply
      // left out of the file. Honouring `r` keeps "row 7" here and "row 7" in
      // Excel the same row, which is the whole basis of the header picker.
      const attrs = parseAttrs(match[1] ?? '');
      rowNumber = Number(attrs.r) || rowNumber + 1;
      column = 0;
      if (match[2] === '/') {
        place(grid, rowNumber, []);
        row = [];
      }
      continue;
    }

    const attrs = parseAttrs(match[3] ?? match[4] ?? '');
    const at = attrs.r ? columnOf(attrs.r) : column;
    column = at + 1;
    if (at >= MAX_SHEET_COLUMNS) continue;

    const value = cellText(attrs, match[5] ?? '', strings, dateStyles);
    if (value !== '') {
      while (row.length < at) row.push('');
      row[at] = value;
    }
  }

  if (row.length > 0) place(grid, rowNumber, row);
  return grid;
}

/** Put a row at its spreadsheet position, filling in the empty rows before it. */
function place(grid: string[][], rowNumber: number, row: string[]): void {
  if (rowNumber < 1) return;
  if (rowNumber > MAX_SHEET_ROWS) throw tooTall();
  while (grid.length < rowNumber - 1) grid.push([]);
  grid[rowNumber - 1] = row;
}

function tooTall(): WorkbookError {
  return new WorkbookError(
    `That sheet is over ${MAX_SHEET_ROWS.toLocaleString('en-US')} rows, which is more than an import can take.`,
  );
}

/** What one cell reads as, once its type and number format are taken into account. */
function cellText(
  attrs: Record<string, string>,
  inner: string,
  strings: string[],
  dateStyles: Set<number>,
): string {
  const type = attrs.t ?? 'n';

  if (type === 'inlineStr') {
    let value = '';
    for (const match of inner.matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g)) value += decodeXml(match[1] ?? '');
    return value;
  }

  // `<f>` holds the formula; `<v>` holds what it last worked out to, which is
  // the only part an import can use. A workbook saved without cached values
  // has no answer to give, and an empty cell is the honest result.
  const raw = decodeXml(between(inner, '<v', '</v>')?.replace(/^[^>]*>/, '') ?? '');
  if (raw === '') return '';

  if (type === 's') return strings[Number(raw)] ?? '';
  if (type === 'str' || type === 'e') return raw;
  if (type === 'b') return raw === '1' ? 'true' : 'false';

  const style = Number(attrs.s ?? -1);
  if (dateStyles.has(style)) {
    const asDate = fromSerial(Number(raw));
    if (asDate) return asDate;
  }
  return numberText(raw);
}

/**
 * A number as a person would have typed it.
 *
 * Round-tripped through `Number` so that the seventeen digits a spreadsheet
 * writes for 0.1 + 0.2 come back as `0.3`, and left alone when it is not a
 * number at all.
 */
function numberText(raw: string): string {
  const value = Number(raw);
  return Number.isFinite(value) ? String(value) : raw;
}

/**
 * A date serial as an ISO date.
 *
 * Two quirks, both Excel's. Day 0 is 1899-12-30 rather than the 31st, because
 * the format deliberately reproduces a Lotus 1-2-3 bug: 1900 is treated as a
 * leap year, so every serial from 61 onwards is one day past the truth and the
 * epoch is shifted back to cancel it. Serials under 60 predate the phantom
 * 29th of February and need the shift undone again.
 *
 * A fraction is a time of day, and comes back joined to the date rather than
 * silently dropped — the column will import as text, which is the truthful
 * outcome for a value this product has no type for.
 */
export function fromSerial(serial: number): string | null {
  if (!Number.isFinite(serial) || serial < 0 || serial > 2_958_465) return null;

  const whole = Math.floor(serial);
  const seconds = Math.round((serial - whole) * 86_400);
  const days = whole < 60 ? whole + 1 : whole;
  const at = new Date((days - 25_569) * 86_400_000 + seconds * 1_000);
  if (Number.isNaN(at.getTime())) return null;

  const date = at.toISOString().slice(0, 10);
  if (seconds === 0) return date;

  const time = at.toISOString().slice(11, 19).replace(/:00$/, '');
  // A serial under 1 is a time with no date attached — a duration, or a clock
  // reading. Dating it to 1899 would be an invention.
  return whole === 0 ? time : `${date} ${time}`;
}

/** The zero-based column a cell reference names: `A1` is 0, `AB7` is 27. */
export function columnOf(reference: string): number {
  let index = 0;
  for (const char of reference) {
    const letter = char.toUpperCase();
    if (letter < 'A' || letter > 'Z') break;
    index = index * 26 + (letter.charCodeAt(0) - 64);
  }
  return Math.max(0, index - 1);
}

// ---- ODS ------------------------------------------------------------------

/**
 * OpenDocument, which is the same problem with better manners.
 *
 * Every cell states its own type and carries the machine-readable value in an
 * attribute beside the displayed text, so none of the style archaeology above
 * is needed. What it has instead is *repetition*: a row of a hundred identical
 * cells is written once with a count, and so is the empty remainder of every
 * sheet — which is why the trailing repeats have to be dropped rather than
 * expanded into sixteen thousand empty strings.
 */
function readOds(files: Map<string, Buffer>): Sheet[] {
  const content = files.get('content.xml')!.toString('utf8');
  const sheets: Sheet[] = [];

  const scanner = /<table:table(?:\s([^>]*?))?>([\s\S]*?)<\/table:table>/g;
  for (let match = scanner.exec(content); match; match = scanner.exec(content)) {
    const attrs = parseAttrs(match[1] ?? '');
    sheets.push({
      name: decodeXml(attrs['table:name'] ?? `Sheet ${sheets.length + 1}`),
      grid: readOdsRows(match[2] ?? ''),
    });
  }

  if (sheets.length === 0) throw new WorkbookError('That workbook has no sheets in it.');
  return sheets;
}

function readOdsRows(xml: string): string[][] {
  const grid: string[][] = [];
  // The close marker in the lookahead needs its `>`: without it, the row would
  // also end at the first `</table:table-cell>` and every row would read empty.
  const scanner = /<table:table-row(?:\s([^>]*?))?(\/)?>([\s\S]*?)(?=<table:table-row|<\/table:table>|$)/g;

  for (let match = scanner.exec(xml); match; match = scanner.exec(xml)) {
    const attrs = parseAttrs(match[1] ?? '');
    const repeat = countOf(attrs['table:number-rows-repeated'], MAX_SHEET_ROWS);
    const row = match[2] ? [] : readOdsCells(match[3] ?? '');

    // Every ODS file ends by declaring the million empty rows below the data,
    // and there is no point materialising those. A short run of empty rows is
    // a gap inside the sheet, and it is kept: dropping it would shift every
    // row number after it away from the one the operator sees in their editor.
    const times = row.length > 0 || repeat <= EMPTY_ROW_RUN ? repeat : 1;
    for (let n = 0; n < times; n++) {
      if (grid.length >= MAX_SHEET_ROWS) throw tooTall();
      grid.push(row.length === 0 ? [] : [...row]);
    }
  }

  while (grid.length > 0 && grid[grid.length - 1]!.length === 0) grid.pop();
  return grid;
}

function readOdsCells(xml: string): string[] {
  const row: string[] = [];
  const scanner =
    /<table:(?:covered-)?table-cell(?:\s([^>]*?))?\/>|<table:(?:covered-)?table-cell(?:\s([^>]*?))?>([\s\S]*?)<\/table:(?:covered-)?table-cell>/g;

  for (let match = scanner.exec(xml); match; match = scanner.exec(xml)) {
    const attrs = parseAttrs(match[1] ?? match[2] ?? '');
    const repeat = countOf(attrs['table:number-columns-repeated'], MAX_SHEET_COLUMNS);
    const value = odsCellText(attrs, match[3] ?? '');

    // Empty repeats are expanded like any other: a gap of three between two
    // filled cells is written exactly the way the trailing padding is, and
    // collapsing it would slide every column after it one place left. The
    // padding costs nothing here because the trailing run is trimmed below.
    for (let n = 0; n < repeat && row.length < MAX_SHEET_COLUMNS; n++) row.push(value);
  }

  while (row.length > 0 && row[row.length - 1] === '') row.pop();
  return row;
}

function odsCellText(attrs: Record<string, string>, inner: string): string {
  const type = attrs['office:value-type'];

  if (type === 'float' || type === 'percentage' || type === 'currency') {
    return numberText(decodeXml(attrs['office:value'] ?? ''));
  }
  if (type === 'date') return decodeXml(attrs['office:date-value'] ?? '').replace('T00:00:00', '');
  if (type === 'time') return decodeXml(attrs['office:time-value'] ?? '');
  if (type === 'boolean') return decodeXml(attrs['office:boolean-value'] ?? '');

  // Text, and anything unlabelled: the displayed paragraphs, which a
  // multi-line cell writes as several. `<text:s/>` is a run of spaces.
  const lines: string[] = [];
  for (const match of inner.matchAll(/<text:p(?:\s[^>]*)?>([\s\S]*?)<\/text:p>/g)) {
    lines.push(decodeXml(stripTags(match[1] ?? '')));
  }
  return lines.join('\n').trim();
}

/** A repeat count, defaulting to one and never past the ceiling it is bounded by. */
function countOf(raw: string | undefined, ceiling: number): number {
  const value = Number(raw ?? 1);
  return Number.isInteger(value) && value > 0 ? Math.min(value, ceiling) : 1;
}

// ---- XML ------------------------------------------------------------------

interface Tag {
  attrs: Record<string, string>;
}

/** Every occurrence of one element, self-closing or not, with its attributes. */
function* tagsNamed(xml: string, name: string): Generator<Tag> {
  const scanner = new RegExp(`<${name}(?:\\s([^>]*?))?\\s*/?>`, 'g');
  for (let match = scanner.exec(xml); match; match = scanner.exec(xml)) {
    yield { attrs: parseAttrs(match[1] ?? '') };
  }
}

function parseAttrs(source: string): Record<string, string> {
  const attrs: Record<string, string> = {};
  for (const match of source.matchAll(/([\w:.-]+)\s*=\s*"([^"]*)"|([\w:.-]+)\s*=\s*'([^']*)'/g)) {
    attrs[match[1] ?? match[3]!] = match[2] ?? match[4] ?? '';
  }
  return attrs;
}

/** The slice between an opening marker and its close, or nothing. */
function between(xml: string, open: string, close: string): string | null {
  const start = xml.indexOf(open);
  if (start < 0) return null;
  const end = xml.indexOf(close, start);
  return end < 0 ? null : xml.slice(start, end);
}

function stripTags(xml: string): string {
  return xml.replace(/<text:s(?:\s[^>]*)?\/>/g, ' ').replace(/<[^>]*>/g, '');
}

const ENTITIES: Record<string, string> = {
  amp: '&',
  lt: '<',
  gt: '>',
  quot: '"',
  apos: "'",
};

function decodeXml(text: string): string {
  if (!text.includes('&')) return text;
  return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, body: string) => {
    if (body.startsWith('#')) {
      const code = body[1] === 'x' || body[1] === 'X' ? parseInt(body.slice(2), 16) : Number(body.slice(1));
      return Number.isInteger(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
    }
    return ENTITIES[body] ?? whole;
  });
}