From b54e6d7f45f1c1e84478b5d9cb34f7b7dc328240 Mon Sep 17 00:00:00 2001 From: Eduardo Russo Date: Sat, 17 Oct 2026 00:37:08 +0100 Subject: [PATCH] feat(office-reader,core): read OpenDocument spreadsheets (.ods) like workbooks (0.7.0) readOds gives the same Workbook readXlsx does: typed values, merges, hidden rows and sheets. Core recognises .ods by extension, content type and the mimetype the file declares, so recipes, Studio and exports work on it unchanged. .odt and .odp are recognised too and refused with what to do. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TjR6eM2tUwU3NGnKXsmEap --- apps/studio-ui/src/app/toolbar.component.tsx | 2 +- .../unsupported-format.policy.test.ts | 8 +- .../recipe-start/unsupported-format.policy.ts | 1 - docs/recipes/authoring.md | 2 +- docs/requirements.md | 6 +- .../http-session/body-sniff.algorithm.test.ts | 8 +- .../src/http-session/body-sniff.algorithm.ts | 4 + .../core/src/http-session/http.client.test.ts | 3 + packages/core/src/http-session/http.client.ts | 5 +- packages/core/src/opendocument-sniff/index.ts | 1 + .../opendocument-mimetype.algorithm.test.ts | 34 +++ .../opendocument-mimetype.algorithm.ts | 54 +++++ .../read-xlsx.client.test.ts | 11 + .../src/workbook-document/read-xlsx.client.ts | 36 ++- packages/office-reader/README.md | 10 +- packages/office-reader/package.json | 5 + packages/office-reader/rollup.config.cjs | 1 + packages/office-reader/src/index.ts | 2 + .../src/ooxml-package/ooxml-package.client.ts | 25 +++ .../fixtures/incentivi.ods | Bin 0 -> 1834 bytes .../src/opendocument-spreadsheet/index.ts | 4 + .../ods-cell.algorithm.ts | 94 ++++++++ .../ods-sheets.mapper.ts | 211 ++++++++++++++++++ .../read-ods.use-case.test.ts | 190 ++++++++++++++++ .../read-ods.use-case.ts | 37 +++ .../src/read-error/office-read.error.ts | 4 +- 26 files changed, 740 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/opendocument-sniff/index.ts create mode 100644 packages/core/src/opendocument-sniff/opendocument-mimetype.algorithm.test.ts create mode 100644 packages/core/src/opendocument-sniff/opendocument-mimetype.algorithm.ts create mode 100644 packages/office-reader/src/opendocument-spreadsheet/fixtures/incentivi.ods create mode 100644 packages/office-reader/src/opendocument-spreadsheet/index.ts create mode 100644 packages/office-reader/src/opendocument-spreadsheet/ods-cell.algorithm.ts create mode 100644 packages/office-reader/src/opendocument-spreadsheet/ods-sheets.mapper.ts create mode 100644 packages/office-reader/src/opendocument-spreadsheet/read-ods.use-case.test.ts create mode 100644 packages/office-reader/src/opendocument-spreadsheet/read-ods.use-case.ts diff --git a/apps/studio-ui/src/app/toolbar.component.tsx b/apps/studio-ui/src/app/toolbar.component.tsx index e098f766..0c4c0fd7 100644 --- a/apps/studio-ui/src/app/toolbar.component.tsx +++ b/apps/studio-ui/src/app/toolbar.component.tsx @@ -10,7 +10,7 @@ import { RecipeMenu } from './recipe-menu.component' import { SaveStatus } from './save-status.component' /** What "Load ▾ Document…" offers: every format the engine reads off disk (core's `formatOfFile`), as the file picker's filter. */ -const DOCUMENT_EXTENSIONS = '.pdf,.xlsx,.xlsm,.xls,.csv,.tsv,.pptx,.docx,.md,.markdown,.html,.htm,.json,.jsonl,.yaml,.yml,.xml' +const DOCUMENT_EXTENSIONS = '.pdf,.xlsx,.xlsm,.xls,.ods,.csv,.tsv,.pptx,.docx,.md,.markdown,.html,.htm,.json,.jsonl,.yaml,.yml,.xml' export interface ToolbarProps { folder: string diff --git a/apps/studio-ui/src/recipe-start/unsupported-format.policy.test.ts b/apps/studio-ui/src/recipe-start/unsupported-format.policy.test.ts index 0f57a80a..1be506ca 100644 --- a/apps/studio-ui/src/recipe-start/unsupported-format.policy.test.ts +++ b/apps/studio-ui/src/recipe-start/unsupported-format.policy.test.ts @@ -1,8 +1,12 @@ import { unsupportedFormatMessage } from './unsupported-format.policy' describe('unsupportedFormatMessage', () => { - it('says what an OpenDocument spreadsheet is and what to do instead', () => { - expect(unsupportedFormatMessage('LiveTable254.ods')).toBe('“LiveTable254.ods” is an OpenDocument spreadsheet, which the Studio cannot read yet. Open it in Excel or LibreOffice, save it as .xlsx or .csv, and load that file.') + it('says what an OpenDocument presentation is and what to do instead', () => { + expect(unsupportedFormatMessage('Talk.odp')).toBe('“Talk.odp” is an OpenDocument presentation, which the Studio cannot read yet. Open it in PowerPoint or LibreOffice, save it as .pptx or .pdf, and load that file.') + }) + + it('does not refuse an OpenDocument spreadsheet: the Studio reads it', () => { + expect(unsupportedFormatMessage('LiveTable254.ods')).toBeUndefined() }) it('reads the type off an address, ignoring its query and the case', () => { diff --git a/apps/studio-ui/src/recipe-start/unsupported-format.policy.ts b/apps/studio-ui/src/recipe-start/unsupported-format.policy.ts index ca678ab9..f7f77bbd 100644 --- a/apps/studio-ui/src/recipe-start/unsupported-format.policy.ts +++ b/apps/studio-ui/src/recipe-start/unsupported-format.policy.ts @@ -1,6 +1,5 @@ /** File types the Studio cannot read yet, with what each one is called in plain words and what to do about it. */ const UNSUPPORTED: Record = { - 'ods': { what: 'an OpenDocument spreadsheet', instead: 'Open it in Excel or LibreOffice, save it as .xlsx or .csv, and load that file.' }, 'odt': { what: 'an OpenDocument text document', instead: 'Open it in Word or LibreOffice, save it as .docx or .pdf, and load that file.' }, 'odp': { what: 'an OpenDocument presentation', instead: 'Open it in PowerPoint or LibreOffice, save it as .pptx or .pdf, and load that file.' }, 'rtf': { what: 'a Rich Text document', instead: 'Open it in Word, save it as .docx or .pdf, and load that file.' }, diff --git a/docs/recipes/authoring.md b/docs/recipes/authoring.md index 0ff898a8..8da6f9ca 100644 --- a/docs/recipes/authoring.md +++ b/docs/recipes/authoring.md @@ -735,7 +735,7 @@ local `file:…xlsx` / `…xlsm`) reads every worksheet with `2026-06-01T09:30:00`), errors their text (`#DIV/0!`), and empty cells `''`. Formulas give their cached value; nothing is evaluated, and display formats are not applied (a percentage stays `0.125`). Hidden sheets and rows are read, and `table` skips them unless `includeHidden`. Merged ranges are recorded, and `table` -fills them. A legacy `.xls`, a password-protected file or an `.ods` fails the step, with what to do. +fills them. An OpenDocument spreadsheet (`.ods`) reads exactly like an `.xlsx`, by its extension, its content type or the `mimetype` the file declares. A legacy `.xls` or a password-protected file fails the step, with what to do. **CSV.** `request` with `as: "csv"` (or a response served as `text/csv`, `application/csv` or `text/tab-separated-values`, or a local `file:…csv` / `…tsv`) reads the file into a **workbook**: a CSV is a diff --git a/docs/requirements.md b/docs/requirements.md index 247cfed3..38e34bd4 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -213,10 +213,10 @@ end, and returns `{ sheet, title, header, rows }`; merged ranges are filled, `he several rows, `fillDown` fills blank cells from the row above (PDF tables too). Text bodies of every kind are decoded the same way. -A spreadsheet (`as: 'xlsx'`, a spreadsheet content type, `.xlsx`/`.xlsm`) is read by `@opencraw/office-reader` +A spreadsheet (`as: 'xlsx'`, a spreadsheet content type, `.xlsx`/`.xlsm`, `.ods`) is read by `@opencraw/office-reader` into the same workbook shape: numbers and booleans typed, dates ISO text, errors their text, formulas their -cached value, with hidden sheets and rows and merged ranges. `.xls`, encrypted files and `.ods` fail with what -to do. `@opencraw/office-reader` is a standalone package: `readXlsx(source, { sheets, values, limits })` from +cached value, with hidden sheets and rows and merged ranges. An OpenDocument spreadsheet (`.ods`) reads the same way, +through `readOds`. `.xls` and encrypted files fail with what to do. `@opencraw/office-reader` is a standalone package: `readXlsx(source, { sheets, values, limits })` from a path, bytes, a Blob or a stream, in Node or a browser; zip entries capped by declared size, XML entities never expanded. diff --git a/packages/core/src/http-session/body-sniff.algorithm.test.ts b/packages/core/src/http-session/body-sniff.algorithm.test.ts index b089615f..528fa478 100644 --- a/packages/core/src/http-session/body-sniff.algorithm.test.ts +++ b/packages/core/src/http-session/body-sniff.algorithm.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { gzipSync } from 'node:zlib' +import { strToU8, zipSync } from 'fflate' import { sniffFormat, zipPartNames } from './body-sniff.algorithm' const officeFixture = (folder: string, name: string): Uint8Array => new Uint8Array(readFileSync(join(__dirname, '..', '..', '..', 'office-reader', 'src', folder, 'fixtures', name))) @@ -27,8 +28,13 @@ describe('sniffFormat', () => { expect(sniffFormat(pptx.subarray(0, -30), '/file')).toBe('pptx') }) + it('knows an OpenDocument file by the media type its first entry declares', () => { + expect(sniffFormat(officeFixture('opendocument-spreadsheet', 'incentivi.ods'), '/file')).toBe('xlsx') + }) + it('leaves a zip that is not an Office package, plain text and nothing to the extension', () => { - expect(sniffFormat(officeFixture('ooxml-package', 'sheet.ods'), '/sheet')).toBeUndefined() + const other = zipSync({ 'readme.txt': strToU8('hello') }) + expect(sniffFormat(other, '/archive')).toBeUndefined() expect(sniffFormat(new TextEncoder().encode('Marca;Modello\nFiat;Pandina\n'), '/listino')).toBeUndefined() expect(sniffFormat(new Uint8Array(), '/empty')).toBeUndefined() }) diff --git a/packages/core/src/http-session/body-sniff.algorithm.ts b/packages/core/src/http-session/body-sniff.algorithm.ts index 18b69c34..460fcd46 100644 --- a/packages/core/src/http-session/body-sniff.algorithm.ts +++ b/packages/core/src/http-session/body-sniff.algorithm.ts @@ -1,3 +1,5 @@ +import { openDocumentFormat, openDocumentMimetype } from '../opendocument-sniff' + /** A format a body's first bytes can prove. */ export type SniffedFormat = 'pdf' | 'xlsx' | 'pptx' | 'docx' | 'xml' @@ -30,6 +32,8 @@ const OOXML_FOLDERS: readonly (readonly [string, SniffedFormat])[] = [['xl/', 'x export function sniffFormat (bytes: Uint8Array, path: string): SniffedFormat | undefined { if (startsPdf(bytes)) return 'pdf' if (bytes.length >= 4 && uint32(bytes, 0) === ZIP_LOCAL_HEADER) { + const opened = openDocumentMimetype(bytes) + if (opened !== undefined) return openDocumentFormat(opened) const names = zipPartNames(bytes) return OOXML_FOLDERS.find(([folder]) => names.some(name => name.startsWith(folder)))?.[1] diff --git a/packages/core/src/http-session/http.client.test.ts b/packages/core/src/http-session/http.client.test.ts index 2d296a5e..644f1801 100644 --- a/packages/core/src/http-session/http.client.test.ts +++ b/packages/core/src/http-session/http.client.test.ts @@ -237,6 +237,9 @@ describe('HttpClient', () => { expect(served.body).toMatchObject({ kind: 'workbook', sheets: [{ name: 'Incentivi giugno', hiddenRows: [6] }, { name: 'Archivio', hidden: true }, { name: 'Maggio' }] }) const local = await client.send({ url: pathToFileURL(join(__dirname, '..', '..', '..', 'office-reader', 'src', 'spreadsheet', 'fixtures', 'incentivi.xlsx')).href }) expect(local.body.kind === 'workbook' && local.body.sheets[0].rows[4]).toEqual(['Fiat', 'Pandina', 15_950, 13_955.625, 0.125, '2026-06-01', true, 'Solo rottamazione']) + const ods = await client.send({ url: pathToFileURL(join(__dirname, '..', '..', '..', 'office-reader', 'src', 'opendocument-spreadsheet', 'fixtures', 'incentivi.ods')).href }) + expect(ods.body.kind === 'workbook' && ods.body.sheets[0].rows[4]).toEqual(['Fiat', 'Pandina', 15_950, 13_955.625, 0.125, '2026-06-01', true, 'Solo rottamazione']) + expect(ods.body.kind === 'workbook' && ods.body.sheets.map(sheet => [sheet.name, sheet.hidden])).toEqual([['Incentivi giugno', false], ['Archivio', true], ['Maggio', false]]) const directory = await mkdtemp(join(tmpdir(), 'http-client-')) await writeFile(join(directory, 'old.xls'), new Uint8Array([0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1])) const legacy = pathToFileURL(join(directory, 'old.xls')).href diff --git a/packages/core/src/http-session/http.client.ts b/packages/core/src/http-session/http.client.ts index 71b89faf..21988127 100644 --- a/packages/core/src/http-session/http.client.ts +++ b/packages/core/src/http-session/http.client.ts @@ -7,6 +7,7 @@ import { readPptxDeck } from '../deck-document' import type { HostAllowlist } from '../host-allowlist' import { readDocxHtml } from '../docx-document' import { readMarkdown } from '../markdown-document' +import { openDocumentFormat } from '../opendocument-sniff' import { readPdf } from '../pdf-document' import type { BodyKind } from '../recipe-schema' import { csvWorkbook, readXlsxWorkbook, sheetNameOf } from '../workbook-document' @@ -266,6 +267,8 @@ function formatFromContentType (type: string, url: string): BodyKind { if (CSV_TYPES.has(type)) return 'csv' if (JSON_LINES_TYPES.has(type)) return 'jsonl' // A legacy .xls or .ppt goes to the Office reader too, which says what to do with it. + const opendocument = openDocumentFormat(type) + if (opendocument !== undefined) return opendocument if (type.includes('spreadsheetml') || type.startsWith('application/vnd.ms-excel')) return 'xlsx' if (type.includes('presentationml') || type.startsWith('application/vnd.ms-powerpoint')) return 'pptx' if (type === 'application/msword' || type.includes('wordprocessingml') || type.startsWith('application/vnd.ms-word')) return 'docx' @@ -289,7 +292,7 @@ const CSV_TYPES = new Set(['text/csv', 'application/csv', 'text/x-csv', 'applica function formatFromExtension (path: string): BodyKind | undefined { if (/\.xml\.gz$/i.test(path)) return 'xml' const extension = extname(path) - const formats: Record = { '.json': 'json', '.jsonl': 'jsonl', '.ndjson': 'jsonl', '.pdf': 'pdf', '.csv': 'csv', '.tsv': 'csv', '.xlsx': 'xlsx', '.xlsm': 'xlsx', '.xls': 'xlsx', '.pptx': 'pptx', '.pptm': 'pptx', '.ppsx': 'pptx', '.ppt': 'pptx', '.docx': 'docx', '.docm': 'docx', '.dotx': 'docx', '.doc': 'docx', '.yaml': 'yaml', '.yml': 'yaml', '.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html', '.xml': 'xml', '.rss': 'xml', '.atom': 'xml', '.kml': 'xml', '.gpx': 'xml' } + const formats: Record = { '.json': 'json', '.jsonl': 'jsonl', '.ndjson': 'jsonl', '.pdf': 'pdf', '.csv': 'csv', '.tsv': 'csv', '.xlsx': 'xlsx', '.xlsm': 'xlsx', '.xls': 'xlsx', '.ods': 'xlsx', '.odt': 'docx', '.odp': 'pptx', '.pptx': 'pptx', '.pptm': 'pptx', '.ppsx': 'pptx', '.ppt': 'pptx', '.docx': 'docx', '.docm': 'docx', '.dotx': 'docx', '.doc': 'docx', '.yaml': 'yaml', '.yml': 'yaml', '.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html', '.xml': 'xml', '.rss': 'xml', '.atom': 'xml', '.kml': 'xml', '.gpx': 'xml' } return formats[extension.toLowerCase()] } diff --git a/packages/core/src/opendocument-sniff/index.ts b/packages/core/src/opendocument-sniff/index.ts new file mode 100644 index 00000000..a3d51f68 --- /dev/null +++ b/packages/core/src/opendocument-sniff/index.ts @@ -0,0 +1 @@ +export { openDocumentFormat, openDocumentMimetype } from './opendocument-mimetype.algorithm' diff --git a/packages/core/src/opendocument-sniff/opendocument-mimetype.algorithm.test.ts b/packages/core/src/opendocument-sniff/opendocument-mimetype.algorithm.test.ts new file mode 100644 index 00000000..172bffa0 --- /dev/null +++ b/packages/core/src/opendocument-sniff/opendocument-mimetype.algorithm.test.ts @@ -0,0 +1,34 @@ +import { strToU8, zipSync } from 'fflate' +import { openDocumentFormat, openDocumentMimetype } from './opendocument-mimetype.algorithm' + +function odf (mimetype: string): Uint8Array { + return zipSync({ 'mimetype': [strToU8(mimetype), { level: 0 }], 'content.xml': strToU8('') }) +} + +describe('openDocumentMimetype', () => { + it('reads the media type a stored first entry declares', () => { + expect(openDocumentMimetype(odf('application/vnd.oasis.opendocument.spreadsheet'))).toBe('application/vnd.oasis.opendocument.spreadsheet') + expect(openDocumentMimetype(odf('application/vnd.oasis.opendocument.text'))).toBe('application/vnd.oasis.opendocument.text') + }) + + it('says nothing about an Office Open XML package, another zip, or bytes that are not a zip', () => { + const ooxml = zipSync({ '[Content_Types].xml': strToU8(''), 'xl/workbook.xml': strToU8('') }) + const compressed = zipSync({ mimetype: strToU8('application/vnd.oasis.opendocument.spreadsheet'.repeat(5)) }) + + expect(openDocumentMimetype(ooxml)).toBeUndefined() + expect(openDocumentMimetype(compressed)).toBeUndefined() + expect(openDocumentMimetype(odf('text/plain'))).toBeUndefined() + expect(openDocumentMimetype(strToU8('a,b\n1,2\n'))).toBeUndefined() + expect(openDocumentMimetype(new Uint8Array())).toBeUndefined() + }) +}) + +describe('openDocumentFormat', () => { + it('maps a spreadsheet to a workbook, text to a document and a presentation to a deck', () => { + expect(openDocumentFormat('application/vnd.oasis.opendocument.spreadsheet')).toBe('xlsx') + expect(openDocumentFormat('application/vnd.oasis.opendocument.text; charset=binary')).toBe('docx') + expect(openDocumentFormat('Application/Vnd.Oasis.OpenDocument.Presentation')).toBe('pptx') + expect(openDocumentFormat('application/vnd.oasis.opendocument.graphics')).toBeUndefined() + expect(openDocumentFormat('text/csv')).toBeUndefined() + }) +}) diff --git a/packages/core/src/opendocument-sniff/opendocument-mimetype.algorithm.ts b/packages/core/src/opendocument-sniff/opendocument-mimetype.algorithm.ts new file mode 100644 index 00000000..486c4cc0 --- /dev/null +++ b/packages/core/src/opendocument-sniff/opendocument-mimetype.algorithm.ts @@ -0,0 +1,54 @@ +/** The OpenDocument kinds a file's own `mimetype` entry can name, and the body format each is read as. */ +const KINDS: readonly (readonly [string, 'xlsx' | 'docx' | 'pptx'])[] = [ + ['application/vnd.oasis.opendocument.spreadsheet', 'xlsx'], + ['application/vnd.oasis.opendocument.text', 'docx'], + ['application/vnd.oasis.opendocument.presentation', 'pptx'], +] + +const ZIP_LOCAL_HEADER = 0x04_03_4B_50 +const MIMETYPE_NAME = 'mimetype' +/** The longest media type read: they are under 100 bytes. */ +const MAX_MIMETYPE_BYTES = 256 + +function uint16 (bytes: Uint8Array, at: number): number { + return (bytes[at] ?? 0) | ((bytes[at + 1] ?? 0) << 8) +} + +function uint32 (bytes: Uint8Array, at: number): number { + return (uint16(bytes, at) | (uint16(bytes, at + 2) << 16)) >>> 0 +} + +/** + * The media type an OpenDocument file declares: its first zip entry is a stored (uncompressed) `mimetype`, so it can be read without + * opening the package. `undefined` for anything that is not a zip starting that way, which includes every Office Open XML file. + * + * @param bytes - The file. + * @returns The media type (`application/vnd.oasis.opendocument.spreadsheet`), or `undefined`. + */ +export function openDocumentMimetype (bytes: Uint8Array): string | undefined { + if (bytes.length < 38 || uint32(bytes, 0) !== ZIP_LOCAL_HEADER) return undefined + const method = uint16(bytes, 8) + const size = uint32(bytes, 18) + const nameLength = uint16(bytes, 26) + if (method !== 0 || nameLength !== MIMETYPE_NAME.length || size > MAX_MIMETYPE_BYTES) return undefined + const extraLength = uint16(bytes, 28) + const name = new TextDecoder().decode(bytes.subarray(30, 30 + nameLength)) + if (name !== MIMETYPE_NAME) return undefined + const start = 30 + nameLength + extraLength + const mimetype = new TextDecoder().decode(bytes.subarray(start, start + size)).trim() + + return mimetype.startsWith('application/vnd.oasis.opendocument.') ? mimetype : undefined +} + +/** + * The body format an OpenDocument media type is read as: a spreadsheet is a workbook (`xlsx`), a text document a Word-like document (`docx`), a + * presentation a deck (`pptx`), the way their readers produce the same models. + * + * @param mimetype - A media type (from `openDocumentMimetype`, or a response's content type). + * @returns The body format, or `undefined` for a media type that is not one of those. + */ +export function openDocumentFormat (mimetype: string): 'xlsx' | 'docx' | 'pptx' | undefined { + const type = mimetype.toLowerCase().split(';', 1)[0].trim() + + return KINDS.find(([candidate]) => type === candidate)?.[1] +} diff --git a/packages/core/src/workbook-document/read-xlsx.client.test.ts b/packages/core/src/workbook-document/read-xlsx.client.test.ts index 65d1fb27..c99dfefb 100644 --- a/packages/core/src/workbook-document/read-xlsx.client.test.ts +++ b/packages/core/src/workbook-document/read-xlsx.client.test.ts @@ -1,10 +1,21 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' +import { strToU8, zipSync } from 'fflate' import { readXlsxWorkbook } from './read-xlsx.client' const fixtures = join(__dirname, '..', '..', '..', 'office-reader', 'src', 'spreadsheet', 'fixtures') describe('readXlsxWorkbook', () => { + it('reads an OpenDocument spreadsheet like a workbook, and says what to do with another OpenDocument file', async () => { + const ods = await readXlsxWorkbook(readFileSync(join(fixtures, '..', '..', 'opendocument-spreadsheet', 'fixtures', 'incentivi.ods')), 'incentivi.ods') + const xlsx = await readXlsxWorkbook(readFileSync(join(fixtures, 'incentivi.xlsx')), 'incentivi.xlsx') + // What the two formats store alike: not the column widths and row heights the .ods fixture leaves out. + const shape = (book: typeof ods): unknown => book.sheets.map(sheet => ({ name: sheet.name, hidden: sheet.hidden, hiddenRows: sheet.hiddenRows, rows: sheet.rows, merges: [...(sheet.merges ?? [])].sort((a, b) => a.localeCompare(b)) })) + expect(shape(ods)).toEqual(shape(xlsx)) + const text = zipSync({ 'mimetype': [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }], 'content.xml': strToU8('') }) + await expect(readXlsxWorkbook(text, 'letter.odt')).rejects.toThrow(/letter\.odt: .*OpenDocument/) + }) + it('writes a time of day as the time alone, in the 1900 and the 1904 date systems', async () => { const book = await readXlsxWorkbook(readFileSync(join(fixtures, 'incentivi.xlsx')), 'incentivi.xlsx') expect(book.sheets[0].rows[4][5]).toBe('2026-06-01') diff --git a/packages/core/src/workbook-document/read-xlsx.client.ts b/packages/core/src/workbook-document/read-xlsx.client.ts index acfa83f1..f916e5ed 100644 --- a/packages/core/src/workbook-document/read-xlsx.client.ts +++ b/packages/core/src/workbook-document/read-xlsx.client.ts @@ -1,3 +1,5 @@ +import type { Workbook } from '@opencraw/office-reader/xlsx' +import { openDocumentFormat, openDocumentMimetype } from '../opendocument-sniff' import type { WorkbookCell, WorkbookDocument } from './workbook-document.model' /** @@ -16,20 +18,46 @@ import type { WorkbookCell, WorkbookDocument } from './workbook-document.model' * not a readable workbook (a legacy `.xls`, a password-protected file, an `.ods`…). */ export async function readXlsxWorkbook (bytes: Uint8Array, source: string): Promise { + const mimetype = openDocumentMimetype(bytes) + if (mimetype !== undefined && openDocumentFormat(mimetype) === 'xlsx') return await readOdsWorkbook(bytes, source) const { readXlsx, OfficeReadError } = await import('@opencraw/office-reader/xlsx') try { const book = await readXlsx(bytes) - return { - kind: 'workbook', - sheets: book.sheets.map(sheet => ({ name: sheet.name, rows: sheet.rows.map(row => row.map(cell => workbookCell(cell, book.date1904))), hidden: sheet.hidden, hiddenRows: sheet.hiddenRows, merges: sheet.merges, columnWidths: sheet.columnWidths, rowHeights: sheet.rowHeights, ...(sheet.defaultColumnWidth !== undefined && { defaultColumnWidth: sheet.defaultColumnWidth }), ...(sheet.defaultRowHeight !== undefined && { defaultRowHeight: sheet.defaultRowHeight }) })), - } + return workbookOf(book) } catch (error) { if (error instanceof OfficeReadError) throw new Error(`${source}: ${error.message}`, { cause: error }) throw error } } +/** + * Reads an OpenDocument spreadsheet (`.ods`) into the same workbook document an `.xlsx` gives, through `@opencraw/office-reader`'s `readOds`. + * Loaded on first use, like the other readers. + * + * @param bytes - The file. + * @param source - Where it came from, for messages. + * @returns The workbook. + * @throws Error naming the source, and saying what to do, for a file that is not a readable spreadsheet. + */ +async function readOdsWorkbook (bytes: Uint8Array, source: string): Promise { + const { readOds, OfficeReadError } = await import('@opencraw/office-reader/ods') + try { + return workbookOf(await readOds(bytes)) + } catch (error) { + if (error instanceof OfficeReadError) throw new Error(`${source}: ${error.message}`, { cause: error }) + throw error + } +} + +/** A workbook the office reader gave, as a workbook document. */ +function workbookOf (book: Workbook): WorkbookDocument { + return { + kind: 'workbook', + sheets: book.sheets.map(sheet => ({ name: sheet.name, rows: sheet.rows.map(row => row.map(cell => workbookCell(cell, book.date1904))), hidden: sheet.hidden, hiddenRows: sheet.hiddenRows, merges: sheet.merges, columnWidths: sheet.columnWidths, rowHeights: sheet.rowHeights, ...(sheet.defaultColumnWidth !== undefined && { defaultColumnWidth: sheet.defaultColumnWidth }), ...(sheet.defaultRowHeight !== undefined && { defaultRowHeight: sheet.defaultRowHeight }) })), + } +} + const MS_PER_DAY = 86_400_000 /** Day zero of each date system, where a time of day falls: serial 0 is 1899-12-30 in the 1900 system, 1904-01-01 in the 1904 one. */ const DAY_ZERO = { 1900: Date.UTC(1899, 11, 30), 1904: Date.UTC(1904, 0, 1) } diff --git a/packages/office-reader/README.md b/packages/office-reader/README.md index 27f7f2f9..168357fd 100644 --- a/packages/office-reader/README.md +++ b/packages/office-reader/README.md @@ -32,6 +32,9 @@ npm install @opencraw/office-reader ## Read a workbook +`.xlsx` and `.xlsm` are read by `readXlsx`; an OpenDocument spreadsheet (`.ods`, from LibreOffice, Google Sheets or Excel) by `readOds`, which +gives the same `Workbook` (`import { readOds } from '@opencraw/office-reader/ods'`, same options). Formulas give the value stored with them. + ```ts import { readXlsx } from '@opencraw/office-reader/xlsx' @@ -124,7 +127,8 @@ says what to do: |---|---| | `legacy-format` | a legacy binary `.xls`, `.ppt` or `.doc`: save it as `.xlsx` / `.pptx` / `.docx`, or export it as PDF | | `encrypted` | password-protected | -| `unsupported-format` | an OpenDocument `.ods` / `.odp` / `.odt` | +| `unsupported-format` | an OpenDocument file this reader does not read yet (`.odp` / `.odt`), or a different kind of OpenDocument file than the one asked for | +| `not-ods` | a zip package that is not an OpenDocument spreadsheet | | `not-xlsx` / `not-pptx` / `not-docx` | a zip package of another kind (a `.pptx` given to `readXlsx`, say) | | `not-zip` | not a zip at all | | `too-large` | past the `limits` | @@ -251,7 +255,7 @@ returns flattened text, and the others are text-only or browser renderers. SheetJS's npm copy (0.18.5) carries two high-severity advisories (prototype pollution, ReDoS); the fixed versions are published on its own CDN only. Choose SheetJS or ExcelJS when you need to **write** workbooks, -evaluate formulas, or read `.xls` and `.ods`. This package only reads. +evaluate formulas, or read `.xls`. This package only reads. Tested against the 367 workbooks of Apache POI's test corpus, real files and fuzzer cases alike. It reads 350 of them and refuses the other 17 with an `OfficeReadError`: encrypted, truncated or corrupted files. @@ -260,7 +264,7 @@ Tested against the 367 workbooks of Apache POI's test corpus, real files and fuz - **Writing files.** - **Evaluating formulas, and applying display formats.** A percentage stays `0.125` and a price stays `15950`. -- **Legacy `.xls`, `.ppt`, `.doc`, OpenDocument `.ods` / `.odp` / `.odt`, and binary `.xlsb`.** These are refused with a code. +- **Legacy `.xls`, `.ppt`, `.doc`, OpenDocument `.odp` / `.odt`, and binary `.xlsb`.** These are refused with a code. (OpenDocument spreadsheets, `.ods`, are read.) - **In workbooks:** charts, pivot tables, images, comments and data validation. - **In presentations:** SmartArt text, text inside images (no OCR), animations and themes. - **In documents:** comments, formatting (bold, colours, fonts), images, equations, and the text of charts. diff --git a/packages/office-reader/package.json b/packages/office-reader/package.json index 8cd3de82..0062265b 100644 --- a/packages/office-reader/package.json +++ b/packages/office-reader/package.json @@ -26,6 +26,11 @@ "types": "./dist/src/document/index.d.ts", "import": "./dist/docx.esm.js", "default": "./dist/docx.esm.js" + }, + "./ods": { + "types": "./dist/src/opendocument-spreadsheet/index.d.ts", + "import": "./dist/ods.esm.js", + "default": "./dist/ods.esm.js" } }, "files": [ diff --git a/packages/office-reader/rollup.config.cjs b/packages/office-reader/rollup.config.cjs index 7434d9cc..fe48b889 100644 --- a/packages/office-reader/rollup.config.cjs +++ b/packages/office-reader/rollup.config.cjs @@ -24,6 +24,7 @@ module.exports = withNx( xlsx: `${__dirname}/src/spreadsheet/index.ts`, pptx: `${__dirname}/src/presentation/index.ts`, docx: `${__dirname}/src/document/index.ts`, + ods: `${__dirname}/src/opendocument-spreadsheet/index.ts`, }, // Added by MoNecromanCI. rollup hands sourcemapPathTransform an OS-NATIVE // path with one parent segment too many, so `sources` resolve to nothing diff --git a/packages/office-reader/src/index.ts b/packages/office-reader/src/index.ts index a1f47455..c0358603 100644 --- a/packages/office-reader/src/index.ts +++ b/packages/office-reader/src/index.ts @@ -1,5 +1,7 @@ export { readXlsx } from './spreadsheet' export type { ReadXlsxOptions, CellValue, Sheet, Workbook, ValueMode, SheetFilter } from './spreadsheet' +export { readOds } from './opendocument-spreadsheet' +export type { ReadOdsOptions } from './opendocument-spreadsheet' export { readPptx } from './presentation' export type { ReadPptxOptions, Deck, Slide, SlideShape, SlideChart, ChartSeries, SlideFilter } from './presentation' export { readDocx } from './document' diff --git a/packages/office-reader/src/ooxml-package/ooxml-package.client.ts b/packages/office-reader/src/ooxml-package/ooxml-package.client.ts index 6830c588..0af8d0ee 100644 --- a/packages/office-reader/src/ooxml-package/ooxml-package.client.ts +++ b/packages/office-reader/src/ooxml-package/ooxml-package.client.ts @@ -45,11 +45,36 @@ export class OoxmlPackage { } const opened = new OoxmlPackage(bytes, entries, { entryBytes: limits.entryBytes ?? DEFAULT_ENTRY_BYTES, totalBytes: limits.totalBytes ?? DEFAULT_TOTAL_BYTES }) const mimetype = opened.has('mimetype') ? opened.text('mimetype').trim() : '' + if (mimetype.startsWith('application/vnd.oasis.opendocument.spreadsheet')) throw new OfficeReadError('unsupported-format', 'an OpenDocument spreadsheet (.ods): read it with readOds, or save it as .xlsx') if (mimetype.startsWith('application/vnd.oasis.opendocument')) throw new OfficeReadError('unsupported-format', `an OpenDocument file (${mimetype}): save it as .xlsx, .pptx or .docx`) return opened } + /** + * Opens an OpenDocument package of one kind (a zip whose first entry, `mimetype`, names it), refusing anything else with the reason. + * + * @param bytes - The file. + * @param mimetype - The media type the package must declare (`application/vnd.oasis.opendocument.spreadsheet`). + * @param limits - How much it may inflate to. + * @returns The package; no part is inflated yet. + * @throws OfficeReadError: `not-zip`, `legacy-format`, `encrypted`, or `unsupported-format` when it is another kind of file. + */ + static openOpenDocument (bytes: Uint8Array, mimetype: string, limits: PackageLimits = {}): OoxmlPackage { + if (isCompoundFile(bytes)) throw new OfficeReadError('legacy-format', 'a legacy binary Office file: save it as .ods, or export it as PDF') + const entries = new Map() + try { + for (const entry of readZipDirectory(bytes)) entries.set(partKey(entry.name), entry) + } catch (error) { + throw new OfficeReadError('not-zip', `not an OpenDocument file: not a readable zip (${(error as Error).message})`, { cause: error }) + } + const opened = new OoxmlPackage(bytes, entries, { entryBytes: limits.entryBytes ?? DEFAULT_ENTRY_BYTES, totalBytes: limits.totalBytes ?? DEFAULT_TOTAL_BYTES }) + const declared = opened.has('mimetype') ? opened.text('mimetype').trim() : '' + if (declared !== mimetype) throw new OfficeReadError('unsupported-format', declared === '' ? 'not an OpenDocument file: it has no mimetype entry' : `not the kind of file expected: this one is ${declared}`) + + return opened + } + private declared = 0 /** The parts counted against the total limit: a part read twice counts once. */ private readonly counted = new Set() diff --git a/packages/office-reader/src/opendocument-spreadsheet/fixtures/incentivi.ods b/packages/office-reader/src/opendocument-spreadsheet/fixtures/incentivi.ods new file mode 100644 index 0000000000000000000000000000000000000000..134487406de2064037dfceea9eaec200b800ae86 GIT binary patch literal 1834 zcmZ`)3p5jY7+*75<#7{p3n_+>Nv_JHq?ie_5JhO%Jlcb8UPD^pQiR$>n4%FCjXc+= zrMhvEme7^AsCgtduGMv?(>*=T?fakq`M&=-|KE4M?|k3+{ZI~4ASJ+xg-IRuPQ;m= zgGu6*3zl4bFdjpR4#l8DLvg|WXi6}VU=%?JfDzH;U^0vtiXj9L{loDX0tH46C1KD3 z7CVlpVb7b`xNrxGyZcfhl5QJ{>;znRa=f`%F*}cyIA5oxS+2a^A-vPmRyaJR=v0r?_oJNh1Bd^kb&5RPp3s{hKtED0|AI#d~U2#e>?^%_l++m4m0gB^k3d@O6%EZOH(<+xQzhL$Jp?eyp! z*-10A3bT3xO{wR@$C(~_jxLXzO-d8PZ+O#v9ipOwin^E+(e76L0hfj3dUmz@%$lNW zcMk9xp5Ma8m9oL%*w8kS?zM$+grIL<`KVg)lvmmNGQop&SG}ZN6{_bp*t|V?h%yel zr=uno@C8#zYYKB~jJ>S+Iw~7#GZ|<1+M9E-9c5gJeu$f>PYB9tEmu!@=JqvW%A~^0 z)$%~3o}u7~n#ywO@W3E4ivu~cYN&cbvHbeDVT56%aE>!$k^WHiL4tPTOQ?=Q&rR!@ zuBjWAY@b8d+?fsK`-mY-dM(nJPKUB}rLbFH-q$tmM=J{rN z#Fz+**7Wy%_>@JZ{usTwGZXjsu`qQ(d5K*$f%_SC z-8VBA&I)|$r_y@7b}%BjmHeVd#*up_bX+DQ6``OzsC8I4e=u({cgC{$q9r6Kd{)Lk-~ zj&}HFZlku$JaIDTwtqY&9THy~Bg@GQRLT9opU4vv1Qd4^1)EhD=d+g&XwowkWKK_Zt+>GTKSx^ z-w#7uo$OM3&IU)Xd-kNnO25!Cb>BdwD2JLHDQu=oxv~^ETz3RBcfU7_*i8<5QEl2+ z^FZsjNpUjN)+Q_js`s$6j8XvZ8Oxv@Z#!=ig7bawX+AOUhT4cqdyoYeRb^ z+z5{*1YKn=viA6xW4Bt zWKNH^g^ge5Ol+Jpwa}iXeYfIn`|2{g@c8I3&ejC;A~m@iDYnpYc9w&VR5tlN1asF- zH=>Q}VRD;K@7vHof-0h#bYC@-E~R(JK82W{Z@9f2ng(4gVl6GLtYRsod&KUWJ$ z)8E*C+VK_j|9bEh;wA~dQHLLaKkmm@V4Or=BtrFhTYg0SIF_#{OX;5s3FROyv+@lj LITI4U{qyTz>ZuO5 literal 0 HcmV?d00001 diff --git a/packages/office-reader/src/opendocument-spreadsheet/index.ts b/packages/office-reader/src/opendocument-spreadsheet/index.ts new file mode 100644 index 00000000..3409107f --- /dev/null +++ b/packages/office-reader/src/opendocument-spreadsheet/index.ts @@ -0,0 +1,4 @@ +export { readOds } from './read-ods.use-case' +export type { ReadOdsOptions } from './read-ods.use-case' +export { OfficeReadError } from '../read-error' +export type { OfficeReadErrorCode } from '../read-error' diff --git a/packages/office-reader/src/opendocument-spreadsheet/ods-cell.algorithm.ts b/packages/office-reader/src/opendocument-spreadsheet/ods-cell.algorithm.ts new file mode 100644 index 00000000..fa5abf19 --- /dev/null +++ b/packages/office-reader/src/opendocument-spreadsheet/ods-cell.algorithm.ts @@ -0,0 +1,94 @@ +import type { CellValue, ValueMode } from '../spreadsheet' + +/** A cell as `content.xml` stores it: its attributes and the text of its paragraphs. */ +export interface OdsStoredCell { + /** The cell's attributes, with their prefixes (`office:value-type`, `office:value`...). */ + attributes: Readonly> + /** The text a person sees, one entry per paragraph. */ + paragraphs: readonly string[] +} + +const NUMERIC_TYPES = new Set(['float', 'percentage', 'currency']) + +/** A date value (`2026-06-01`, `2026-06-01T09:30:00`) as a `Date` holding the wall-clock time as UTC. */ +function dateOf (value: string): Date | undefined { + const withTime = value.includes('T') + const hasZone = /(?:Z|[+-]\d\d:\d\d)$/.test(value) + const date = new Date(hasZone ? value : (withTime ? `${value}Z` : `${value}T00:00:00Z`)) + + return Number.isNaN(date.getTime()) ? undefined : date +} + +/** A duration value (`PT09H30M00S`, `P1DT2H`) as seconds. */ +function secondsOf (value: string): number | undefined { + const match = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(value) + if (match === null) return undefined + const [, days, hours, minutes, seconds] = match + + return Number(days ?? 0) * 86_400 + Number(hours ?? 0) * 3600 + Number(minutes ?? 0) * 60 + Number(seconds ?? 0) +} + +/** 1899-12-30, the day an `.xlsx` time of day (a serial below 1) falls on: a time reads the same in both. */ +const TIME_OF_DAY_BASE_MS = Date.UTC(1899, 11, 30) + +function isoText (date: Date, timeOfDay: boolean): string { + const iso = date.toISOString() + if (timeOfDay) return iso.slice(11, 19) + + return iso.slice(11, 19) === '00:00:00' ? iso.slice(0, 10) : iso.slice(0, 19) +} + +/** A cell's value as typed: text, number, boolean, date, error, or `null` when empty. */ +function typedValue (cell: OdsStoredCell): CellValue { + const { attributes } = cell + const text = cell.paragraphs.join('\n') + if (attributes['calcext:value-type'] === 'error') return { error: text } + const type = attributes['office:value-type'] + if (type !== undefined && NUMERIC_TYPES.has(type)) { + const raw = attributes['office:value'] + if (raw === undefined || raw.trim() === '') return null + const number = Number(raw) + + return Number.isNaN(number) ? raw : number + } + switch (type) { + case 'boolean': { + return attributes['office:boolean-value'] === 'true' + } + case 'date': { + const raw = attributes['office:date-value'] ?? '' + + return dateOf(raw) ?? (raw === '' ? null : raw) + } + case 'time': { + const seconds = secondsOf(attributes['office:time-value'] ?? '') + + return seconds === undefined ? (text === '' ? null : text) : new Date(TIME_OF_DAY_BASE_MS + (seconds * 1000)) + } + default: { + // `string`, or no type at all: a cell holding only text. + const value = attributes['office:string-value'] ?? text + + return value === '' ? null : value + } + } +} + +/** + * A cell in the mode asked for. Typed: numbers, booleans, dates (wall-clock time as UTC) and text; `null` when empty. Text: the canonical text + * the `.xlsx` reader gives (shortest round-trip numbers, ISO dates, `true`/`false`), `''` when empty. A formula gives the value stored with it; + * nothing is evaluated, and display formats are not applied. + * + * @param cell - The stored cell. + * @param mode - `typed` or `text`. + * @returns The value. + */ +export function odsCellValue (cell: OdsStoredCell, mode: ValueMode): CellValue { + const value = typedValue(cell) + if (mode === 'typed') return value + if (value === null) return '' + if (value instanceof Date) return isoText(value, cell.attributes['office:value-type'] === 'time') + if (typeof value === 'object') return value.error + + return String(value) +} diff --git a/packages/office-reader/src/opendocument-spreadsheet/ods-sheets.mapper.ts b/packages/office-reader/src/opendocument-spreadsheet/ods-sheets.mapper.ts new file mode 100644 index 00000000..9cb55f76 --- /dev/null +++ b/packages/office-reader/src/opendocument-spreadsheet/ods-sheets.mapper.ts @@ -0,0 +1,211 @@ +import { walkXml } from '../ooxml-package' +import type { CellValue, Sheet, SheetFilter, ValueMode } from '../spreadsheet' +import { odsCellValue } from './ods-cell.algorithm' + +/** The most rows and columns a sheet is ever expanded to: the format's own limits. A file that repeats an empty row a million times costs nothing. */ +const MAX_ROWS = 1_048_576 +const MAX_COLUMNS = 16_384 + +interface SheetBuilder { + name: string + style: string + rows: CellValue[][] + hiddenRows: number[] + merges: string[] + /** Empty rows seen since the last row with a cell: kept only if a row with a cell follows. */ + pendingEmptyRows: number +} + +interface RowBuilder { + repeat: number + hidden: boolean + cells: CellValue[] + /** Empty cells seen since the last cell with a value: kept only if one follows. */ + pendingEmpty: number +} + +interface CellBuilder { + attributes: Record + paragraphs: string[] + repeat: number + colSpan: number + rowSpan: number +} + +function count (value: string | undefined): number { + const number = Math.trunc(Number(value ?? '')) + + return Number.isNaN(number) || number < 1 ? 1 : number +} + +/** A column index as its letters: 0 → `A`, 26 → `AA`. */ +function letters (index: number): string { + let rest = index + 1 + let name = '' + while (rest > 0) { + const digit = (rest - 1) % 26 + name = String.fromCodePoint(65 + digit) + name + rest = Math.floor((rest - 1) / 26) + } + + return name +} + +function selector (filter: SheetFilter | undefined): (sheet: { name: string, hidden: boolean }) => boolean { + if (filter === undefined) return () => true + if (typeof filter === 'string') return sheet => sheet.name === filter + if (filter instanceof RegExp) return sheet => filter.test(sheet.name) + + return filter +} + +/** + * Reads the sheets of an OpenDocument spreadsheet's `content.xml`: each table's cells (empty trailing rows and cells, which the format + * repeats up to its limits, are dropped), the merged ranges as A1 references and the hidden rows and sheets a person would not see. + * + * @param xml - The text of `content.xml`. + * @param mode - `typed` or `text` values. + * @param filter - Which sheets to keep, by name or test; default all. + * @returns The sheets in document order. + */ +export function odsSheets (xml: string, mode: ValueMode, filter?: SheetFilter): Sheet[] { + const keep = selector(filter) + const hiddenTableStyles = new Set() + const sheets: Sheet[] = [] + let styleName = '' + let styleFamily = '' + let inSpreadsheet = false + let tableDepth = 0 + let sheet: SheetBuilder | undefined + let row: RowBuilder | undefined + let cell: CellBuilder | undefined + let inParagraph = false + + const emptyOf = (): CellValue => (mode === 'text' ? '' : null) + const isEmpty = (value: CellValue): boolean => value === null || value === '' + + function finishCell (done: CellBuilder, current: RowBuilder, builder: SheetBuilder): void { + const value = odsCellValue({ attributes: done.attributes, paragraphs: done.paragraphs }, mode) + const column = current.cells.length + current.pendingEmpty + if (done.colSpan > 1 || done.rowSpan > 1) { + const row0 = builder.rows.length + builder.pendingEmptyRows + builder.merges.push(`${letters(column)}${String(row0 + 1)}:${letters(column + done.colSpan - 1)}${String(row0 + done.rowSpan)}`) + } + if (isEmpty(value)) { + current.pendingEmpty += done.repeat + + return + } + for (let index = 0; index < current.pendingEmpty && current.cells.length < MAX_COLUMNS; index += 1) current.cells.push(emptyOf()) + current.pendingEmpty = 0 + for (let index = 0; index < done.repeat && current.cells.length < MAX_COLUMNS; index += 1) current.cells.push(value) + } + + function finishRow (done: RowBuilder, builder: SheetBuilder): void { + if (done.cells.length === 0) { + builder.pendingEmptyRows += done.repeat + // A hidden run of empty rows is a few hundred at most in practice; a huge one is the format filling the sheet. + if (done.hidden && done.repeat <= 1000) for (let index = 0; index < done.repeat; index += 1) builder.hiddenRows.push(builder.rows.length + builder.pendingEmptyRows - done.repeat + index) + + return + } + for (let index = 0; index < builder.pendingEmptyRows && builder.rows.length < MAX_ROWS; index += 1) builder.rows.push([]) + builder.pendingEmptyRows = 0 + for (let index = 0; index < done.repeat && builder.rows.length < MAX_ROWS; index += 1) { + if (done.hidden) builder.hiddenRows.push(builder.rows.length) + builder.rows.push([...done.cells]) + } + } + + walkXml(xml, { + open: (name, attributes) => { + switch (name) { + case 'spreadsheet': { + inSpreadsheet = true + break + } + case 'style': { + styleName = attributes['style:name'] ?? '' + styleFamily = attributes['style:family'] ?? '' + break + } + case 'table-properties': { + if (styleFamily === 'table' && attributes['table:display'] === 'false') hiddenTableStyles.add(styleName) + break + } + case 'table': { + if (!inSpreadsheet) break + tableDepth += 1 + if (tableDepth === 1) sheet = { name: attributes['table:name'] ?? '', style: attributes['table:style-name'] ?? '', rows: [], hiddenRows: [], merges: [], pendingEmptyRows: 0 } + break + } + case 'table-row': { + if (tableDepth === 1) row = { repeat: count(attributes['table:number-rows-repeated']), hidden: attributes['table:visibility'] === 'collapse' || attributes['table:visibility'] === 'filter', cells: [], pendingEmpty: 0 } + break + } + case 'table-cell': + case 'covered-table-cell': { + if (tableDepth === 1 && row !== undefined) cell = { attributes, paragraphs: [], repeat: count(attributes['table:number-columns-repeated']), colSpan: count(attributes['table:number-columns-spanned']), rowSpan: count(attributes['table:number-rows-spanned']) } + break + } + case 'p': { + if (cell !== undefined && tableDepth === 1) { + inParagraph = true + cell.paragraphs.push('') + } + break + } + case 's': { + if (cell !== undefined && inParagraph && tableDepth === 1) cell.paragraphs[cell.paragraphs.length - 1] += ' '.repeat(count(attributes['text:c'])) + break + } + case 'tab': { + if (cell !== undefined && inParagraph && tableDepth === 1) cell.paragraphs[cell.paragraphs.length - 1] += '\t' + break + } + case 'line-break': { + if (cell !== undefined && inParagraph && tableDepth === 1) cell.paragraphs[cell.paragraphs.length - 1] += '\n' + break + } + // No default + } + }, + text: (text) => { + if (cell !== undefined && inParagraph && tableDepth === 1) cell.paragraphs[cell.paragraphs.length - 1] += text + }, + close: (name) => { + switch (name) { + case 'p': { + inParagraph = false + break + } + case 'table-cell': + case 'covered-table-cell': { + if (tableDepth !== 1) break + if (cell !== undefined && row !== undefined && sheet !== undefined) finishCell(cell, row, sheet) + cell = undefined + break + } + case 'table-row': { + if (tableDepth !== 1) break + if (row !== undefined && sheet !== undefined) finishRow(row, sheet) + row = undefined + break + } + case 'table': { + if (!inSpreadsheet) break + if (tableDepth === 1 && sheet !== undefined) { + const hidden = hiddenTableStyles.has(sheet.style) + if (keep({ name: sheet.name, hidden })) sheets.push({ name: sheet.name, hidden, rows: sheet.rows, hiddenRows: sheet.hiddenRows, merges: sheet.merges }) + sheet = undefined + } + tableDepth -= 1 + break + } + // No default + } + }, + }) + + return sheets +} diff --git a/packages/office-reader/src/opendocument-spreadsheet/read-ods.use-case.test.ts b/packages/office-reader/src/opendocument-spreadsheet/read-ods.use-case.test.ts new file mode 100644 index 00000000..5c6d99e4 --- /dev/null +++ b/packages/office-reader/src/opendocument-spreadsheet/read-ods.use-case.test.ts @@ -0,0 +1,190 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { strToU8, zipSync } from 'fflate' +import { readOds } from './read-ods.use-case' +import type { ReadOdsOptions } from './read-ods.use-case' + +const NAMESPACES = 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:calcext="urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0"' +const ODS_MIMETYPE = 'application/vnd.oasis.opendocument.spreadsheet' + +/** An `.ods` as the apps write it: a stored `mimetype` entry first, then `content.xml`. */ +function ods (body: string, styles = ''): Uint8Array { + const content = `${styles}${body}` + + return zipSync({ 'mimetype': [strToU8(ODS_MIMETYPE), { level: 0 }], 'content.xml': strToU8(content) }) +} + +const text = (value: string): string => `${value}` +const number = (value: number): string => `${String(value)}` +const row = (...cells: string[]): string => `${cells.join('')}` +const table = (name: string, ...rows: string[]): string => `${rows.join('')}` + +/** The first sheet's rows of a workbook with one sheet of these rows. */ +async function rowsOf (rows: string[], options?: ReadOdsOptions): Promise { + const body = table('T', ...rows) + const book = await readOds(ods(body), options) + + return book.sheets[0].rows +} + +async function sheetNames (bytes: Uint8Array, sheets: ReadOdsOptions['sheets']): Promise { + const book = await readOds(bytes, { sheets }) + + return book.sheets.map(sheet => sheet.name) +} + +describe('readOds', () => { + it('reads each sheet in order with its rows, numbers as numbers and text as text', async () => { + const prices = table('Prices', row(text('Model'), text('Price')), row(text('Clio'), number(19_900)), row(text('Captur'), number(24_500.5))) + const other = table('Other', row(text('x'))) + const book = await readOds(ods(prices + other)) + + expect(book.date1904).toBe(false) + expect(book.sheets.map(sheet => sheet.name)).toEqual(['Prices', 'Other']) + expect(book.sheets[0].rows).toEqual([['Model', 'Price'], ['Clio', 19_900], ['Captur', 24_500.5]]) + expect(book.sheets[0].hidden).toBe(false) + }) + + it('reads percentages, currency, booleans, dates, times and errors', async () => { + const cells = [ + '12.5%', + '€ 15,950.00', + 'TRUE', + '01/06/2026', + 'x', + '09:30', + '#DIV/0!', + ] + const rows = await rowsOf([row(...cells)]) + + expect(rows[0]).toEqual([0.125, 15_950, true, new Date('2026-06-01T00:00:00Z'), new Date('2026-06-01T09:30:00Z'), new Date('1899-12-30T09:30:00Z'), { error: '#DIV/0!' }]) + }) + + it('gives canonical text in text mode: shortest numbers, ISO dates, true/false, empty as an empty string', async () => { + const cells = [ + number(78.6), + '', + '', + '', + '', + '', + text('end'), + ] + const rows = await rowsOf([row(...cells)], { values: 'text' }) + + expect(rows[0]).toEqual(['78.6', 'false', '2026-06-01', '2026-06-01T09:30:00', '09:30:00', '', 'end']) + }) + + it('reads a cell of several paragraphs, spaces, tabs and line breaks as the text a person sees', async () => { + const cell = 'one twothreefoursecond' + const rows = await rowsOf([row(cell)]) + + expect(rows[0][0]).toBe('one two\tthree\nfour\nsecond') + }) + + it('uses office:string-value over the paragraphs when the cell has one', async () => { + const cell = 'shown' + const rows = await rowsOf([row(cell)]) + + expect(rows[0][0]).toBe('stored') + }) + + it('expands repeated cells and rows that hold something, and drops the empty ones the format repeats to its limits', async () => { + const gap = '' + const tail = '' + const full = `${text('a')}${gap}${text('b')}${tail}` + const filler = '' + const rows = await rowsOf([full, filler]) + + expect(rows).toEqual([['a', null, null, null, 'b'], ['a', null, null, null, 'b']]) + }) + + it('keeps empty rows between rows that hold something, as empty rows', async () => { + const gap = '' + const rows = await rowsOf([row(text('top')), gap, row(text('bottom'))]) + + expect(rows).toEqual([['top'], [], [], ['bottom']]) + }) + + it('reports merged ranges as A1 references, with the value in the top-left cell', async () => { + const merged = 'Title' + const first = row(text('a'), merged) + const covered = '' + const second = row(text('b'), covered, text('c')) + const book = await readOds(ods(table('M', first, second))) + + expect(book.sheets[0].merges).toEqual(['B1:C2']) + expect(book.sheets[0].rows).toEqual([['a', 'Title'], ['b', null, null, 'c']]) + }) + + it('names columns beyond Z in merged ranges', async () => { + const run = 'x' + const wide = 'wide' + const body = table('W', row(run, wide)) + const book = await readOds(ods(body)) + + expect(book.sheets[0].merges).toEqual(['AA1:AC1']) + }) + + it('marks hidden sheets by their table style, and hidden and filtered rows', async () => { + const styles = '' + const collapsed = `${text('b')}` + const filtered = `${text('c')}` + const shown = `${row(text('a'))}${collapsed}${filtered}${row(text('d'))}` + const secret = `${row(text('s'))}` + const book = await readOds(ods(shown + secret, styles)) + + expect(book.sheets.map(sheet => [sheet.name, sheet.hidden])).toEqual([['Shown', false], ['Secret', true]]) + expect(book.sheets[0].hiddenRows).toEqual([1, 2]) + }) + + it('reads only the sheets asked for', async () => { + const body = table('2025', row(text('a'))) + table('2026', row(text('b'))) + table('Notes', row(text('c'))) + const bytes = ods(body) + + expect(await sheetNames(bytes, '2026')).toEqual(['2026']) + expect(await sheetNames(bytes, /^20/)).toEqual(['2025', '2026']) + expect(await sheetNames(bytes, sheet => sheet.name === 'Notes')).toEqual(['Notes']) + }) + + it('ignores a table nested inside a cell', async () => { + const inner = table('inner', row(text('deep'))) + const nested = `outer${inner}` + const next = text('next') + const body = table('N', row(nested, next)) + const book = await readOds(ods(body)) + + expect(book.sheets.map(sheet => sheet.name)).toEqual(['N']) + expect(book.sheets[0].rows).toEqual([['outer', 'next']]) + }) + + it('refuses other files with the reason', async () => { + const odt = zipSync({ 'mimetype': [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }], 'content.xml': strToU8('') }) + const legacy = readFileSync(join(__dirname, '..', 'ooxml-package', 'fixtures', 'legacy.xls')) + const xlsx = readFileSync(join(__dirname, '..', 'spreadsheet', 'fixtures', 'incentivi.xlsx')) + + await expect(readOds(odt)).rejects.toMatchObject({ code: 'unsupported-format' }) + await expect(readOds(new TextEncoder().encode('a,b\n1,2\n'))).rejects.toMatchObject({ code: 'not-zip' }) + await expect(readOds(legacy)).rejects.toMatchObject({ code: 'legacy-format' }) + await expect(readOds(xlsx)).rejects.toMatchObject({ code: 'unsupported-format' }) + }) +}) + +describe('readOds on a workbook also saved as .xlsx', () => { + it('gives the same sheets, rows, merges and hidden rows and sheets as readXlsx', async () => { + const { readXlsx } = await import('../spreadsheet') + const fromOds = await readOds(join(__dirname, 'fixtures', 'incentivi.ods')) + const fromXlsx = await readXlsx(join(__dirname, '..', 'spreadsheet', 'fixtures', 'incentivi.xlsx')) + const shape = (sheets: typeof fromOds.sheets): unknown => sheets.map(sheet => ({ name: sheet.name, hidden: sheet.hidden, rows: sheet.rows, hiddenRows: sheet.hiddenRows, merges: [...sheet.merges].sort((a, b) => a.localeCompare(b)) })) + + expect(shape(fromOds.sheets)).toEqual(shape(fromXlsx.sheets)) + }) + + it('gives the same canonical text as readXlsx in text mode', async () => { + const { readXlsx } = await import('../spreadsheet') + const fromOds = await readOds(join(__dirname, 'fixtures', 'incentivi.ods'), { values: 'text' }) + const fromXlsx = await readXlsx(join(__dirname, '..', 'spreadsheet', 'fixtures', 'incentivi.xlsx'), { values: 'text' }) + + expect(fromOds.sheets.map(sheet => sheet.rows)).toEqual(fromXlsx.sheets.map(sheet => sheet.rows)) + }) +}) diff --git a/packages/office-reader/src/opendocument-spreadsheet/read-ods.use-case.ts b/packages/office-reader/src/opendocument-spreadsheet/read-ods.use-case.ts new file mode 100644 index 00000000..44919a9f --- /dev/null +++ b/packages/office-reader/src/opendocument-spreadsheet/read-ods.use-case.ts @@ -0,0 +1,37 @@ +import { OoxmlPackage } from '../ooxml-package' +import type { PackageLimits } from '../ooxml-package' +import { readSource } from '../source-bytes' +import type { OfficeSource } from '../source-bytes' +import type { CellValue, SheetFilter, ValueMode, Workbook } from '../spreadsheet' +import { odsSheets } from './ods-sheets.mapper' + +/** How to read an OpenDocument spreadsheet; the same choices as `readXlsx`. */ +export interface ReadOdsOptions { + /** Which sheets to read; default all. */ + sheets?: SheetFilter + /** `typed` (default): numbers, booleans, dates as values; `text`: every cell as canonical text. */ + values?: ValueMode + /** How much the file may inflate to. */ + limits?: PackageLimits +} + +/** The media type an OpenDocument spreadsheet declares in its `mimetype` entry. */ +const ODS_MIMETYPE = 'application/vnd.oasis.opendocument.spreadsheet' + +/** + * Reads an OpenDocument spreadsheet (`.ods`, as LibreOffice, Google Sheets and Excel save it) into the same {@link Workbook} model `readXlsx` + * gives: every sheet's cells, with the merged ranges and hidden rows and sheets a person would see. Formulas give the value stored with + * them; nothing is evaluated. Display formats are not applied. + * + * @param source - A path, bytes, a Blob or a stream (see {@link OfficeSource}). + * @param options - Which sheets, which value mode, which limits. + * @returns The workbook. + * @throws OfficeReadError for a file that is not a readable spreadsheet: `not-zip`, `legacy-format`, `unsupported-format` (another kind of file), `too-large`… + */ +export async function readOds (source: OfficeSource, options: ReadOdsOptions & { values: 'text' }): Promise> +export async function readOds (source: OfficeSource, options?: ReadOdsOptions): Promise +export async function readOds (source: OfficeSource, options: ReadOdsOptions = {}): Promise> { + const pkg = OoxmlPackage.openOpenDocument(await readSource(source), ODS_MIMETYPE, options.limits) + + return { date1904: false, sheets: odsSheets(pkg.text('content.xml'), options.values ?? 'typed', options.sheets) } +} diff --git a/packages/office-reader/src/read-error/office-read.error.ts b/packages/office-reader/src/read-error/office-read.error.ts index 6b9d72dc..554ba173 100644 --- a/packages/office-reader/src/read-error/office-read.error.ts +++ b/packages/office-reader/src/read-error/office-read.error.ts @@ -10,8 +10,10 @@ export type OfficeReadErrorCode = 'legacy-format' | /** A password-protected Office file. */ 'encrypted' | - /** An OpenDocument file (`.ods`, `.odp`). */ + /** An OpenDocument file this reader does not read. */ 'unsupported-format' | + /** A zip package that is not an OpenDocument spreadsheet. */ + 'not-ods' | /** A zip package that is not a spreadsheet. */ 'not-xlsx' | /** A zip package that is not a presentation. */