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,.ods,.csv,.tsv,.pptx,.docx,.odt,.md,.markdown,.html,.htm,.json,.jsonl,.yaml,.yml,.xml'
const DOCUMENT_EXTENSIONS = '.pdf,.xlsx,.xlsm,.xls,.ods,.csv,.tsv,.pptx,.odp,.docx,.odt,.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,17 +1,14 @@
import { unsupportedFormatMessage } from './unsupported-format.policy'

describe('unsupportedFormatMessage', () => {
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 or text document: the Studio reads them', () => {
it('does not refuse an OpenDocument spreadsheet, text document or presentation: the Studio reads them', () => {
expect(unsupportedFormatMessage('LiveTable254.ods')).toBeUndefined()
expect(unsupportedFormatMessage('Report.odt')).toBeUndefined()
expect(unsupportedFormatMessage('Talk.odp')).toBeUndefined()
})

it('reads the type off an address, ignoring its query and the case', () => {
expect(unsupportedFormatMessage('https://example.org/files/Talk.ODP?download=1')).toMatch(/OpenDocument presentation/)
expect(unsupportedFormatMessage('https://example.org/files/Slides.KEY?download=1')).toMatch(/Keynote/)
expect(unsupportedFormatMessage(String.raw`C:\Users\me\Pictures\scan.PNG`)).toMatch(/“scan.PNG” is a picture/)
})

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 }> = {
'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.' },
'pages': { what: 'an Apple Pages document', instead: 'Export it from Pages as .pdf or .docx, and load that file.' },
'numbers': { what: 'an Apple Numbers spreadsheet', instead: 'Export it from Numbers as .xlsx or .csv, and load that file.' },
Expand Down
4 changes: 2 additions & 2 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,8 @@ through `readOds`. `.xls` and encrypted files fail with what to do. `@opencraw/o
a path, bytes, a Blob or a stream, in Node or a browser; zip entries capped by declared size, XML entities never
expanded.

A presentation (`as: 'pptx'`, a presentation content type, `.pptx`) is read by `@opencraw/office-reader`'s
`readPptx` into a deck: slides in presentation order, each with its text boxes (points, reading order,
A presentation (`as: 'pptx'`, a presentation content type, `.pptx`, and an OpenDocument presentation, `.odp`) is read by
`@opencraw/office-reader`'s `readPptx` (`readOdp` for `.odp`) into a deck: slides in presentation order, each with its text boxes (points, reading order,
placeholders positioned through layout and master, group transforms applied; slide numbers, dates, footers
left out), native tables as sheets with merges, charts from their caches, notes, hidden flag. `table` reads
native tables through the workbook algorithm, or text boxes (`shapes: true`) through the PDF algorithm, one
Expand Down
39 changes: 27 additions & 12 deletions packages/core/src/deck-document/read-pptx.client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Deck } from '@opencraw/office-reader/pptx'
import { openDocumentFormat, openDocumentMimetype } from '../opendocument-sniff'
import type { DeckDocument } from './deck-document.model'

/**
Expand All @@ -11,24 +13,37 @@ import type { DeckDocument } from './deck-document.model'
* @param source - Where it came from, for messages.
* @returns The deck.
* @throws Error naming the source, and saying what to do, for a file that is
* not a readable presentation (a legacy `.ppt`, a password-protected file, an `.odp`…).
* not a readable presentation (a legacy `.ppt`, a password-protected file…). An OpenDocument presentation (`.odp`) reads the same way.
*/
export async function readPptxDeck (bytes: Uint8Array, source: string): Promise<DeckDocument> {
const mimetype = openDocumentMimetype(bytes)
if (mimetype !== undefined && openDocumentFormat(mimetype) === 'pptx') {
const { readOdp, OfficeReadError } = await import('@opencraw/office-reader/odp')
try {
return deckOf(await readOdp(bytes))
} catch (error) {
if (error instanceof OfficeReadError) throw new Error(`${source}: ${error.message}`, { cause: error })
throw error
}
}
const { readPptx, OfficeReadError } = await import('@opencraw/office-reader/pptx')
try {
const deck = await readPptx(bytes)

return {
kind: 'deck',
width: deck.width,
height: deck.height,
slides: deck.slides.map(slide => ({
...slide,
tables: slide.tables.map(table => ({ name: table.name, rows: table.rows, merges: table.merges })),
})),
}
return deckOf(await readPptx(bytes))
} catch (error) {
if (error instanceof OfficeReadError) throw new Error(`${source}: ${error.message}`, { cause: error })
throw error
}
}

/** A deck the office reader gave, as a deck document. */
function deckOf (deck: Deck): DeckDocument {
return {
kind: 'deck',
width: deck.width,
height: deck.height,
slides: deck.slides.map(slide => ({
...slide,
tables: slide.tables.map(table => ({ name: table.name, rows: table.rows, merges: table.merges })),
})),
}
}
2 changes: 2 additions & 0 deletions packages/core/src/http-session/body-sniff.algorithm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ describe('sniffFormat', () => {

it('knows an OpenDocument file by the media type its first entry declares', () => {
expect(sniffFormat(officeFixture('opendocument-spreadsheet', 'incentivi.ods'), '/file')).toBe('xlsx')
expect(sniffFormat(officeFixture('opendocument-text', 'incentivi.odt'), '/file')).toBe('docx')
expect(sniffFormat(officeFixture('opendocument-presentation', 'incentivi.odp'), '/file')).toBe('pptx')
})

it('leaves a zip that is not an Office package, plain text and nothing to the extension', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/http-session/http.client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ describe('HttpClient', () => {
const local = await client.send({ url: pathToFileURL(join(__dirname, '..', '..', '..', 'office-reader', 'src', 'presentation', 'fixtures', 'incentivi.pptx')).href })
const titles = local.body.kind === 'deck' ? local.body.slides.map(slide => slide.title) : []
expect(titles).toEqual(['Incentivi giugno', 'Griglia prezzi Jeep', 'Vendite', 'Bozza'])
const odp = await client.send({ url: pathToFileURL(join(__dirname, '..', '..', '..', 'office-reader', 'src', 'opendocument-presentation', 'fixtures', 'incentivi.odp')).href })
expect(odp.body).toEqual(local.body)
} finally {
await client.dispose()
}
Expand Down
10 changes: 7 additions & 3 deletions packages/office-reader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
Reads Office files into plain objects:

- **workbooks** (`.xlsx`, `.xlsm`) as sheets of cells, with their merged ranges and hidden rows;
- **presentations** (`.pptx`, `.pptm`, `.ppsx`) as slides of positioned text boxes, tables, chart data and
- **presentations** (`.pptx`, `.pptm`, `.ppsx`, and OpenDocument `.odp`) as slides of positioned text boxes, tables, chart data and
speaker notes;
- **documents** (`.docx`, `.docm`, `.dotx`, and OpenDocument `.odt` through `readOdt`) as paragraphs with their heading and list levels and links, tables
with their merged cells, headers, footers and notes.
Expand Down Expand Up @@ -127,7 +127,7 @@ 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 file this reader does not read yet (`.odp`), or a different kind of OpenDocument file than the one asked for |
| `unsupported-format` | an OpenDocument file of a kind this reader does not read (a drawing, a formula…), or a different kind of OpenDocument file than the one asked for |
| `not-ods` / `not-odt` | a zip package that is not an OpenDocument spreadsheet / text document |
| `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 |
Expand All @@ -143,6 +143,10 @@ import { readPptx } from '@opencraw/office-reader/pptx'
const deck = await readPptx('./incentivi.pptx')
```

An OpenDocument presentation (`.odp`, from LibreOffice, Google Slides or PowerPoint) is read by `readOdp` into the same `Deck`
(`import { readOdp } from '@opencraw/office-reader/odp'`, same options): text boxes and shapes with their positions in points, the title, tables with
merged cells, charts from the data table they keep, speaker notes and the hidden flag. Footers, dates and slide numbers are left out.

```ts
// { width: 960, height: 540, slides: [
// { number: 1, title: 'Incentivi giugno', hidden: false,
Expand Down Expand Up @@ -268,7 +272,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 `.odp`, and binary `.xlsb`.** These are refused with a code. (OpenDocument spreadsheets and text documents, `.ods` and `.odt`, are read.)
- **Legacy `.xls`, `.ppt`, `.doc`, and binary `.xlsb`.** These are refused with a code. (OpenDocument spreadsheets, text documents and presentations, `.ods`, `.odt` and `.odp`, 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.
5 changes: 5 additions & 0 deletions packages/office-reader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
"types": "./dist/src/opendocument-text/index.d.ts",
"import": "./dist/odt.esm.js",
"default": "./dist/odt.esm.js"
},
"./odp": {
"types": "./dist/src/opendocument-presentation/index.d.ts",
"import": "./dist/odp.esm.js",
"default": "./dist/odp.esm.js"
}
},
"files": [
Expand Down
1 change: 1 addition & 0 deletions packages/office-reader/rollup.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ module.exports = withNx(
docx: `${__dirname}/src/document/index.ts`,
ods: `${__dirname}/src/opendocument-spreadsheet/index.ts`,
odt: `${__dirname}/src/opendocument-text/index.ts`,
odp: `${__dirname}/src/opendocument-presentation/index.ts`,
},
// Added by MoNecromanCI. rollup hands sourcemapPathTransform an OS-NATIVE
// path with one parent segment too many, so `sources` resolve to nothing
Expand Down
2 changes: 2 additions & 0 deletions packages/office-reader/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export { readOds } from './opendocument-spreadsheet'
export type { ReadOdsOptions } from './opendocument-spreadsheet'
export { readOdt } from './opendocument-text'
export type { ReadOdtOptions } from './opendocument-text'
export { readOdp } from './opendocument-presentation'
export type { ReadOdpOptions } from './opendocument-presentation'
export { readPptx } from './presentation'
export type { ReadPptxOptions, Deck, Slide, SlideShape, SlideChart, ChartSeries, SlideFilter } from './presentation'
export { readDocx } from './document'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export class OoxmlPackage {
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.text')) throw new OfficeReadError('unsupported-format', 'an OpenDocument text document (.odt): read it with readOdt, or save it as .docx')
if (mimetype.startsWith('application/vnd.oasis.opendocument.presentation')) throw new OfficeReadError('unsupported-format', 'an OpenDocument presentation (.odp): read it with readOdp, or save it as .pptx')
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
Expand Down
Binary file not shown.
4 changes: 4 additions & 0 deletions packages/office-reader/src/opendocument-presentation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { readOdp } from './read-odp.use-case'
export type { ReadOdpOptions } from './read-odp.use-case'
export { OfficeReadError } from '../read-error'
export type { OfficeReadErrorCode } from '../read-error'
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { walkXml } from '../ooxml-package'
import type { SlideChart } from '../presentation'
import type { ValueMode } from '../spreadsheet'

/** The chart types the `.pptx` reader names differently: ODF's `circle` is a pie and `ring` a doughnut. */
const TYPES: Readonly<Record<string, string>> = { circle: 'pie', ring: 'doughnut' }

interface OpenCell {
text: string
value: string | undefined
}

/**
* Reads a chart object of an OpenDocument presentation (the `content.xml` of `Object 1`): its type, its title once (an axis's title is not it), and
* its series from the data table the chart keeps with it: the header row names the series, the first column holds the categories and each other
* column is one series' values, a missing point `null` (`''` in text mode).
*
* @param xml - The object's `content.xml`.
* @param mode - `typed`: values as numbers; `text`: as text.
* @returns The chart, or `undefined` when the object is not a chart (a formula, an embedded spreadsheet) or has no data.
*/
export function odpChart (xml: string, mode: ValueMode): SlideChart<number | null | string> | undefined {
let type: string | undefined
let title: string | undefined
let inTitle = false
let axisDepth = 0
let inTable = false
let header = false
let row: OpenCell[] | undefined
let cell: OpenCell | undefined
let inParagraph = false
const rows: { header: boolean, cells: OpenCell[] }[] = []
walkXml(xml, {
open: (name, attributes) => {
switch (name) {
case 'chart': {
type ??= attributes['chart:class']?.replace(/^chart:/, '')
break
}
case 'axis': {
axisDepth += 1
break
}
case 'title': {
if (axisDepth === 0 && title === undefined) inTitle = true
break
}
case 'table': {
inTable = true
header = false
break
}
case 'table-header-rows': {
header = true
break
}
case 'table-rows': {
header = false
break
}
case 'table-row': {
if (inTable) row = []
break
}
case 'table-cell': {
if (row !== undefined) cell = { text: '', value: attributes['office:value'] }
break
}
case 'p': {
inParagraph = true
if (inTitle) title = ''
break
}
// No default
}
},
text: (text) => {
if (!inParagraph) return
if (inTitle) title = (title ?? '') + text
else if (cell !== undefined) cell.text += text
},
close: (name) => {
switch (name) {
case 'p': {
inParagraph = false
break
}
case 'title': {
inTitle = false
break
}
case 'axis': {
axisDepth -= 1
break
}
case 'table-cell': {
if (cell !== undefined) row?.push(cell)
cell = undefined
break
}
case 'table-row': {
if (row !== undefined) rows.push({ header, cells: row })
row = undefined
break
}
case 'table': {
inTable = false
break
}
// No default
}
},
})
if (type === undefined) return undefined
const names = rows.find(candidate => candidate.header)?.cells ?? []
const body = rows.filter(candidate => !candidate.header)
const columns = Math.max(0, ...body.map(candidate => candidate.cells.length))
if (body.length === 0 || columns < 2) return undefined
const categories = body.map(candidate => candidate.cells[0]?.text.trim() ?? '')
const series = Array.from({ length: columns - 1 }, (_, index) => ({
name: names[index + 1]?.text.trim() ?? '',
categories,
values: body.map((candidate) => {
const value = candidate.cells[index + 1]?.value
const number = value === undefined || value.trim() === '' ? undefined : Number(value)
if (number === undefined || Number.isNaN(number)) return mode === 'text' ? '' : null

return mode === 'text' ? String(number) : number
}),
}))
const kind = TYPES[type] ?? type

return { type: kind, ...(title !== undefined && title.trim() !== '' && { title: title.trim() }), series }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { pointsOf } from './odp-length.algorithm'

describe('pointsOf', () => {
it('converts the units a length is written in to points, to two decimals', () => {
expect(pointsOf('28cm')).toBe(793.7)
expect(pointsOf('15.75cm')).toBe(446.46)
expect(pointsOf('10in')).toBe(720)
expect(pointsOf('254mm')).toBe(720)
expect(pointsOf('12pt')).toBe(12)
expect(pointsOf('2pc')).toBe(24)
expect(pointsOf('16px')).toBe(12)
expect(pointsOf('-1.5cm')).toBe(-42.52)
expect(pointsOf('36')).toBe(36)
expect(pointsOf('.5in')).toBe(36)
})

it('says nothing for what is not a length', () => {
expect(pointsOf(undefined)).toBeUndefined()
expect(pointsOf('')).toBeUndefined()
expect(pointsOf('wide')).toBeUndefined()
expect(pointsOf('3furlongs')).toBeUndefined()
expect(pointsOf('50%')).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/** Points in one of each unit an OpenDocument length is written in. */
const POINTS_PER: Readonly<Record<string, number>> = {
pt: 1,
cm: 72 / 2.54,
mm: 72 / 25.4,
in: 72,
pc: 12,
px: 0.75,
}

/**
* An OpenDocument length (`28cm`, `10.5in`, `4pt`; a bare number is points) in points, rounded to two decimals as the `.pptx` reader gives positions.
*
* @param value - The attribute's text.
* @returns The length in points, or `undefined` when there is none or it is not a length.
*/
export function pointsOf (value: string | undefined): number | undefined {
if (value === undefined) return undefined
const match = /^(-?(?:\d+(?:\.\d+)?|\.\d+))([a-z]*)$/i.exec(value.trim())
if (match === null) return undefined
const unit = match[2].toLowerCase() === '' ? 'pt' : match[2].toLowerCase()
const perUnit = POINTS_PER[unit]
if (perUnit === undefined) return undefined

return Math.round(Number(match[1]) * perUnit * 100) / 100
}
Loading
Loading