@@ -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+
5097describe ( '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