Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/studio-ui/src/app/toolbar.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, { what: string, instead: string }> = {
'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.' },
Expand Down
2 changes: 1 addition & 1 deletion docs/recipes/authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/http-session/body-sniff.algorithm.test.ts
Original file line number Diff line number Diff line change
@@ -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)))
Expand All @@ -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()
})
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/http-session/body-sniff.algorithm.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/http-session/http.client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/http-session/http.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand All @@ -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<string, BodyKind> = { '.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<string, BodyKind> = { '.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()]
}
1 change: 1 addition & 0 deletions packages/core/src/opendocument-sniff/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { openDocumentFormat, openDocumentMimetype } from './opendocument-mimetype.algorithm'
Original file line number Diff line number Diff line change
@@ -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('<x/>') })
}

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('<x/>'), 'xl/workbook.xml': strToU8('<x/>') })
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()
})
})
Original file line number Diff line number Diff line change
@@ -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]
}
11 changes: 11 additions & 0 deletions packages/core/src/workbook-document/read-xlsx.client.test.ts
Original file line number Diff line number Diff line change
@@ -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('<x/>') })
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')
Expand Down
36 changes: 32 additions & 4 deletions packages/core/src/workbook-document/read-xlsx.client.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand All @@ -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<WorkbookDocument> {
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<WorkbookDocument> {
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) }
Expand Down
Loading
Loading