Skip to content

Commit d2e8e7c

Browse files
waleedlatif1claude
andcommitted
fix(parsers): walk slides in display order and read SmartArt and chart text
The PPTX walker sorted physical slide part names, but a deck reordered in PowerPoint keeps its old part names and changes only p:sldIdLst, so it was indexed out of order. Slides now follow the presentation's id list resolved through its rels, skipping ids whose part is missing and falling back to part numbering only when nothing resolves. Graphic frames that hold SmartArt or a chart were dropped entirely; the diagram data part's dgm:pt text bodies and a modest chart summary (title, axis titles, series, categories) are now emitted, with connector text included. Every relationship target is clamped under ppt/ and read through the per-part size cap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 1454be9 commit d2e8e7c

2 files changed

Lines changed: 364 additions & 52 deletions

File tree

apps/sim/lib/file-parsers/ooxml-presentation.test.ts

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,25 +28,72 @@ interface DeckSlide {
2828
index: number
2929
spTree: string
3030
notesSpTree?: string
31+
/** Extra `<Relationship>` elements for the slide's own rels part. */
32+
extraRels?: string
3133
}
3234

33-
async function buildDeck(slides: DeckSlide[]): Promise<Buffer> {
35+
const RELS_NS = 'xmlns="http://schemas.openxmlformats.org/package/2006/relationships"'
36+
const REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
37+
38+
/** Lists the given slide part names in `p:sldIdLst` order, resolved through the presentation rels. */
39+
function presentationParts(zip: JSZip, order: number[]): void {
40+
const ids = order.map((n, i) => `<p:sldId id="${256 + i}" r:id="rId${n}"/>`).join('')
41+
zip.file(
42+
'ppt/presentation.xml',
43+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:presentation ${NS}><p:sldIdLst>${ids}</p:sldIdLst></p:presentation>`
44+
)
45+
const rels = order
46+
.map(
47+
(n) => `<Relationship Id="rId${n}" Type="${REL_TYPE}/slide" Target="slides/slide${n}.xml"/>`
48+
)
49+
.join('')
50+
zip.file(
51+
'ppt/_rels/presentation.xml.rels',
52+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships ${RELS_NS}>${rels}</Relationships>`
53+
)
54+
}
55+
56+
async function buildDeck(slides: DeckSlide[], order?: number[]): Promise<Buffer> {
3457
const zip = new JSZip()
3558
zip.file('[Content_Types].xml', '<Types/>')
3659
zip.file('ppt/media/image1.png', Buffer.from([0x89, 0x50, 0x4e, 0x47]))
60+
if (order) presentationParts(zip, order)
3761
for (const slide of slides) {
3862
zip.file(`ppt/slides/slide${slide.index}.xml`, slideXml(slide.spTree))
63+
const rels: string[] = []
3964
if (slide.notesSpTree !== undefined) {
65+
rels.push(
66+
`<Relationship Id="rId2" Type="${REL_TYPE}/notesSlide" Target="../notesSlides/notesSlide${slide.index}.xml"/>`
67+
)
68+
zip.file(`ppt/notesSlides/notesSlide${slide.index}.xml`, notesXml(slide.notesSpTree))
69+
}
70+
if (slide.extraRels) rels.push(slide.extraRels)
71+
if (rels.length > 0) {
4072
zip.file(
4173
`ppt/slides/_rels/slide${slide.index}.xml.rels`,
42-
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" Target="../notesSlides/notesSlide${slide.index}.xml"/></Relationships>`
74+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships ${RELS_NS}>${rels.join('')}</Relationships>`
4375
)
44-
zip.file(`ppt/notesSlides/notesSlide${slide.index}.xml`, notesXml(slide.notesSpTree))
4576
}
4677
}
4778
return zip.generateAsync({ type: 'nodebuffer' }) as Promise<Buffer>
4879
}
4980

81+
const DIAGRAM_FRAME = `<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="6" name="Diagram 5"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/diagram"><dgm:relIds xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" r:dm="rId3" r:lo="rId4" r:qs="rId5" r:cs="rId6"/></a:graphicData></a:graphic></p:graphicFrame>`
82+
83+
const CHART_FRAME = `<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="7" name="Chart 6"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" r:id="rId3"/></a:graphicData></a:graphic></p:graphicFrame>`
84+
85+
function diagramDataXml(points: string[]): string {
86+
const pts = points
87+
.map(
88+
(text, i) =>
89+
`<dgm:pt modelId="{${i}}"><dgm:t><a:bodyPr/><a:p><a:r><a:t>${text}</a:t></a:r></a:p></dgm:t></dgm:pt>`
90+
)
91+
.join('')
92+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><dgm:dataModel xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><dgm:ptLst><dgm:pt modelId="{doc}" type="doc"><dgm:t><a:p/></dgm:t></dgm:pt>${pts}</dgm:ptLst></dgm:dataModel>`
93+
}
94+
95+
const CHART_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:chart><c:title><c:tx><c:rich><a:p><a:r><a:t>Revenue by </a:t></a:r><a:r><a:t>quarter</a:t></a:r></a:p></c:rich></c:tx></c:title><c:plotArea><c:barChart><c:ser><c:idx val="0"/><c:tx><c:strRef><c:f>Sheet1!$B$1</c:f><c:strCache><c:pt idx="0"><c:v>Sales</c:v></c:pt></c:strCache></c:strRef></c:tx><c:cat><c:strRef><c:strCache><c:pt idx="0"><c:v>1st Qtr</c:v></c:pt><c:pt idx="1"><c:v>2nd Qtr</c:v></c:pt></c:strCache></c:strRef></c:cat><c:val><c:numRef><c:numCache><c:pt idx="0"><c:v>10</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser><c:ser><c:idx val="1"/><c:tx><c:strRef><c:strCache><c:pt idx="0"><c:v>Costs</c:v></c:pt></c:strCache></c:strRef></c:tx><c:cat><c:strRef><c:strCache><c:pt idx="0"><c:v>1st Qtr</c:v></c:pt><c:pt idx="1"><c:v>2nd Qtr</c:v></c:pt></c:strCache></c:strRef></c:cat></c:ser></c:barChart><c:catAx><c:axId val="1"/><c:title><c:tx><c:rich><a:p><a:r><a:t>Quarter</a:t></a:r></a:p></c:rich></c:tx></c:title></c:catAx><c:valAx><c:axId val="2"/></c:valAx></c:plotArea></c:chart></c:chartSpace>`
96+
5097
describe('extractPresentationText', () => {
5198
afterEach(() => {
5299
vi.restoreAllMocks()
@@ -196,6 +243,83 @@ describe('extractPresentationText', () => {
196243
})
197244
})
198245

246+
it('follows the presentation sldIdLst order rather than part numbering', async () => {
247+
const buffer = await buildDeck(
248+
[
249+
{ index: 1, spTree: shape('One') },
250+
{ index: 2, spTree: shape('Two') },
251+
{ index: 3, spTree: shape('Three') },
252+
],
253+
[3, 1, 2]
254+
)
255+
256+
expect(await extractPresentationText(buffer)).toBe('Three\n\nOne\n\nTwo')
257+
})
258+
259+
it('skips slide ids whose target is missing and falls back when none resolve', async () => {
260+
const withMissing = await buildDeck(
261+
[
262+
{ index: 1, spTree: shape('One') },
263+
{ index: 2, spTree: shape('Two') },
264+
],
265+
[2, 9, 1]
266+
)
267+
expect(await extractPresentationText(withMissing)).toBe('Two\n\nOne')
268+
269+
const noneResolve = await buildDeck([{ index: 1, spTree: shape('Only') }], [7])
270+
expect(await extractPresentationText(noneResolve)).toBe('Only')
271+
})
272+
273+
it('reads SmartArt text from the diagram data part in document order', async () => {
274+
const zip = new JSZip()
275+
zip.file('ppt/slides/slide1.xml', slideXml(shape('Process', 'title') + DIAGRAM_FRAME))
276+
zip.file(
277+
'ppt/slides/_rels/slide1.xml.rels',
278+
`<Relationships ${RELS_NS}><Relationship Id="rId3" Type="${REL_TYPE}/diagramData" Target="../diagrams/data1.xml"/><Relationship Id="rId4" Type="${REL_TYPE}/diagramLayout" Target="../diagrams/layout1.xml"/></Relationships>`
279+
)
280+
zip.file('ppt/diagrams/data1.xml', diagramDataXml(['Plan', 'Build', 'Ship']))
281+
zip.file('ppt/diagrams/layout1.xml', '<dgm:layoutDef xmlns:dgm="x"/>')
282+
const buffer = (await zip.generateAsync({ type: 'nodebuffer' })) as Buffer
283+
284+
expect(await extractPresentationText(buffer)).toBe('Process\n\nPlan\nBuild\nShip')
285+
})
286+
287+
it('summarizes a chart as title, axis titles, series, and categories', async () => {
288+
const zip = new JSZip()
289+
zip.file('ppt/slides/slide1.xml', slideXml(CHART_FRAME))
290+
zip.file(
291+
'ppt/slides/_rels/slide1.xml.rels',
292+
`<Relationships ${RELS_NS}><Relationship Id="rId3" Type="${REL_TYPE}/chart" Target="../charts/chart1.xml"/></Relationships>`
293+
)
294+
zip.file('ppt/charts/chart1.xml', CHART_XML)
295+
const buffer = (await zip.generateAsync({ type: 'nodebuffer' })) as Buffer
296+
297+
expect(await extractPresentationText(buffer)).toBe(
298+
'[Chart]\nRevenue by quarter\nQuarter\nSales\nCosts\n1st Qtr\n2nd Qtr\n[/Chart]'
299+
)
300+
})
301+
302+
it('ignores a diagram target that escapes ppt/', async () => {
303+
const zip = new JSZip()
304+
zip.file('ppt/slides/slide1.xml', slideXml(shape('Body') + DIAGRAM_FRAME))
305+
zip.file(
306+
'ppt/slides/_rels/slide1.xml.rels',
307+
`<Relationships ${RELS_NS}><Relationship Id="rId3" Type="${REL_TYPE}/diagramData" Target="../../docProps/data1.xml"/></Relationships>`
308+
)
309+
zip.file('docProps/data1.xml', diagramDataXml(['Leaked']))
310+
const buffer = (await zip.generateAsync({ type: 'nodebuffer' })) as Buffer
311+
312+
expect(await extractPresentationText(buffer)).toBe('Body')
313+
})
314+
315+
it('includes text carried by a connector shape', async () => {
316+
const spTree = `<p:cxnSp><p:nvCxnSpPr><p:cNvPr id="9" name="Connector 8"/><p:cNvCxnSpPr/><p:nvPr/></p:nvCxnSpPr><p:txBody><a:bodyPr/><a:p><a:r><a:t>Yes</a:t></a:r></a:p></p:txBody></p:cxnSp>${shape('After')}`
317+
318+
expect(await extractPresentationText(await buildDeck([{ index: 1, spTree }]))).toBe(
319+
'Yes\nAfter'
320+
)
321+
})
322+
199323
it('rejects when the signal is already aborted', async () => {
200324
const controller = new AbortController()
201325
controller.abort()

0 commit comments

Comments
 (0)