From 0fc7dc99da8cd676d23776ff2fdeb91ac6f244d2 Mon Sep 17 00:00:00 2001 From: Eduardo Russo Date: Sat, 17 Oct 2026 00:55:28 +0100 Subject: [PATCH 1/2] feat(office-reader,core): read OpenDocument presentations (.odp) like decks (0.9.0) readOdp gives the same Deck readPptx does: text boxes and shapes in points, title, tables with merged cells, charts from their data table, notes and the hidden flag. Core reads an .odp through the same deck conversion as a .pptx. ODT and ODP share one table reader. 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 | 9 +- .../recipe-start/unsupported-format.policy.ts | 1 - docs/requirements.md | 4 +- .../src/deck-document/read-pptx.client.ts | 39 +++- .../http-session/body-sniff.algorithm.test.ts | 2 + .../core/src/http-session/http.client.test.ts | 2 + 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 | 1 + .../fixtures/incentivi.odp | Bin 0 -> 2904 bytes .../src/opendocument-presentation/index.ts | 4 + .../odp-chart.mapper.ts | 134 +++++++++++ .../odp-length.algorithm.test.ts | 24 ++ .../odp-length.algorithm.ts | 26 +++ .../odp-page-size.mapper.ts | 51 ++++ .../odp-slides.mapper.ts | 219 ++++++++++++++++++ .../read-odp.use-case.test.ts | 154 ++++++++++++ .../read-odp.use-case.ts | 83 +++++++ .../src/opendocument-table/index.ts | 1 + .../odf-table.model.test.ts | 60 +++++ .../src/opendocument-table/odf-table.model.ts | 92 ++++++++ .../opendocument-text/odt-blocks.mapper.ts | 74 ++---- 25 files changed, 915 insertions(+), 85 deletions(-) create mode 100644 packages/office-reader/src/opendocument-presentation/fixtures/incentivi.odp create mode 100644 packages/office-reader/src/opendocument-presentation/index.ts create mode 100644 packages/office-reader/src/opendocument-presentation/odp-chart.mapper.ts create mode 100644 packages/office-reader/src/opendocument-presentation/odp-length.algorithm.test.ts create mode 100644 packages/office-reader/src/opendocument-presentation/odp-length.algorithm.ts create mode 100644 packages/office-reader/src/opendocument-presentation/odp-page-size.mapper.ts create mode 100644 packages/office-reader/src/opendocument-presentation/odp-slides.mapper.ts create mode 100644 packages/office-reader/src/opendocument-presentation/read-odp.use-case.test.ts create mode 100644 packages/office-reader/src/opendocument-presentation/read-odp.use-case.ts create mode 100644 packages/office-reader/src/opendocument-table/index.ts create mode 100644 packages/office-reader/src/opendocument-table/odf-table.model.test.ts create mode 100644 packages/office-reader/src/opendocument-table/odf-table.model.ts diff --git a/apps/studio-ui/src/app/toolbar.component.tsx b/apps/studio-ui/src/app/toolbar.component.tsx index 1534441f..527d6f7a 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,.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 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 48947837..f8177981 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,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/) }) 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 0bcac868..4926fff8 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 = { - '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.' }, diff --git a/docs/requirements.md b/docs/requirements.md index 35b385aa..1e39b511 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -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 diff --git a/packages/core/src/deck-document/read-pptx.client.ts b/packages/core/src/deck-document/read-pptx.client.ts index 402aa9a4..70cf0ca1 100644 --- a/packages/core/src/deck-document/read-pptx.client.ts +++ b/packages/core/src/deck-document/read-pptx.client.ts @@ -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' /** @@ -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 { + 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 })), + })), + } +} 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 528fa478..2fdec9a1 100644 --- a/packages/core/src/http-session/body-sniff.algorithm.test.ts +++ b/packages/core/src/http-session/body-sniff.algorithm.test.ts @@ -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', () => { diff --git a/packages/core/src/http-session/http.client.test.ts b/packages/core/src/http-session/http.client.test.ts index 644f1801..1d487949 100644 --- a/packages/core/src/http-session/http.client.test.ts +++ b/packages/core/src/http-session/http.client.test.ts @@ -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() } diff --git a/packages/office-reader/README.md b/packages/office-reader/README.md index 6575537a..0a0bb32c 100644 --- a/packages/office-reader/README.md +++ b/packages/office-reader/README.md @@ -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. @@ -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 | @@ -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, @@ -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. diff --git a/packages/office-reader/package.json b/packages/office-reader/package.json index 674e08a4..fc5e4e7d 100644 --- a/packages/office-reader/package.json +++ b/packages/office-reader/package.json @@ -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": [ diff --git a/packages/office-reader/rollup.config.cjs b/packages/office-reader/rollup.config.cjs index 8a501b1d..20a20fdb 100644 --- a/packages/office-reader/rollup.config.cjs +++ b/packages/office-reader/rollup.config.cjs @@ -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 diff --git a/packages/office-reader/src/index.ts b/packages/office-reader/src/index.ts index 36dfebbf..af2bdd79 100644 --- a/packages/office-reader/src/index.ts +++ b/packages/office-reader/src/index.ts @@ -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' 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 354c2443..37f58b1f 100644 --- a/packages/office-reader/src/ooxml-package/ooxml-package.client.ts +++ b/packages/office-reader/src/ooxml-package/ooxml-package.client.ts @@ -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 diff --git a/packages/office-reader/src/opendocument-presentation/fixtures/incentivi.odp b/packages/office-reader/src/opendocument-presentation/fixtures/incentivi.odp new file mode 100644 index 0000000000000000000000000000000000000000..b0bd42fb01433017b560d467e85b916d4af352ca GIT binary patch literal 2904 zcmZ`*c{CJk7az@Ao|Co_qgze!p{`bMO7#i-2+~(V;(aRuP#G5ZF zJ&-oYB*o{6Q7ks3;gIecElO?SaKE5(ttH9}${ZB+J({lASr2kLO{7<)<8eGE9AGx7 zl*2^@{z)zZLJ5dUqFdXb3gIg}MJ+Bg-O-4_S2YINezdS*2_cN{p}sL8{JcG+h*OaT70OjA0(5jR-t4RsY>Vr0L!iBWBFfh=eiU);dwxf|}( ze6G0(s|kBn;}Z&sF{XkNdykh1Bsed3i04P(i(EjJZKkY{cRc zz8`wAJgmPwoHN4wk-*5R%X#&C$x*x&RdQ#8vCPP&=Xxye4@$3>h^DL6(ZES#U}{6Z zbVoqP%_Ie@xI~#_u?He^akKslJWYD`atuuHVc2ke-)-J&0`~d|ois#dK4%Ts&4e7#}^A@7_CZDN~Xl zQmLO5o?_PEuXb~_BZh7(Fr?WI%HuEz!~HRKo$KHhZ9wORH;>MIZQ(bEO^5QbvKAEz z=fa%AEvyds)=a|blkd!9gl+=`3f2(ADGyVtz9dRXp5Jnc%iF8zlA&v_Msz)dxw{&2kFXsUvCf&HEF?~ai}oBovlTRFF@-IFZ_D@#rDHjg5 zwKZQZS}pcjZ4$zpn6Exm?9YF`m5r!BeqTar$@?ZQmuZ!V2$=?|oK6eBJQFBj7jB>I z@3WyKA)f6+>$@Au$Hsb(1*!(~uVdH?somO0}rj~jWr7vMx zr@_Qilwg|@V#iuyDe3PhyepzaozI%mjxhp6N6h4eA8~&|xFYK|`Q1MHQ-x%A_ujC9bs>Si`=*c0 zzqwu`so|c%U|md$$XR8m7dj|&(R52>6%H1qb>=oh>;KvsA_p9JNu0hr#K}R^t8wJs zyz+))iTo8;zt_KRUHtvZ6N5`L_hWqp1EIUce*M~#P5VQ@Bh4mzN=dlfb%#5)NeD;kx}SL#MZc4!&C zzKdF{72G2XBK&4uxFbM6k7`pHj4xhN^y-slK)Fjo3Cwss4`-Coy9C1(@A0mUpf)CQ zVPv+0I(cClQ^(AU+M@_po|g;?qPK!)5n8TQ#p>wCDO6Tk@N33H^zkYeFQ${`FPV`rvK#YDE3fE!>r(H)G_N?xY4rHZOG08wOo@MJyZ}t(?nF zpGwtZlQ9YmcbI6l`@Lv^-V#7O|c zlU>Ckx+^A(oa7r%n+e&T?@Kxit_!fvG%)c`$buHYO>W_PZXbc(xu>@9 zvg|B;pJWy;I5f&rRt5aaoT<6k5|;tlPh0;pfd%v)D3t(~_+y8jnrKsH-Ub_#NE~7w z*K4MS?Tbbx?@LVLN4Bkgh@IH$J@u;Abv(rAJjmkZfZ{pn$$K3qz=EG=)_r#W^f%LO zb*al2k0vJ$9h)5K1-@GT$dUf2qwIK5s+ORgrN}i$L6JEnL6OO$isbyGPtn>YK6sTk zz($Oj15Nl%yLp9gVWIO{m&cH(h7$kNI8Sac|$BzCQ{|FIa%wzi#I$#>khKbXri z;Qn?bXM^)aM2l%`mR)+jP1i&;Ki0wU>jZGIrYuhulkPTDVyiFOGT>Idr%Q1qLY&O) z6Wf2yr0xhSE`sSUF?uqi5Y}|6#5%O3vQ=<-&B?0UNFu}XVSf%fy5{eDS2ko2ed=PO zQ&$8%Vq^9!qKubk87FPHkmhGOFL~VJb}qjVs%Q*X(rl(7o*w$VEy%j^MN5X_J#$7= zSTb3CXw)8N(M!K1CM51g|D;!Gp3*F@E-#q=Vm`^>Rup^VI+=&Jq*N+(gi%VG6qG}ck(%E5~5!FUFuBf3+_JSd-Z`)v5;4H z<|F$y>`JiX{B4de^$*~3w%5> = { 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 | 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 } +} diff --git a/packages/office-reader/src/opendocument-presentation/odp-length.algorithm.test.ts b/packages/office-reader/src/opendocument-presentation/odp-length.algorithm.test.ts new file mode 100644 index 00000000..afcac715 --- /dev/null +++ b/packages/office-reader/src/opendocument-presentation/odp-length.algorithm.test.ts @@ -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() + }) +}) diff --git a/packages/office-reader/src/opendocument-presentation/odp-length.algorithm.ts b/packages/office-reader/src/opendocument-presentation/odp-length.algorithm.ts new file mode 100644 index 00000000..165dd44a --- /dev/null +++ b/packages/office-reader/src/opendocument-presentation/odp-length.algorithm.ts @@ -0,0 +1,26 @@ +/** Points in one of each unit an OpenDocument length is written in. */ +const POINTS_PER: Readonly> = { + 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 +} diff --git a/packages/office-reader/src/opendocument-presentation/odp-page-size.mapper.ts b/packages/office-reader/src/opendocument-presentation/odp-page-size.mapper.ts new file mode 100644 index 00000000..82285aab --- /dev/null +++ b/packages/office-reader/src/opendocument-presentation/odp-page-size.mapper.ts @@ -0,0 +1,51 @@ +import { walkXml } from '../ooxml-package' +import { pointsOf } from './odp-length.algorithm' + +/** A slide's size in points. */ +export interface PageSize { + width: number + height: number +} + +/** 28 × 15.75 cm, what LibreOffice makes a 16:9 presentation, for a file that says nothing. */ +const DEFAULT_SIZE: PageSize = { width: 793.7, height: 446.46 } + +/** + * The slide size an OpenDocument presentation declares: the page layout of the master page the first slide uses, else of the first master page. + * + * @param xml - `styles.xml` (page layouts and master pages), plus `content.xml` when it holds automatic page layouts, as text. + * @param masterName - The master page the first slide names, when it names one. + * @returns The size in points. + */ +export function odpPageSize (xml: string, masterName: string | undefined): PageSize { + const layouts = new Map>() + const masters = new Map() + let first: string | undefined + let layout: Partial | undefined + walkXml(xml, { + open: (name, attributes) => { + if (name === 'page-layout') { + layout = {} + const layoutName = attributes['style:name'] + if (layoutName !== undefined) layouts.set(layoutName, layout) + } else if (name === 'page-layout-properties' && layout !== undefined) { + layout.width = pointsOf(attributes['fo:page-width']) + layout.height = pointsOf(attributes['fo:page-height']) + } else if (name === 'master-page') { + const masterPage = attributes['style:name'] + const layoutName = attributes['style:page-layout-name'] + if (masterPage !== undefined && layoutName !== undefined) { + masters.set(masterPage, layoutName) + first ??= masterPage + } + } + }, + close: (name) => { + if (name === 'page-layout') layout = undefined + }, + }) + const layoutName = masters.get(masterName ?? '') ?? masters.get(first ?? '') + const size = layoutName === undefined ? undefined : layouts.get(layoutName) + + return { width: size?.width ?? DEFAULT_SIZE.width, height: size?.height ?? DEFAULT_SIZE.height } +} diff --git a/packages/office-reader/src/opendocument-presentation/odp-slides.mapper.ts b/packages/office-reader/src/opendocument-presentation/odp-slides.mapper.ts new file mode 100644 index 00000000..cf2caebf --- /dev/null +++ b/packages/office-reader/src/opendocument-presentation/odp-slides.mapper.ts @@ -0,0 +1,219 @@ +import { OdfTable } from '../opendocument-table' +import { walkXml } from '../ooxml-package' +import type { SlideShape } from '../presentation' +import type { Sheet } from '../spreadsheet' +import { pointsOf } from './odp-length.algorithm' + +/** One slide as `content.xml` holds it, before charts, notes and filters are applied. */ +export interface OdpSlide { + /** The master page it uses. */ + master?: string + /** The automatic style of the page: where `presentation:visibility="hidden"` is written. */ + style?: string + title?: string + shapes: SlideShape[] + tables: Sheet[] + /** The objects (charts) on the slide, as the links to their folders in the package (`./Object 1`). */ + chartLinks: string[] + notes: string +} + +/** What `content.xml` holds: the slides, and the page styles that hide a slide. */ +export interface OdpContent { + slides: OdpSlide[] + hiddenStyles: Set +} + +interface Target { + x: number + y: number + width: number + height: number + /** `presentation:class`: `title`, `outline`, `notes`, `footer`... */ + placeholder: string | undefined + paragraphs: string[] + chartLink?: string +} + +/** Elements that carry their own position and may hold text, besides `draw:frame`. */ +const SHAPES = new Set(['custom-shape', 'rect', 'ellipse', 'circle', 'polygon', 'polyline', 'path', 'caption', 'line', 'connector', 'measure']) + +/** The placeholders that are not the slide's content: its footer, its date and its slide number. */ +const FURNITURE = new Set(['footer', 'date-time', 'page-number', 'header']) + +/** The name the `.pptx` reader gives a placeholder, for the classes that have one. */ +const PLACEHOLDER_NAMES: Readonly> = { title: 'title', subtitle: 'subTitle', outline: 'body', text: 'body' } + +function count (value: string | undefined): number { + const number = Math.trunc(Number(value ?? '')) + + return Number.isNaN(number) || number < 1 ? 1 : number +} + +/** + * Reads the slides of an OpenDocument presentation's `content.xml`: each slide's text boxes with their positions in points (a frame, or a shape + * that holds text), its title, its tables, the links to its charts and its speaker notes. Footers, dates and slide numbers are left out, text + * boxes come in reading order (top to bottom, left to right), and a table is a block of its own, its text not part of any box. + * + * @param xml - The text of `content.xml`. + * @returns The slides in order, and the page styles that hide one. + */ +export function odpSlides (xml: string): OdpContent { + const slides: OdpSlide[] = [] + const hiddenStyles = new Set() + let styleName = '' + let slide: OdpSlide | undefined + let inNotes = false + let skipping = 0 + const targets: Target[] = [] + const tables: OdfTable[] = [] + const paragraphs: string[] = [] + + function appendText (text: string): void { + if (skipping === 0 && paragraphs.length > 0) paragraphs[paragraphs.length - 1] += text + } + + function finishTarget (target: Target, current: OdpSlide): void { + const text = target.paragraphs.join('\n').trim() + if (target.chartLink !== undefined) current.chartLinks.push(target.chartLink) + if (text === '' || FURNITURE.has(target.placeholder ?? '')) return + if (inNotes) { + if (target.placeholder === 'notes') current.notes += `${current.notes === '' ? '' : '\n'}${text}` + + return + } + const placeholder = target.placeholder === undefined ? undefined : PLACEHOLDER_NAMES[target.placeholder] + if (placeholder === 'title') current.title ??= text + current.shapes.push({ x: target.x, y: target.y, width: target.width, height: target.height, text, ...(placeholder !== undefined && { placeholder }) }) + } + + walkXml(xml, { + open: (name, attributes) => { + if (name === 'annotation') skipping += 1 + if (skipping > 0) return + switch (name) { + case 'style': { + styleName = attributes['style:name'] ?? '' + break + } + case 'drawing-page-properties': { + if (attributes['presentation:visibility'] === 'hidden') hiddenStyles.add(styleName) + break + } + case 'page': { + slide = { ...(attributes['draw:master-page-name'] !== undefined && { master: attributes['draw:master-page-name'] }), ...(attributes['draw:style-name'] !== undefined && { style: attributes['draw:style-name'] }), shapes: [], tables: [], chartLinks: [], notes: '' } + slides.push(slide) + break + } + case 'notes': { + inNotes = slide !== undefined + break + } + case 'object': { + const link = attributes['xlink:href'] + const target = targets.at(-1) + if (target !== undefined && link !== undefined) target.chartLink = link + break + } + case 'table': { + if (slide !== undefined && !inNotes) tables.push(new OdfTable()) + break + } + case 'table-row': { + tables.at(-1)?.openRow(attributes) + break + } + case 'table-cell': + case 'covered-table-cell': { + tables.at(-1)?.openCell(attributes) + break + } + case 'p': + case 'h': { + paragraphs.push('') + break + } + case 's': { + appendText(' '.repeat(count(attributes['text:c']))) + break + } + case 'tab': { + appendText('\t') + break + } + case 'line-break': { + appendText('\n') + break + } + default: { + if (name === 'frame' || SHAPES.has(name)) { + targets.push({ + x: pointsOf(attributes['svg:x']) ?? 0, + y: pointsOf(attributes['svg:y']) ?? 0, + width: pointsOf(attributes['svg:width']) ?? 0, + height: pointsOf(attributes['svg:height']) ?? 0, + placeholder: attributes['presentation:class'], + paragraphs: [], + }) + } + } + } + }, + text: (text) => { + appendText(text) + }, + close: (name) => { + if (name === 'annotation') { + skipping -= 1 + + return + } + if (skipping > 0) return + switch (name) { + case 'p': + case 'h': { + const text = paragraphs.pop() + if (text === undefined) break + const table = tables.at(-1) + if (table?.inCell === true) table.addParagraph(text) + else targets.at(-1)?.paragraphs.push(text) + break + } + case 'table-cell': + case 'covered-table-cell': { + tables.at(-1)?.closeCell() + break + } + case 'table-row': { + tables.at(-1)?.closeRow() + break + } + case 'table': { + const table = tables.pop() + if (table === undefined || slide === undefined) break + const outer = tables.at(-1) + if (outer?.inCell === true) outer.addParagraph(table.text) + else slide.tables.push({ name: `table ${String(slide.tables.length + 1)}`, hidden: false, rows: table.rows, hiddenRows: [], merges: table.merges }) + break + } + case 'notes': { + inNotes = false + break + } + case 'page': { + if (slide !== undefined) slide.shapes.sort((first, second) => Math.round(first.y) - Math.round(second.y) || first.x - second.x) + slide = undefined + break + } + default: { + if (name === 'frame' || SHAPES.has(name)) { + const target = targets.pop() + if (target !== undefined && slide !== undefined) finishTarget(target, slide) + } + } + } + }, + }) + + return { slides, hiddenStyles } +} diff --git a/packages/office-reader/src/opendocument-presentation/read-odp.use-case.test.ts b/packages/office-reader/src/opendocument-presentation/read-odp.use-case.test.ts new file mode 100644 index 00000000..2931b7c3 --- /dev/null +++ b/packages/office-reader/src/opendocument-presentation/read-odp.use-case.test.ts @@ -0,0 +1,154 @@ +import { join } from 'node:path' +import { strToU8, zipSync } from 'fflate' +import { readPptx } from '../presentation' +import { readOdp } from './read-odp.use-case' +import type { ReadOdpOptions } from './read-odp.use-case' + +const NAMESPACES = 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0" xmlns:chart="urn:oasis:names:tc:opendocument:xmlns:chart:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink"' +const ODP_MIMETYPE = 'application/vnd.oasis.opendocument.presentation' + +interface Parts { + pages: string + styles?: string + layout?: string + objects?: Record +} + +/** An `.odp` as the apps write it: a stored `mimetype` first, then `content.xml`, `styles.xml` and chart folders. */ +function odp ({ pages, styles = '', layout = '', objects = {} }: Parts): Uint8Array { + const content = `${styles}${pages}` + const stylesXml = `${layout}` + const folders = Object.fromEntries(Object.entries(objects).map(([name, xml]) => [`${name}/content.xml`, strToU8(xml)])) + + return zipSync({ 'mimetype': [strToU8(ODP_MIMETYPE), { level: 0 }], 'content.xml': strToU8(content), 'styles.xml': strToU8(stylesXml), ...folders }) +} + +const p = (text: string): string => `${text}` +const name = (text: string): string => `${text}` +const frame = (geometry: string, content: string, klass = ''): string => `${content}` +const at = (x: number, y: number, width = 100, height = 20): string => `svg:x="${String(x)}pt" svg:y="${String(y)}pt" svg:width="${String(width)}pt" svg:height="${String(height)}pt"` +const page = (content: string, style = 'dp1'): string => `${content}` + +async function slidesOf (pages: string, options?: ReadOdpOptions, extra: Omit = {}): Promise>['slides']> { + const deck = await readOdp(odp({ pages, ...extra }), options) + + return deck.slides +} + +describe('readOdp', () => { + it('reads each slide\'s text boxes in points, in reading order, with the title and the placeholder', async () => { + const title = frame(at(60, 30, 840, 60), p('Incentivi'), 'title') + const lower = frame(at(60, 200), p('Second') + p('line')) + const upper = frame(at(300, 100), p('Right')) + const left = frame(at(60, 100), p('Left'), 'outline') + const slides = await slidesOf(page(lower + upper + left + title)) + + expect(slides).toEqual([{ + number: 1, + title: 'Incentivi', + hidden: false, + shapes: [ + { x: 60, y: 30, width: 840, height: 60, text: 'Incentivi', placeholder: 'title' }, + { x: 60, y: 100, width: 100, height: 20, text: 'Left', placeholder: 'body' }, + { x: 300, y: 100, width: 100, height: 20, text: 'Right' }, + { x: 60, y: 200, width: 100, height: 20, text: 'Second\nline' }, + ], + tables: [], + charts: [], + notes: '', + }]) + }) + + it('reads the units a position is written in, and the slide size from the page layout', async () => { + const box = frame('svg:x="2.54cm" svg:y="1in" svg:width="25.4mm" svg:height="36pt"', p('x')) + const layout = '' + const deck = await readOdp(odp({ pages: page(box), layout })) + + expect(deck.slides[0].shapes).toEqual([{ x: 72, y: 72, width: 72, height: 36, text: 'x' }]) + expect([deck.width, deck.height]).toEqual([793.7, 446.46]) + }) + + it('reads text in a shape too, with its spaces, tabs and line breaks, and leaves out footers, dates and slide numbers', async () => { + const shape = `a bcd` + const furniture = frame(at(0, 500), p('Footer'), 'footer') + frame(at(0, 520), p('1'), 'page-number') + frame(at(0, 540), p('1 June'), 'date-time') + const slides = await slidesOf(page(shape + furniture)) + + expect(slides[0].shapes).toEqual([{ x: 10, y: 10, width: 100, height: 20, text: 'a b\tc\nd' }]) + }) + + it('reads a table as a sheet with merged cells as ranges, its text not a text box', async () => { + const wide = 'Price' + const first = `${wide}` + const second = 'ListNet' + const table = `${first}${second}` + const slides = await slidesOf(page(table)) + + expect(slides[0].shapes).toEqual([]) + expect(slides[0].tables).toEqual([{ name: 'table 1', hidden: false, rows: [['Price', ''], ['List', 'Net']], hiddenRows: [], merges: ['A1:B1'] }]) + }) + + it('reads the speaker notes, a hidden slide by its page style, and the slides asked for', async () => { + const notes = `${frame(at(0, 400), p('Say this.') + p('Then that.'), 'notes')}` + const styles = '' + const pages = page(frame(at(0, 0), p('One'), 'title') + notes) + page(frame(at(0, 0), p('Two'), 'title'), 'dp9') + page(frame(at(0, 0), p('Three'), 'title')) + const all = await slidesOf(pages, undefined, { styles }) + + expect(all.map(slide => [slide.number, slide.title, slide.hidden, slide.notes])).toEqual([[1, 'One', false, 'Say this.\nThen that.'], [2, 'Two', true, ''], [3, 'Three', false, '']]) + const numbers = async (options: ReadOdpOptions): Promise => { + const chosen = await slidesOf(pages, options, { styles }) + + return chosen.map(slide => slide.number) + } + const withoutNotes = await slidesOf(pages, { notes: false }, { styles }) + + expect(await numbers({ slides: [3] })).toEqual([3]) + expect(await numbers({ slides: /^T/ })).toEqual([2, 3]) + expect(await numbers({ slides: slide => !slide.hidden })).toEqual([1, 3]) + expect(withoutNotes[0].notes).toBe('') + }) + + it('reads a chart from its data table: type, title once (not an axis\'s), series, categories and a missing point', async () => { + const cells = (...values: (number | undefined)[]): string => values.map(value => (value === undefined ? '' : `${String(value)}`)).join('') + const header = `${name('A')}${name('B')}` + const body = `${name('Q1')}${cells(1, 10)}${name('Q2')}${cells(2, undefined)}` + const chart = `ShareAxis${header}${body}` + const pages = page('') + const objects = { 'Object 1': chart } + const typed = await slidesOf(pages, undefined, { objects }) + const text = await slidesOf(pages, { values: 'text' }, { objects }) + const none = await slidesOf(pages, { charts: false }, { objects }) + + expect(typed[0].charts).toEqual([{ type: 'pie', title: 'Share', series: [{ name: 'A', categories: ['Q1', 'Q2'], values: [1, 2] }, { name: 'B', categories: ['Q1', 'Q2'], values: [10, null] }] }]) + expect(text[0].charts[0].series[1].values).toEqual(['10', '']) + expect(none[0].charts).toEqual([]) + }) + + it('skips an object that is not a chart, and comments', async () => { + const formula = `` + const comment = 'private' + const object = '' + const visible = frame(at(0, 0), p('Visible') + comment) + const objects = { 'Object 2': formula } + const slides = await slidesOf(page(object + visible), undefined, { objects }) + + expect(slides[0].charts).toEqual([]) + expect(slides[0].shapes.map(shape => shape.text)).toEqual(['Visible']) + }) + + it('refuses other files with the reason', async () => { + const odt = zipSync({ 'mimetype': [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }], 'content.xml': strToU8('') }) + + await expect(readOdp(odt)).rejects.toMatchObject({ code: 'unsupported-format' }) + await expect(readOdp(new TextEncoder().encode('plain'))).rejects.toMatchObject({ code: 'not-zip' }) + }) +}) + +describe('readOdp on a deck also saved as .pptx', () => { + it('gives the same slides, positions, tables, charts, notes and size as readPptx, in typed and text mode', async () => { + const odpPath = join(__dirname, 'fixtures', 'incentivi.odp') + const pptxPath = join(__dirname, '..', 'presentation', 'fixtures', 'incentivi.pptx') + + expect(await readOdp(odpPath)).toEqual(await readPptx(pptxPath)) + expect(await readOdp(odpPath, { values: 'text' })).toEqual(await readPptx(pptxPath, { values: 'text' })) + }) +}) diff --git a/packages/office-reader/src/opendocument-presentation/read-odp.use-case.ts b/packages/office-reader/src/opendocument-presentation/read-odp.use-case.ts new file mode 100644 index 00000000..2e437c3e --- /dev/null +++ b/packages/office-reader/src/opendocument-presentation/read-odp.use-case.ts @@ -0,0 +1,83 @@ +import { OoxmlPackage } from '../ooxml-package' +import type { PackageLimits } from '../ooxml-package' +import type { Deck, Slide, SlideChart, SlideFilter } from '../presentation' +import { readSource } from '../source-bytes' +import type { OfficeSource } from '../source-bytes' +import type { ValueMode } from '../spreadsheet' +import { odpChart } from './odp-chart.mapper' +import { odpPageSize } from './odp-page-size.mapper' +import { odpSlides } from './odp-slides.mapper' + +/** How to read an OpenDocument presentation; the same choices as `readPptx`. */ +export interface ReadOdpOptions { + /** Which slides to read; default all. */ + slides?: SlideFilter + /** `typed` (default): chart values as numbers; `text`: as text. */ + values?: ValueMode + /** Read speaker notes; default true. */ + notes?: boolean + /** Read charts; default true. */ + charts?: boolean + /** How much the file may inflate to. */ + limits?: PackageLimits +} + +/** The media type an OpenDocument presentation declares in its `mimetype` entry. */ +const ODP_MIMETYPE = 'application/vnd.oasis.opendocument.presentation' + +function selector (filter: SlideFilter | undefined): (slide: { number: number, title: string, hidden: boolean }) => boolean { + if (filter === undefined) return () => true + if (Array.isArray(filter)) return slide => filter.includes(slide.number) + if (filter instanceof RegExp) return slide => filter.test(slide.title) + + return filter +} + +/** The folder a chart object lives in, from the link the slide holds (`./Object 1`): its `content.xml`. */ +function chartPart (link: string): string { + return `${link.replace(/^\.\//, '').replace(/\/$/, '')}/content.xml` +} + +/** + * Reads an OpenDocument presentation (`.odp`, as LibreOffice, Google Slides and PowerPoint save it) into the same {@link Deck} model `readPptx` + * gives: every slide's text boxes with their positions in points, its title, its tables, its charts' data and its notes. Footers, dates and slide + * numbers are left out. + * + * @param source - A path, bytes, a Blob or a stream (see {@link OfficeSource}). + * @param options - Which slides, which value mode, what to leave out, which limits. + * @returns The deck. + * @throws OfficeReadError for a file that is not a readable presentation: `not-zip`, `legacy-format`, `unsupported-format` (another kind of file), `too-large`… + */ +export async function readOdp (source: OfficeSource, options: ReadOdpOptions & { values: 'text' }): Promise> +export async function readOdp (source: OfficeSource, options?: ReadOdpOptions): Promise +export async function readOdp (source: OfficeSource, options: ReadOdpOptions = {}): Promise> { + const pkg = OoxmlPackage.openOpenDocument(await readSource(source), ODP_MIMETYPE, options.limits) + const { slides: found, hiddenStyles } = odpSlides(pkg.text('content.xml')) + const mode = options.values ?? 'typed' + const selected = selector(options.slides) + const slides: Slide[] = [] + for (const [index, slide] of found.entries()) { + const hidden = slide.style !== undefined && hiddenStyles.has(slide.style) + const number = index + 1 + if (!selected({ number, title: slide.title ?? '', hidden })) continue + const charts: SlideChart[] = options.charts === false + ? [] + : slide.chartLinks.flatMap((link) => { + const chart = odpChart(pkg.text(chartPart(link)), mode) + + return chart === undefined ? [] : [chart] + }) + slides.push({ + number, + ...(slide.title !== undefined && { title: slide.title }), + hidden, + shapes: slide.shapes, + tables: slide.tables, + charts, + notes: options.notes === false ? '' : slide.notes, + }) + } + const size = odpPageSize(pkg.text('styles.xml'), found[0]?.master) + + return { width: size.width, height: size.height, slides } +} diff --git a/packages/office-reader/src/opendocument-table/index.ts b/packages/office-reader/src/opendocument-table/index.ts new file mode 100644 index 00000000..f03041d5 --- /dev/null +++ b/packages/office-reader/src/opendocument-table/index.ts @@ -0,0 +1 @@ +export { OdfTable } from './odf-table.model' diff --git a/packages/office-reader/src/opendocument-table/odf-table.model.test.ts b/packages/office-reader/src/opendocument-table/odf-table.model.test.ts new file mode 100644 index 00000000..696d2498 --- /dev/null +++ b/packages/office-reader/src/opendocument-table/odf-table.model.test.ts @@ -0,0 +1,60 @@ +import { OdfTable } from './odf-table.model' + +function fill (table: OdfTable, rows: { attributes?: Record, cells: { attributes?: Record, text?: string }[] }[]): void { + for (const row of rows) { + table.openRow(row.attributes ?? {}) + for (const cell of row.cells) { + table.openCell(cell.attributes ?? {}) + if (cell.text !== undefined) table.addParagraph(cell.text) + table.closeCell() + } + table.closeRow() + } +} + +describe('OdfTable', () => { + it('collects rows of cell texts, joining a cell\'s paragraphs and trimming', () => { + const table = new OdfTable() + fill(table, [{ cells: [{ text: ' a ' }, { text: 'b' }] }, { cells: [{ text: 'c' }, {}] }]) + + expect(table.rows).toEqual([['a', 'b'], ['c', '']]) + expect(table.merges).toEqual([]) + }) + + it('records merged ranges, the covered cells staying in the grid as empty text', () => { + const table = new OdfTable() + fill(table, [ + { cells: [{ attributes: { 'table:number-columns-spanned': '2', 'table:number-rows-spanned': '2' }, text: 'Title' }, { text: 'covered' }, { text: 'x' }] }, + { cells: [{ text: 'a' }, { text: 'b' }, { text: 'c' }] }, + ]) + + expect(table.merges).toEqual(['A1:B2']) + }) + + it('repeats a cell or a row as many times as the file says, up to a limit', () => { + const table = new OdfTable() + fill(table, [ + { attributes: { 'table:number-rows-repeated': '2' }, cells: [{ attributes: { 'table:number-columns-repeated': '3' }, text: 'x' }] }, + { attributes: { 'table:number-rows-repeated': '999999' }, cells: [{ attributes: { 'table:number-columns-repeated': '999999' } }] }, + ]) + + expect(table.rows.slice(0, 2)).toEqual([['x', 'x', 'x'], ['x', 'x', 'x']]) + expect(table.rows).toHaveLength(2 + 1024) + expect(table.rows[2]).toHaveLength(1024) + }) + + it('says whether a cell is open, and gives its text for the cell a table is nested in', () => { + const table = new OdfTable() + table.openRow({}) + expect(table.inCell).toBe(false) + table.openCell({}) + expect(table.inCell).toBe(true) + table.addParagraph('one') + table.closeCell() + table.openCell({}) + table.closeCell() + table.closeRow() + + expect(table.text).toBe('one') + }) +}) diff --git a/packages/office-reader/src/opendocument-table/odf-table.model.ts b/packages/office-reader/src/opendocument-table/odf-table.model.ts new file mode 100644 index 00000000..0062fb3e --- /dev/null +++ b/packages/office-reader/src/opendocument-table/odf-table.model.ts @@ -0,0 +1,92 @@ +/** How many times a repeated cell or row is written out: the format repeats empty ones to its limits. */ +const MAX_REPEAT = 1024 + +/** A column index as its letters: 0 → `A`, 26 → `AA`. */ +function letters (index: number): string { + let rest = index + 1 + let name = '' + while (rest > 0) { + name = String.fromCodePoint(65 + ((rest - 1) % 26)) + name + rest = Math.floor((rest - 1) / 26) + } + + return name +} + +/** A repeat or span count: at least 1, whatever the file says. */ +function count (value: string | undefined): number { + const number = Math.trunc(Number(value ?? '')) + + return Number.isNaN(number) || number < 1 ? 1 : number +} + +interface OpenCell { + paragraphs: string[] + columns: number + rows: number + repeat: number +} + +/** + * A table of an OpenDocument text or presentation being read, one event at a time: its rows as a grid of cell texts, with the merged cells as A1 + * ranges (the spanning cell holds the text; the cells it covers are in the grid as empty text, the way the file stores them). The caller feeds it the + * `table:table-row` and `table:table-cell` (or `covered-table-cell`) elements and the paragraphs of the cell that is open. + */ +export class OdfTable { + private row: string[] | undefined + private rowRepeat = 1 + private cell: OpenCell | undefined + readonly rows: string[][] = [] + readonly merges: string[] = [] + + /** Whether a cell is open: paragraphs read now belong to it. */ + get inCell (): boolean { + return this.cell !== undefined + } + + /** A row starts. */ + openRow (attributes: Readonly>): void { + this.row = [] + this.rowRepeat = count(attributes['table:number-rows-repeated']) + } + + /** A cell starts, `table-cell` or `covered-table-cell`. */ + openCell (attributes: Readonly>): void { + this.cell = { + paragraphs: [], + columns: count(attributes['table:number-columns-spanned']), + rows: count(attributes['table:number-rows-spanned']), + repeat: Math.min(count(attributes['table:number-columns-repeated']), MAX_REPEAT), + } + } + + /** A paragraph of the open cell has been read. */ + addParagraph (text: string): void { + this.cell?.paragraphs.push(text) + } + + /** The open cell ends: its text goes in the row, once for each time it repeats, and a merge is recorded when it spans. */ + closeCell (): void { + const { cell } = this + if (cell === undefined) return + const row = this.row ?? [] + const text = cell.paragraphs.join('\n').trim() + if (cell.columns > 1 || cell.rows > 1) this.merges.push(`${letters(row.length)}${String(this.rows.length + 1)}:${letters(row.length + cell.columns - 1)}${String(this.rows.length + cell.rows)}`) + for (let copy = 0; copy < cell.repeat; copy += 1) row.push(text) + this.row = row + this.cell = undefined + } + + /** The open row ends: it joins the grid, once for each time it repeats. */ + closeRow (): void { + if (this.row === undefined) return + for (let copy = 0; copy < Math.min(this.rowRepeat, MAX_REPEAT); copy += 1) this.rows.push([...this.row]) + this.row = undefined + this.rowRepeat = 1 + } + + /** The table's text as its cell texts separated by spaces and its rows by line breaks, for a table nested in a cell. */ + get text (): string { + return this.rows.map(row => row.filter(cell => cell !== '').join(' ')).join('\n') + } +} diff --git a/packages/office-reader/src/opendocument-text/odt-blocks.mapper.ts b/packages/office-reader/src/opendocument-text/odt-blocks.mapper.ts index 04e2efa1..b5e9c4cf 100644 --- a/packages/office-reader/src/opendocument-text/odt-blocks.mapper.ts +++ b/packages/office-reader/src/opendocument-text/odt-blocks.mapper.ts @@ -1,5 +1,6 @@ -import { walkXml } from '../ooxml-package' import type { DocumentBlock, DocumentLink, DocumentNote, DocumentTable, Paragraph } from '../document' +import { OdfTable } from '../opendocument-table' +import { walkXml } from '../ooxml-package' import { headingOfStyle, isOrderedList, namedStyle } from './odt-styles.mapper' import type { OdtStyles } from './odt-styles.mapper' @@ -23,52 +24,18 @@ interface OpenParagraph { boxed: DocumentBlock[] } -interface OpenCell { - paragraphs: string[] - columns: number - rows: number - repeat: number -} - -interface OpenTable { - rows: string[][] - merges: string[] - row?: string[] - rowRepeat: number - cell?: OpenCell -} - /** Elements whose content is not part of the text: tracked changes, the templates of indexes, the number of a note, comments, declarations. */ const SKIPPED = new Set([ 'tracked-changes', 'table-of-content-source', 'illustration-index-source', 'table-index-source', 'object-index-source', 'user-index-source', 'alphabetical-index-source', 'bibliography-source', 'note-citation', 'annotation', 'sequence-decls', 'variable-decls', 'user-field-decls', ]) -/** How many times a repeated cell or row is written out: the format repeats empty ones to its limits. */ -const MAX_REPEAT = 1024 - -/** A column index as its letters: 0 → `A`, 26 → `AA`. */ -function letters (index: number): string { - let rest = index + 1 - let name = '' - while (rest > 0) { - name = String.fromCodePoint(65 + ((rest - 1) % 26)) + name - rest = Math.floor((rest - 1) / 26) - } - - return name -} - function count (value: string | undefined): number { const number = Math.trunc(Number(value ?? '')) return Number.isNaN(number) || number < 1 ? 1 : number } -function rangeOf (row: number, column: number, rows: number, columns: number): string { - return `${letters(column)}${String(row + 1)}:${letters(column + columns - 1)}${String(row + rows)}` -} - /** * Reads the parts of an OpenDocument text document: paragraphs with their style, heading level, list level and links, and tables as grids with * their merged cells as ranges. Tracked deletions, comments and the templates of indexes are not text. A paragraph in a text box comes after the @@ -86,7 +53,7 @@ export function odtParts (xml: string, roots: ReadonlySet, styles: OdtSt let rootDepth = 0 let note: { kind: DocumentNote['kind'], id: string, paragraphs: string[] } | undefined let paragraphs: OpenParagraph[] = [] - let tables: OpenTable[] = [] + let tables: OdfTable[] = [] let lists: { style: string | undefined }[] = [] // Where a paragraph that closes goes: a table cell, or the list of blocks (the body, a text box). let containers: ('cell' | 'blocks')[] = ['blocks'] @@ -106,7 +73,7 @@ export function odtParts (xml: string, roots: ReadonlySet, styles: OdtSt if (note !== undefined) { if (text !== '') note.paragraphs.push(text) } else if (containers.at(-1) === 'cell') { - tables.at(-1)?.cell?.paragraphs.push(text, ...open.boxed.map(boxed => (boxed.kind === 'paragraph' ? boxed.text : ''))) + for (const piece of [text, ...open.boxed.map(boxed => (boxed.kind === 'paragraph' ? boxed.text : ''))]) tables.at(-1)?.addParagraph(piece) } else if (host === undefined) { current.blocks.push(...emitted) } else { @@ -175,20 +142,17 @@ export function odtParts (xml: string, roots: ReadonlySet, styles: OdtSt break } case 'table': { - tables.push({ rows: [], merges: [], rowRepeat: 1 }) + tables.push(new OdfTable()) break } case 'table-row': { - if (table !== undefined) { - table.row = [] - table.rowRepeat = count(attributes['table:number-rows-repeated']) - } + table?.openRow(attributes) break } case 'table-cell': case 'covered-table-cell': { if (table !== undefined) { - table.cell = { paragraphs: [], columns: count(attributes['table:number-columns-spanned']), rows: count(attributes['table:number-rows-spanned']), repeat: Math.min(count(attributes['table:number-columns-repeated']), MAX_REPEAT) } + table.openCell(attributes) containers.push('cell') listsOfCells.push(lists) lists = [] @@ -237,24 +201,14 @@ export function odtParts (xml: string, roots: ReadonlySet, styles: OdtSt case 'table-cell': case 'covered-table-cell': { const table = tables.at(-1) - if (table?.cell === undefined) break + if (table?.inCell !== true) break containers.pop() lists = listsOfCells.pop() ?? [] - const { cell } = table - const row = table.row ?? [] - const text = cell.paragraphs.join('\n').trim() - if (cell.columns > 1 || cell.rows > 1) table.merges.push(rangeOf(table.rows.length, row.length, cell.rows, cell.columns)) - for (let copy = 0; copy < cell.repeat; copy += 1) row.push(text) - table.row = row - table.cell = undefined + table.closeCell() break } case 'table-row': { - const table = tables.at(-1) - if (table?.row === undefined) break - for (let copy = 0; copy < Math.min(table.rowRepeat, MAX_REPEAT); copy += 1) table.rows.push([...table.row]) - table.row = undefined - table.rowRepeat = 1 + tables.at(-1)?.closeRow() break } case 'table': { @@ -263,13 +217,13 @@ export function odtParts (xml: string, roots: ReadonlySet, styles: OdtSt const block: DocumentTable = { kind: 'table', name: '', hidden: false, rows: table.rows, hiddenRows: [], merges: table.merges } // A table inside a cell: its text belongs to the cell too. const outer = tables.at(-1) - if (outer?.cell === undefined) { + if (outer?.inCell === true) { + part.blocks.push(block) + outer.addParagraph(table.text) + } else { const host = paragraphs.at(-1) if (host === undefined) part.blocks.push(block) else host.boxed.push(block) - } else { - part.blocks.push(block) - outer.cell.paragraphs.push(table.rows.map(row => row.filter(cell => cell !== '').join(' ')).join('\n')) } break } From 62a0cac7de6df8f654b2502f32388a5634110eba Mon Sep 17 00:00:00 2001 From: Eduardo Russo Date: Sat, 17 Oct 2026 01:01:18 +0100 Subject: [PATCH 2/2] fix(office-reader): odp notes in an unmarked frame; chart fixture with series A notes page from LibreOffice may not mark the notes frame: any frame with text on it is notes, except the footer, date and number furniture. The generated chart fixture now describes its series and categories the way a real chart does. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TjR6eM2tUwU3NGnKXsmEap --- .../fixtures/incentivi.odp | Bin 2904 -> 2983 bytes .../odp-slides.mapper.ts | 3 ++- .../read-odp.use-case.test.ts | 11 +++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/office-reader/src/opendocument-presentation/fixtures/incentivi.odp b/packages/office-reader/src/opendocument-presentation/fixtures/incentivi.odp index b0bd42fb01433017b560d467e85b916d4af352ca..d91ed3a93cb0edca7f8f5c04c9e3d7c42ff20a75 100644 GIT binary patch delta 804 zcmca1wp^Siz?+#xgaHIf*(dS@GnKM$OuWJZq&AzgwlngTvIoZI$|!~PGBGe@aZf(O zCR2aA@0Ni;?fY4f^|Ne;Iff%tSu{ft^ zRmalPn$*{)p0k!-WinYw?QdRx$qJ8@Q!ZkY?f36zzklNOm(wnK({&aGYE3TnYvmNu zugi0UDg4%-FGMb^KH9( zo_o!Mmfoc~vlm~ni8#f7Y08|={f3c}!7hnA?&j^jofy7K=S~0d#}}eJm(NqoFKAD> z<@3^M+3vjw=dI?}?@+#-zP#@Ff>NJ38yH$YzPoF1_+?3w`_dWz&J-JTsZKg<-;y+W zgPG4E<#KhAm{|-Wt!|8}mrWB+O<)So^UR7p8sx)uv?yTrf~d~)e`>@i7g1eq){&+x^lo!#Y25nC@}19Or`8{lG}+2(k;q)* zTUxNXZ@c3cvn-?Hh=6xade+~i&u@NKyQ<@-!mRCjNp)L0X8Gmw#n`{u{H|B|!s*W3 z?|Yg=Tc>En@b5dvT6T21Wh!e){1MmG;PY1TGK-j9xw{@uIhK;(Ut%2DyY9)iBMq~J zBT}}`e6{(?xw1t!^Q*3&Jn)9AKE>~`UDq0=v$;p~1%A}C-fOc_dw)IdeEjauOWd`p zZ+OS!qcIvpid?wPig=|N9B4 zK6ahQA9}C<{m3x$>Fy1F)1MZy|37$bX2rFG?zQy|0abkG1kdPBk@kLFb~*g5{j0As zp-MLUB25Gv%;tOuWJZq&AzgwlnhOum#4(20UD7$Hc&(z%}^{ zn@s(|zS#x>d%tT>c+XOHd94)7nySpJhrMjK$UkUsULz%aQ_W%jzGmLe#L2u9Cvhhp z`~0l;K2O=B>)TT9HF~(R`d_%L^wqE5qhId$YXZS|#6}fZV zZ$riIIX78$zleF1rn#e^H88e9wX=y~P4BnUmv%^)hi^Mxw2@1#;!*>1N%g`%?M%Dk z!vYTa^v;c#wK(+5{Z8)8D7T;YS>A44w@4vlR@&7y;Z9-)FQ;whJfrogO|posy!TR} z*LJ1%eQh_hrEcri&t7i6$9PRh)0&M(g+6?X4L<83H9K(SrTM9yTbImC_#q!~(LzG( zQG!rjZx`p=WX}&T4(Nt5C;B-uMdx+1#jg))((?4yvEIKo;IQeC~;NzuZ{*@x?x^;)_&UW+&}DVx&N)`t* { expect(withoutNotes[0].notes).toBe('') }) + it('reads the notes from a notes frame that is not marked as one, leaving out the thumbnail and the page furniture', async () => { + const note = frame(at(0, 400), p('Unmarked note.')) + const furniture = frame(at(0, 500), p('Notes footer'), 'footer') + const notes = `${note}${furniture}` + const body = frame(at(0, 0), p('Body')) + const slides = await slidesOf(page(body + notes)) + + expect(slides[0].notes).toBe('Unmarked note.') + expect(slides[0].shapes.map(shape => shape.text)).toEqual(['Body']) + }) + it('reads a chart from its data table: type, title once (not an axis\'s), series, categories and a missing point', async () => { const cells = (...values: (number | undefined)[]): string => values.map(value => (value === undefined ? '' : `${String(value)}`)).join('') const header = `${name('A')}${name('B')}`