diff --git a/README.md b/README.md index 6f65e47..ab8989b 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,14 @@ const automizer = new Automizer({ // truncate root presentation and start with zero slides removeExistingSlides: true, + // activate `cleanup` to eventually remove unused files: + cleanup: false, + + // Set a value from 0-9 to specify the zip-compression level. + // The lower the number, the faster your output file will be ready. + // Higher compression levels produce smaller files. + compression: 0, + // use a callback function to track pptx generation process. // statusTracker: myStatusTracker, }) diff --git a/__tests__/modify-chart-scatter-images.test.ts b/__tests__/modify-chart-scatter-images.test.ts new file mode 100644 index 0000000..6e287ad --- /dev/null +++ b/__tests__/modify-chart-scatter-images.test.ts @@ -0,0 +1,33 @@ +import Automizer, { modify } from '../src/index'; +import { ChartData } from '../dist'; + +test('create presentation, add and modify a scatter chart with embedded point images.', async () => { + const automizer = new Automizer({ + templateDir: `${__dirname}/pptx-templates`, + outputDir: `${__dirname}/pptx-output`, + }); + + const pres = automizer + .loadRoot(`RootTemplate.pptx`) + .load(`ChartScatter.pptx`, 'charts'); + + const dataScatter = ({ + series: [{ label: 'series s1' }], + categories: [ + { label: 'r1', values: [{ x: 10, y: 20 }] }, + { label: 'r2', values: [{ x: 21, y: 11 }] }, + { label: 'r3', values: [{ x: 22, y: 28 }] }, + { label: 'r4', values: [{ x: 13, y: 13 }] }, + ], + }); + + const result = await pres + .addSlide('charts', 3, (slide) => { + slide.modifyElement('ScatterPointImages', [ + modify.setChartScatter(dataScatter), + ]); + }) + .write(`modify-chart-scatter-images.test.pptx`); + + // expect(result.charts).toBe(2); +}); diff --git a/__tests__/pptx-templates/ChartScatter.pptx b/__tests__/pptx-templates/ChartScatter.pptx index 6e8e4b9..e9344ae 100644 Binary files a/__tests__/pptx-templates/ChartScatter.pptx and b/__tests__/pptx-templates/ChartScatter.pptx differ diff --git a/__tests__/pptx-templates/RootTemplateWithImages.pptx b/__tests__/pptx-templates/RootTemplateWithImages.pptx new file mode 100644 index 0000000..1d9e0bb Binary files /dev/null and b/__tests__/pptx-templates/RootTemplateWithImages.pptx differ diff --git a/src/automizer.ts b/src/automizer.ts index 49bb47c..4887fb9 100644 --- a/src/automizer.ts +++ b/src/automizer.ts @@ -17,6 +17,8 @@ import path from 'path'; import * as fs from 'fs'; import { XmlHelper } from './helper/xml-helper'; import ModifyPresentationHelper from './helper/modify-presentation-helper'; +import { contentTracker, ContentTracker } from './helper/content-tracker'; +import JSZip from 'jszip'; /** * Automizer @@ -42,7 +44,8 @@ export default class Automizer implements IPresentationProps { params: AutomizerParams; status: StatusTracker; - modifyPresentation: ModifyXmlCallback[]; + content: ContentTracker; + modifyPresentation: ModifyXmlCallback[] = []; /** * Creates an instance of `pptx-automizer`. @@ -50,7 +53,6 @@ export default class Automizer implements IPresentationProps { */ constructor(params: AutomizerParams) { this.templates = []; - this.modifyPresentation = []; this.params = params; this.templateDir = params?.templateDir ? params.templateDir + '/' : ''; @@ -62,6 +64,8 @@ export default class Automizer implements IPresentationProps { this.timer = Date.now(); this.setStatusTracker(params?.statusTracker); + this.content = new ContentTracker(); + if (params.rootTemplate) { const location = this.getLocation(params.rootTemplate, 'template'); this.rootTemplate = Template.import(location) as RootPresTemplate; @@ -277,7 +281,19 @@ export default class Automizer implements IPresentationProps { await this.applyModifyPresentationCallbacks(); const rootArchive = await this.rootTemplate.archive; - const content = await rootArchive.generateAsync({ type: 'nodebuffer' }); + + const options: JSZip.JSZipGeneratorOptions<'nodebuffer'> = { + type: 'nodebuffer', + }; + + if (this.params.compression > 0) { + options.compression = 'DEFLATE'; + options.compressionOptions = { + level: this.params.compression, + }; + } + + const content = await rootArchive.generateAsync(options); return FileHelper.writeOutputFile( this.getLocation(location, 'output'), @@ -318,12 +334,19 @@ export default class Automizer implements IPresentationProps { * Apply some callbacks to restore archive/xml structure * and prevent corrupted pptx files. * - * TODO: Remove unused parts (slides, related items) from archive. * TODO: Use every imported image only once * TODO: Check for lost relations */ - normalizePresentation(): void { + async normalizePresentation(): Promise { this.modify(ModifyPresentationHelper.normalizeSlideIds); + + if (this.params.cleanup) { + if (this.params.removeExistingSlides) { + this.modify(ModifyPresentationHelper.removeUnusedFiles); + } + this.modify(ModifyPresentationHelper.removedUnusedImages); + this.modify(ModifyPresentationHelper.removeUnusedContentTypes); + } } /** diff --git a/src/classes/slide.ts b/src/classes/slide.ts index 6626638..981214d 100644 --- a/src/classes/slide.ts +++ b/src/classes/slide.ts @@ -25,6 +25,7 @@ import { Image } from '../shapes/image'; import { Chart } from '../shapes/chart'; import { GenericShape } from '../shapes/generic'; import { vd } from '../helper/general-helper'; +import { ContentTracker } from '../helper/content-tracker'; export class Slide implements ISlide { /** @@ -98,6 +99,7 @@ export class Slide implements ISlide { */ targetRelsPath: string; status: StatusTracker; + content: ContentTracker; /** * List of unsupported tags in slide xml * @internal @@ -126,6 +128,7 @@ export class Slide implements ISlide { this.importElements = []; this.status = params.presentation.status; + this.content = params.presentation.content; } /** diff --git a/src/classes/template.ts b/src/classes/template.ts index 21ae4be..6dc241a 100644 --- a/src/classes/template.ts +++ b/src/classes/template.ts @@ -11,6 +11,8 @@ import { XmlTemplateHelper } from '../helper/xml-template-helper'; import { SlideInfo } from '../types/xml-types'; import { XmlHelper } from '../helper/xml-helper'; import { vd } from '../helper/general-helper'; +import { ContentTracker } from '../helper/content-tracker'; +import CacheHelper from '../helper/cache-helper'; export class Template implements ITemplate { /** @@ -52,7 +54,7 @@ export class Template implements ITemplate { creationIds: SlideInfo[]; existingSlides: number; - constructor(location: string) { + constructor(location: string, cache?: CacheHelper) { this.location = location; const file = FileHelper.readFile(location); this.archive = FileHelper.extractFileContent(file as unknown as Buffer); @@ -61,20 +63,21 @@ export class Template implements ITemplate { static import( location: string, name?: string, + cache?: CacheHelper, ): PresTemplate | RootPresTemplate { let newTemplate: PresTemplate | RootPresTemplate; - if (name) { - newTemplate = new Template(location) as PresTemplate; + newTemplate = new Template(location, cache) as PresTemplate; newTemplate.name = name; } else { - newTemplate = new Template(location) as RootPresTemplate; + newTemplate = new Template(location, cache) as RootPresTemplate; newTemplate.slides = []; newTemplate.counter = [ new CountHelper('slides', newTemplate), new CountHelper('charts', newTemplate), new CountHelper('images', newTemplate), ]; + newTemplate.content = new ContentTracker(); } return newTemplate; diff --git a/src/constants/constants.ts b/src/constants/constants.ts index c4da173..a5b3e28 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -1,4 +1,8 @@ -import { TargetByRelIdMapParam } from '../types/types'; +import { + TargetByRelIdMapParam, + TrackedRelation, + TrackedRelationTag, +} from '../types/types'; export const TargetByRelIdMap = { chart: { @@ -22,3 +26,93 @@ export const TargetByRelIdMap = { prefix: '../media/image', } as TargetByRelIdMapParam, }; + +export const imagesTrack: () => TrackedRelation[] = () => [ + { + type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', + tag: 'a:blip', + role: 'image', + attribute: 'r:embed', + }, + { + type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image', + tag: 'asvg:svgBlip', + role: 'image', + attribute: 'r:embed', + }, +]; + +export const contentTrack: TrackedRelationTag[] = [ + { + source: 'ppt/presentation.xml', + relationsKey: 'ppt/_rels/presentation.xml.rels', + tags: [ + { + type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster', + tag: 'p:sldMasterId', + role: 'slideMaster', + }, + { + type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide', + tag: 'p:sldId', + role: 'slide', + }, + ], + }, + { + source: 'ppt/slides', + relationsKey: 'ppt/slides/_rels', + isDir: true, + tags: [ + { + type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart', + tag: 'c:chart', + role: 'chart', + }, + { + type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout', + role: 'slideLayout', + tag: null, + }, + ...imagesTrack(), + ], + }, + { + source: 'ppt/charts', + relationsKey: 'ppt/charts/_rels', + isDir: true, + tags: [ + { + type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/package', + tag: 'c:externalData', + role: 'externalData', + }, + ], + }, + { + source: 'ppt/slideMasters', + relationsKey: 'ppt/slideMasters/_rels', + isDir: true, + tags: [ + { + type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout', + tag: 'p:sldLayoutId', + role: 'slideLayout', + }, + ...imagesTrack(), + ], + }, + { + source: 'ppt/slideLayouts', + relationsKey: 'ppt/slideLayouts/_rels', + isDir: true, + tags: [ + { + type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster', + role: 'slideMaster', + tag: null, + }, + ...imagesTrack(), + ], + }, +]; diff --git a/src/dev.ts b/src/dev.ts index 3844ed9..5b656cc 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -1,89 +1,51 @@ import Automizer, { ChartData, modify } from './index'; +import { vd } from './helper/general-helper'; +import { contentTracker } from './helper/content-tracker'; +import ModifyPresentationHelper from './helper/modify-presentation-helper'; const automizer = new Automizer({ templateDir: `${__dirname}/../__tests__/pptx-templates`, outputDir: `${__dirname}/../__tests__/pptx-output`, removeExistingSlides: true, + cleanup: true, + compression: 5, }); const run = async () => { - // const pres = automizer - // .loadRoot(`RootTemplate.pptx`) - // .load(`SlideWithCharts.pptx`, 'charts'); - // - // const result = await pres - // .addSlide('charts', 2, (slide) => { - // slide.modifyElement('ColumnChart', [ - // modify.setChartData({ - // series: [ - // { label: 'series 1' }, - // { label: 'series 2' }, - // { label: 'series 3' }, - // ], - // categories: [ - // { label: 'cat 2-1', values: [50, 50, 20] }, - // { label: 'cat 2-2', values: [14, 50, 20] }, - // { label: 'cat 2-3', values: [15, 50, 20] }, - // { label: 'cat 2-4', values: [26, 50, 20] }, - // ], - // }), - // ]); - // }) - // .write(`modify-existing-chart.test.pptx`); - const pres = automizer - .loadRoot(`RootTemplate.pptx`) - .load(`EmptySlide.pptx`, 'EmptySlide') - .load(`ChartWaterfall.pptx`, 'ChartWaterfall') - .load(`ChartBarsStacked.pptx`, 'ChartBarsStacked'); - - const result = await pres - .addSlide('EmptySlide', 1, (slide) => { - // slide.addElement('ChartBarsStacked', 1, 'BarsStacked', [ - // modify.setChartData({ - // series: [{ label: 'series 1' }], - // categories: [ - // { label: 'cat 2-1', values: [50] }, - // { label: 'cat 2-2', values: [14] }, - // { label: 'cat 2-3', values: [15] }, - // { label: 'cat 2-4', values: [26] }, - // ], - // }), - // ]); + .loadRoot(`RootTemplateWithImages.pptx`) + .load(`RootTemplate.pptx`, 'root') + .load(`SlideWithImages.pptx`, 'images') + .load(`ChartBarsStacked.pptx`, 'charts'); - // slide.modifyElement('Waterfall 1', [ - // modify.setExtendedChartData({ - // series: [{ label: 'series 1' }], - // categories: [ - // { label: 'cat 2-1', values: [100] }, - // { label: 'cat 2-2', values: [20] }, - // { label: 'cat 2-3', values: [50] }, - // { label: 'cat 2-4', values: [-40] }, - // { label: 'cat 2-5', values: [130] }, - // { label: 'cat 2-6', values: [-60] }, - // { label: 'cat 2-7', values: [70] }, - // { label: 'cat 2-8', values: [140] }, - // ], - // }), - // ]); + const dataSmaller = { + series: [{ label: 'series s1' }, { label: 'series s2' }], + categories: [ + { label: 'item test r1', values: [10, 16] }, + { label: 'item test r2', values: [12, 18] }, + ], + }; - slide.addElement('ChartWaterfall', 1, 'Waterfall 1', [ - modify.setExtendedChartData({ - series: [{ label: 'series 1' }], - categories: [ - { label: 'cat 2-1', values: [100] }, - { label: 'cat 2-2', values: [20] }, - { label: 'cat 2-3', values: [50] }, - { label: 'cat 2-4', values: [-40] }, - { label: 'cat 2-5', values: [130] }, - { label: 'cat 2-6', values: [-60] }, - { label: 'cat 2-7', values: [70] }, - { label: 'cat 2-8', values: [140] }, - ], - }), + const result = await pres + .addSlide('charts', 1, (slide) => { + slide.modifyElement('BarsStacked', [modify.setChartData(dataSmaller)]); + slide.addElement('charts', 1, 'BarsStacked', [ + modify.setChartData(dataSmaller), ]); }) - .write(`modify-existing-waterfall-chart.test.pptx`); + .addSlide('images', 1) + .addSlide('root', 1, (slide) => { + slide.addElement('charts', 1, 'BarsStacked', [ + modify.setChartData(dataSmaller), + ]); + }) + .addSlide('charts', 1, (slide) => { + slide.addElement('images', 2, 'imageJPG'); + slide.modifyElement('BarsStacked', [modify.setChartData(dataSmaller)]); + }) + .write(`create-presentation-content-tracker.test.pptx`); + + // vd(pres.rootTemplate.content); }; run().catch((error) => { diff --git a/src/helper/cache-helper.ts b/src/helper/cache-helper.ts new file mode 100644 index 0000000..e01a4a9 --- /dev/null +++ b/src/helper/cache-helper.ts @@ -0,0 +1,29 @@ +import path from 'path'; +import fs from 'fs'; +import JSZip from 'jszip'; +import { vd } from './general-helper'; +const extract = require('extract-zip'); + +export default class CacheHelper { + dir: string; + currentLocation: string; + currentSubDir: string; + + constructor(dir: string) { + this.dir = dir; + } + + setLocation(location: string) { + this.currentLocation = location; + const baseName = path.basename(this.currentLocation); + this.currentSubDir = this.dir + '/' + baseName; + return this; + } + + store() { + extract(this.currentLocation, { dir: this.currentSubDir }).catch((err) => { + throw err; + }); + return this; + } +} diff --git a/src/helper/content-tracker.ts b/src/helper/content-tracker.ts new file mode 100644 index 0000000..9fb8328 --- /dev/null +++ b/src/helper/content-tracker.ts @@ -0,0 +1,274 @@ +import { + Target, + TrackedFiles, + TrackedRelation, + TrackedRelationInfo, + TrackedRelations, + TrackedRelationTag, +} from '../types/types'; +import { Slide } from '../classes/slide'; +import { FileHelper } from './file-helper'; +import { XmlHelper } from './xml-helper'; +import { vd } from './general-helper'; +import JSZip from 'jszip'; +import { RelationshipAttribute } from '../types/xml-types'; +import { contentTrack } from '../constants/constants'; + +export class ContentTracker { + archive: JSZip; + files: TrackedFiles = { + 'ppt/slideMasters': [], + 'ppt/slideLayouts': [], + 'ppt/slides': [], + 'ppt/charts': [], + 'ppt/embeddings': [], + }; + + relations: TrackedRelations = { + // '.': [], + 'ppt/slides/_rels': [], + 'ppt/slideMasters/_rels': [], + 'ppt/slideLayouts/_rels': [], + 'ppt/charts/_rels': [], + 'ppt/_rels': [], + ppt: [], + }; + + relationTags = contentTrack; + + constructor() {} + + trackFile(file: string): void { + const info = FileHelper.getFileInfo(file); + if (this.files[info.dir]) { + this.files[info.dir].push(info.base); + } + } + + trackRelation(file: string, attributes: RelationshipAttribute): void { + const info = FileHelper.getFileInfo(file); + if (this.relations[info.dir]) { + this.relations[info.dir].push({ + base: info.base, + attributes, + }); + } + } + + async analyzeContents(archive: JSZip) { + this.setArchive(archive); + + await this.analyzeRelationships(); + await this.trackSlideMasters(); + await this.trackSlideLayouts(); + } + + setArchive(archive: JSZip) { + this.archive = archive; + } + + /** + * This will be replaced by future slideMaster handling. + */ + async trackSlideMasters() { + const slideMasters = this.getRelationTag( + 'ppt/presentation.xml', + ).getTrackedRelations('slideMaster'); + + await this.addAndAnalyze(slideMasters, 'ppt/slideMasters'); + } + + async trackSlideLayouts() { + const usedSlideLayouts = + this.getRelationTag('ppt/slideMasters').getTrackedRelations( + 'slideLayout', + ); + + await this.addAndAnalyze(usedSlideLayouts, 'ppt/slideLayouts'); + } + + async addAndAnalyze(trackedRelations: TrackedRelation[], section: string) { + const targets = await this.getRelatedContents(trackedRelations); + + targets.forEach((target) => { + this.trackFile(section + '/' + target.filename); + }); + + const relationTagInfo = this.getRelationTag(section); + await this.analyzeRelationship(relationTagInfo); + } + + async getRelatedContents( + trackedRelations: TrackedRelation[], + ): Promise { + const relatedContents = []; + for (const trackedRelation of trackedRelations) { + for (const target of trackedRelation.targets) { + const trackedRelationInfo = await target.getRelatedContent(); + relatedContents.push(trackedRelationInfo); + } + } + return relatedContents; + } + + getRelationTag(source: string): TrackedRelationTag { + return contentTracker.relationTags.find( + (relationTag) => relationTag.source === source, + ); + } + + async analyzeRelationships(): Promise { + for (const relationTagInfo of this.relationTags) { + await this.analyzeRelationship(relationTagInfo); + } + } + + async analyzeRelationship( + relationTagInfo: TrackedRelationTag, + ): Promise { + relationTagInfo.getTrackedRelations = (role: string) => { + return relationTagInfo.tags.filter((tag) => tag.role === role); + }; + + for (const relationTag of relationTagInfo.tags) { + relationTag.targets = relationTag.targets || []; + + if (relationTagInfo.isDir === true) { + const files = this.files[relationTagInfo.source] || []; + if (!files.length) { + // vd('no files'); + // vd(relationTagInfo.source); + } + for (const file of files) { + await this.pushRelationTagTargets( + relationTagInfo.source + '/' + file, + file, + relationTag, + relationTagInfo, + ); + } + } else { + const pathInfo = FileHelper.getFileInfo(relationTagInfo.source); + await this.pushRelationTagTargets( + relationTagInfo.source, + pathInfo.base, + relationTag, + relationTagInfo, + ); + } + } + } + + async pushRelationTagTargets( + file: string, + filename: string, + relationTag: TrackedRelation, + relationTagInfo, + ): Promise { + const attribute = relationTag.attribute || 'r:id'; + + const addTargets = await XmlHelper.getRelationshipItems( + this.archive, + file, + relationTag.tag, + (element, rels) => { + rels.push({ + file, + filename, + rId: element.getAttribute(attribute), + type: relationTag.type, + }); + }, + ); + + this.addCreatedRelationsFunctions( + addTargets, + contentTracker.relations[relationTagInfo.relationsKey], + relationTagInfo, + ); + + relationTag.targets = [...relationTag.targets, ...addTargets]; + } + + addCreatedRelationsFunctions( + addTargets: Target[], + createdRelations: TrackedRelationInfo[], + relationTagInfo: TrackedRelationTag, + ): void { + addTargets.forEach((addTarget) => { + addTarget.getCreatedContent = this.getCreatedContent( + createdRelations, + addTarget, + ); + addTarget.getRelatedContent = this.getRelatedContent( + relationTagInfo, + addTarget, + ); + }); + } + + getCreatedContent( + createdRelations: TrackedRelationInfo[], + addTarget: Target, + ) { + return () => { + return createdRelations.find((relation) => { + return ( + relation.base === addTarget.filename + '.rels' && + relation.attributes?.Id === addTarget.rId + ); + }); + }; + } + + getRelatedContent(relationTagInfo: TrackedRelationTag, addTarget: Target) { + return async () => { + if (addTarget.relatedContent) return addTarget.relatedContent; + + const relationsFile = + relationTagInfo.isDir === true + ? relationTagInfo.relationsKey + '/' + addTarget.filename + '.rels' + : relationTagInfo.relationsKey; + + const relationTarget = await XmlHelper.getRelationshipItems( + this.archive, + relationsFile, + 'Relationship', + (element, rels) => { + const rId = element.getAttribute('Id'); + + if (rId === addTarget.rId) { + const target = element.getAttribute('Target'); + const fileInfo = FileHelper.getFileInfo(target); + + rels.push({ + file: target, + filename: fileInfo.base, + rId: rId, + type: element.getAttribute('Type'), + }); + } + }, + ); + + addTarget.relatedContent = relationTarget.find( + (relationTarget) => relationTarget.rId === addTarget.rId, + ); + + return addTarget.relatedContent; + }; + } + + async collect( + section: string, + role: string, + collection: string[], + ): Promise { + const trackedRelations = + this.getRelationTag(section).getTrackedRelations(role); + const images = await this.getRelatedContents(trackedRelations); + images.forEach((image) => collection.push(image.filename)); + } +} + +export const contentTracker = new ContentTracker(); diff --git a/src/helper/file-helper.ts b/src/helper/file-helper.ts index d1579a4..a79b4e0 100644 --- a/src/helper/file-helper.ts +++ b/src/helper/file-helper.ts @@ -1,9 +1,10 @@ import fs from 'fs'; import path from 'path'; -import JSZip, { InputType, OutputType } from 'jszip'; +import JSZip, { InputType, JSZipObject, OutputType } from 'jszip'; -import { AutomizerSummary } from '../types/types'; +import { AutomizerSummary, FileInfo } from '../types/types'; import { IPresentationProps } from '../interfaces/ipresentation-props'; +import { contentTracker } from './content-tracker'; export class FileHelper { static readFile(location: string): Promise { @@ -18,22 +19,34 @@ export class FileHelper { file: string, type?: OutputType, ): Promise { - if (archive === undefined) { - throw new Error('No files found, expected: ' + file); - } + const exists = FileHelper.check(archive, file); - if (archive.files[file] === undefined) { - console.trace(); - throw new Error('Archived file not found: ' + file); + if (!exists) { + throw new Error('File is not in archive: ' + file); } + return archive.files[file].async(type || 'string'); } - static fileExistsInArchive(archive: JSZip, file: string): boolean { - if (archive === undefined || archive.files[file] === undefined) { - return false; - } - return true; + static removeFromDirectory( + archive: JSZip, + dir: string, + cb: (file: JSZipObject, relativePath: string) => boolean, + ): string[] { + const removed = []; + archive.folder(dir).forEach((relativePath, file) => { + if (!relativePath.includes('/') && cb(file, relativePath)) { + FileHelper.removeFromArchive(archive, file.name); + removed.push(file.name); + } + }); + return removed; + } + + static removeFromArchive(archive: JSZip, file: string): JSZip { + FileHelper.check(archive, file); + + return archive.remove(file); } static extractFileContent(file: Buffer): Promise { @@ -45,6 +58,33 @@ export class FileHelper { return path.extname(filename).replace('.', ''); } + static getFileInfo(filename: string): FileInfo { + return { + base: path.basename(filename), + dir: path.dirname(filename), + isDir: filename[filename.length - 1] === '/', + extension: path.extname(filename).replace('.', ''), + }; + } + + static check(archive: JSZip, file: string): boolean { + FileHelper.isArchive(archive); + return FileHelper.fileExistsInArchive(archive, file); + } + + static isArchive(archive) { + if (archive === undefined || !archive.files) { + throw new Error('Archive is invalid or empty.'); + } + } + + static fileExistsInArchive(archive: JSZip, file: string): boolean { + if (archive === undefined || archive.files[file] === undefined) { + return false; + } + return true; + } + /** * Copies a file from one archive to another. The new file can have a different name to the origin. * @param {JSZip} sourceArchive - Source archive @@ -58,12 +98,13 @@ export class FileHelper { sourceFile: string, targetArchive: JSZip, targetFile?: string, + tmp?: any, ): Promise { - if (sourceArchive.files[sourceFile] === undefined) { - throw new Error(`Zipped file not found: ${sourceFile}`); - } + FileHelper.check(sourceArchive, sourceFile); const content = sourceArchive.files[sourceFile].async('nodebuffer'); + contentTracker.trackFile(targetFile); + return targetArchive.file(targetFile || sourceFile, content); } diff --git a/src/helper/modify-presentation-helper.ts b/src/helper/modify-presentation-helper.ts index c82b636..240a00d 100644 --- a/src/helper/modify-presentation-helper.ts +++ b/src/helper/modify-presentation-helper.ts @@ -1,5 +1,7 @@ import { XmlHelper } from './xml-helper'; -import { vd } from './general-helper'; +import { contentTracker as Tracker } from './content-tracker'; +import { FileHelper } from './file-helper'; +import JSZip from 'jszip'; export default class ModifyPresentationHelper { /** @@ -31,4 +33,75 @@ export default class ModifyPresentationHelper { slide.setAttribute('id', String(firstId + i)); }); }; + + /** + * contentTracker.files includes all files that have been + * copied into the root template by automizer. We remove all other files. + */ + static async removeUnusedFiles( + xml: XMLDocument, + i: number, + archive: JSZip, + ): Promise { + // Need to skip some dirs until masters and layouts are handled properly + const skipDirs = [ + 'ppt/slideMasters', + 'ppt/slideMasters/_rels', + 'ppt/slideLayouts', + 'ppt/slideLayouts/_rels', + ]; + for (const dir in Tracker.files) { + if (skipDirs.includes(dir)) { + continue; + } + const requiredFiles = Tracker.files[dir]; + FileHelper.removeFromDirectory(archive, dir, (file, relativePath) => { + return !requiredFiles.includes(relativePath); + }); + } + } + + /** + * PPT won't complain about unused items in [Content_Types].xml, + * but we remove them anyway in case the file mentioned in PartName- + * attribute does not exist. + */ + static async removeUnusedContentTypes( + xml: XMLDocument, + i: number, + archive: JSZip, + ): Promise { + await XmlHelper.removeIf({ + archive, + file: `[Content_Types].xml`, + tag: 'Override', + clause: (xml: XMLDocument, element: Element) => { + const filename = element.getAttribute('PartName').substring(1); + return FileHelper.fileExistsInArchive(archive, filename) ? false : true; + }, + }); + } + + static async removedUnusedImages( + xml: XMLDocument, + i: number, + archive: JSZip, + ): Promise { + await Tracker.analyzeContents(archive); + + const extensions = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'emf']; + const keepFiles = []; + + await Tracker.collect('ppt/slides', 'image', keepFiles); + await Tracker.collect('ppt/slideMasters', 'image', keepFiles); + await Tracker.collect('ppt/slideLayouts', 'image', keepFiles); + + FileHelper.removeFromDirectory(archive, 'ppt/media', (file) => { + const info = FileHelper.getFileInfo(file.name); + return ( + extensions.includes(info.extension.toLowerCase()) && + !keepFiles.includes(info.base) + ); + }); + } } diff --git a/src/helper/xml-helper.ts b/src/helper/xml-helper.ts index b1755a7..f5a50ad 100644 --- a/src/helper/xml-helper.ts +++ b/src/helper/xml-helper.ts @@ -14,6 +14,7 @@ import { XmlPrettyPrint } from './xml-pretty-print'; import { GetRelationshipsCallback, Target } from '../types/types'; import _ from 'lodash'; import { vd } from './general-helper'; +import { contentTracker, ContentTracker } from './content-tracker'; export class XmlHelper { static async modifyXmlInArchive( @@ -21,10 +22,12 @@ export class XmlHelper { file: string, callbacks: ModifyXmlCallback[], ): Promise { - const xml = await XmlHelper.getXmlFromArchive(await archive, file); + const jsZip = await archive; + const xml = await XmlHelper.getXmlFromArchive(jsZip, file); + let i = 0; for (const callback of callbacks) { - callback(xml); + await callback(xml, i++, jsZip); } return await XmlHelper.writeXmlToArchive(await archive, file, xml); @@ -75,12 +78,16 @@ export class XmlHelper { const newElement = xml.createElement(element.tag); for (const attribute in element.attributes) { const value = element.attributes[attribute]; - newElement.setAttribute( - attribute, - typeof value === 'function' ? value(xml) : value, - ); + const setValue = typeof value === 'function' ? value(xml) : value; + + newElement.setAttribute(attribute, setValue); } + contentTracker.trackRelation( + element.file, + element.attributes as RelationshipAttribute, + ); + if (element.assert) { element.assert(xml); } @@ -93,6 +100,29 @@ export class XmlHelper { return newElement as unknown as HelperElement; } + static async removeIf(element: HelperElement): Promise { + const xml = await XmlHelper.getXmlFromArchive( + element.archive, + element.file, + ); + + const collection = xml.getElementsByTagName(element.tag); + const toRemove: Element[] = []; + XmlHelper.modifyCollection(collection, (item: Element, index) => { + if (element.clause(xml, item)) { + toRemove.push(item); + } + }); + + toRemove.forEach((item) => { + XmlHelper.remove(item); + }); + + await XmlHelper.writeXmlToArchive(element.archive, element.file, xml); + + return toRemove; + } + static async getNextRelId(rootArchive: JSZip, file: string): Promise { const presentationRelsXml = await XmlHelper.getXmlFromArchive( rootArchive, @@ -219,13 +249,22 @@ export class XmlHelper { archive: JSZip, path: string, cb: GetRelationshipsCallback, + ): Promise { + return this.getRelationshipItems(archive, path, 'Relationship', cb); + } + + static async getRelationshipItems( + archive: JSZip, + path: string, + tag: string, + cb: GetRelationshipsCallback, ): Promise { const xml = await XmlHelper.getXmlFromArchive(archive, path); - const relationships = xml.getElementsByTagName('Relationship'); + const relationshipItems = xml.getElementsByTagName(tag); const rels = []; - Object.keys(relationships) - .map((key) => relationships[key] as Element) + Object.keys(relationshipItems) + .map((key) => relationshipItems[key] as Element) .filter((element) => element.getAttribute !== undefined) .forEach((element) => cb(element, rels)); @@ -268,6 +307,14 @@ export class XmlHelper { ) { element.setAttribute(attributeName, replaceValue); } + + if (element.getAttribute !== undefined) { + contentTracker.trackRelation(path, { + Id: element.getAttribute('Id'), + Target: element.getAttribute('Target'), + Type: element.getAttribute('Type'), + }); + } } return XmlHelper.writeXmlToArchive(archive, path, xml); } @@ -394,6 +441,8 @@ export class XmlHelper { targetRelFile: string, attributes: RelationshipAttribute, ): HelperElement { + contentTracker.trackRelation(targetRelFile, attributes); + return { archive, file: targetRelFile, @@ -435,17 +484,19 @@ export class XmlHelper { ): void { if (from !== undefined) { for (let i = from; i < length; i++) { - const toRemove = collection[i]; - toRemove.parentNode.removeChild(toRemove); + XmlHelper.remove(collection[i]); } } else { for (let i = collection.length; i > length; i--) { - const toRemove = collection[i - 1]; - toRemove.parentNode.removeChild(toRemove); + XmlHelper.remove(collection[i - 1]); } } } + static remove(toRemove: Element): void { + toRemove.parentNode.removeChild(toRemove); + } + static sortCollection( collection: HTMLCollectionOf, order: number[], diff --git a/src/interfaces/ipresentation-props.ts b/src/interfaces/ipresentation-props.ts index ab9be7c..d4cb3a1 100644 --- a/src/interfaces/ipresentation-props.ts +++ b/src/interfaces/ipresentation-props.ts @@ -1,6 +1,7 @@ import { AutomizerParams, StatusTracker } from '../types/types'; import { PresTemplate } from './pres-template'; import { RootPresTemplate } from './root-pres-template'; +import { ContentTracker } from '../helper/content-tracker'; export interface IPresentationProps { rootTemplate: RootPresTemplate; @@ -8,5 +9,6 @@ export interface IPresentationProps { params: AutomizerParams; timer: number; status?: StatusTracker; + content?: ContentTracker; getTemplate(name: string): PresTemplate; } diff --git a/src/interfaces/root-pres-template.ts b/src/interfaces/root-pres-template.ts index 5b09c3f..c8d6468 100644 --- a/src/interfaces/root-pres-template.ts +++ b/src/interfaces/root-pres-template.ts @@ -1,6 +1,7 @@ import { ISlide } from './islide'; import { ICounter } from './icounter'; import { ITemplate } from './itemplate'; +import { ContentTracker } from '../helper/content-tracker'; export interface RootPresTemplate extends ITemplate { slides: ISlide[]; @@ -13,4 +14,5 @@ export interface RootPresTemplate extends ITemplate { appendSlide(slide: ISlide): Promise; countExistingSlides(): Promise; truncate(): Promise; + content?: ContentTracker; } diff --git a/src/shapes/chart.ts b/src/shapes/chart.ts index a18a3eb..e18e4bb 100644 --- a/src/shapes/chart.ts +++ b/src/shapes/chart.ts @@ -8,6 +8,7 @@ import { HelperElement, RelationshipAttribute } from '../types/xml-types'; import { ImportedElement, Target, Workbook } from '../types/types'; import { IChart } from '../interfaces/ichart'; import { RootPresTemplate } from '../interfaces/root-pres-template'; +import { contentTracker } from '../helper/content-tracker'; export class Chart extends Shape implements IChart { sourceWorksheet: number | string; @@ -349,23 +350,45 @@ export class Chart extends Shape implements IChart { const type = element.getAttribute('Type'); switch (type) { case 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/package': - element.setAttribute( + this.updateTargetWorksheetRelation( + targetRelFile, + element, 'Target', `${this.wbEmbeddingsPath}${this.worksheetFilePrefix}${this.targetWorksheet}${this.wbExtension}`, ); break; case this.relTypeChartColorStyle: - element.setAttribute('Target', `colors${this.targetNumber}.xml`); + this.updateTargetWorksheetRelation( + targetRelFile, + element, + 'Target', + `colors${this.targetNumber}.xml`, + ); break; case this.relTypeChartStyle: - element.setAttribute('Target', `style${this.targetNumber}.xml`); + this.updateTargetWorksheetRelation( + targetRelFile, + element, + 'Target', + `style${this.targetNumber}.xml`, + ); break; case this.relTypeChartImage: const target = element.getAttribute('Target'); const imageInfo = this.getTargetChartImageUri(target); - element.setAttribute('Target', imageInfo.rel); + this.updateTargetWorksheetRelation( + targetRelFile, + element, + target, + imageInfo, + ); break; } + contentTracker.trackRelation(targetRelFile, { + Id: element.getAttribute('Id'), + Target: element.getAttribute('Target'), + Type: element.getAttribute('Type'), + }); }); await XmlHelper.writeXmlToArchive( @@ -375,6 +398,10 @@ export class Chart extends Shape implements IChart { ); } + updateTargetWorksheetRelation(targetRelFile, element, attribute, value) { + element.setAttribute(attribute, value); + } + getTargetChartImageUri(origin: string): { source: string; target: string; @@ -392,11 +419,12 @@ export class Chart extends Shape implements IChart { } async copyWorksheetFile(): Promise { + const targetFile = `ppt/embeddings/${this.worksheetFilePrefix}${this.targetWorksheet}${this.wbExtension}`; await FileHelper.zipCopy( this.sourceArchive, `ppt/embeddings/${this.worksheetFilePrefix}${this.sourceWorksheet}${this.wbExtension}`, this.targetArchive, - `ppt/embeddings/${this.worksheetFilePrefix}${this.targetWorksheet}${this.wbExtension}`, + targetFile, ); } diff --git a/src/types/types.ts b/src/types/types.ts index 3f51807..9da740d 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1,5 +1,6 @@ import JSZip from 'jszip'; import { ElementSubtype, ElementType } from '../enums/element-type'; +import { RelationshipAttribute } from './xml-types'; export type SourceSlideIdentifier = number | string; export type SlideModificationCallback = (document: Document) => void; @@ -33,6 +34,10 @@ export type AutomizerParams = { * Buffer unzipped pptx on disk */ cacheDir?: string; + /** + * Zip compression level 0-9 + */ + compression?: number; rootTemplate?: string; presTemplates?: string[]; useCreationIds?: boolean; @@ -41,6 +46,10 @@ export type AutomizerParams = { * before automation starts. */ removeExistingSlides?: boolean; + /** + * Eventually remove all unnecessary files from archive. + */ + cleanup?: boolean; /** * statusTracker will be triggered on each appended slide. * You can e.g. attach a custom callback to a progress bar. @@ -67,14 +76,49 @@ export type AutomizerSummary = { }; export type Target = { file: string; + type: string; + filename: string; number?: number; rId?: string; prefix?: string; subtype?: ElementSubtype; - type: string; - filename: string; - filenameExt: string; - filenameBase: string; + filenameExt?: string; + filenameBase?: string; + getCreatedContent?: () => TrackedRelationInfo; + getRelatedContent?: () => Promise; + relatedContent?: Target; +}; +export type FileInfo = { + base: string; + extension: string; + dir: string; + isDir: boolean; +}; +export type TrackedFiles = Record; +export type TrackedRelationInfo = { + base: string; + attributes?: RelationshipAttribute; +}; +export type TrackedRelations = Record; +export type TrackedRelation = { + tag: string; + type?: string; + attribute?: string; + role?: + | 'image' + | 'slideMaster' + | 'slide' + | 'chart' + | 'externalData' + | 'slideLayout'; + targets?: Target[]; +}; +export type TrackedRelationTag = { + source: string; + relationsKey: string; + isDir?: boolean; + tags: TrackedRelation[]; + getTrackedRelations?: (role: string) => TrackedRelation[]; }; export type ImportElement = { presName: string; diff --git a/src/types/xml-types.ts b/src/types/xml-types.ts index 07ee9fe..8addc10 100644 --- a/src/types/xml-types.ts +++ b/src/types/xml-types.ts @@ -24,8 +24,8 @@ export type OverrideAttribute = { export type HelperElement = { archive: JSZip; assert?: (xml: XMLDocument) => void; - clause?: (xml: XMLDocument) => boolean; - parent: (xml: XMLDocument) => Element; + clause?: (xml: XMLDocument, element?: Element) => boolean; + parent?: (xml: XMLDocument) => Element; file: string; tag: string; attributes?: @@ -66,4 +66,5 @@ export type ElementInfo = { export type ModifyXmlCallback = ( xml: XMLDocument | Element, index?: number, + archive?: JSZip, ) => void;