/** * PptxGenJS: Slide Object Generators */ import { BARCHART_COLORS, CHART_NAME, CHART_TYPE, DEF_CELL_BORDER, DEF_CELL_MARGIN_IN, DEF_CHART_BORDER, DEF_FONT_COLOR, DEF_FONT_SIZE, DEF_SHAPE_LINE_COLOR, DEF_SLIDE_MARGIN_IN, EMU, IMG_PLAYBTN, MASTER_OBJECTS, PIECHART_COLORS, SCHEME_COLOR_NAMES, SHAPE_NAME, SHAPE_TYPE, SLIDE_OBJECT_TYPES, TEXT_HALIGN, TEXT_VALIGN, } from './core-enums' import { AddSlideProps, BackgroundProps, FormulaProps, IChartMulti, IChartOptsLib, IOptsChartData, ISlideObject, ImageProps, MediaProps, ObjectOptions, OptsChartGridLine, PresLayout, PresSlide, ShapeLineProps, ShapeProps, SlideLayout, SlideMasterProps, TableCell, TableProps, TableRow, TextProps, TextPropsOptions, } from './core-interfaces' import { getSlidesForTableRows } from './gen-tables' import { encodeXmlEntities, getNewRelId, getSmartParseNumber, inch2Emu, valToPts, correctShadowOptions } from './gen-utils' /** counter for included charts (used for index in their filenames) */ let _chartCounter = 0 /** * Transforms a slide definition to a slide object that is then passed to the XML transformation process. * @param {SlideMasterProps} props - slide definition * @param {PresSlide|SlideLayout} target - empty slide object that should be updated by the passed definition */ export function createSlideMaster(props: SlideMasterProps, target: SlideLayout): void { // STEP 1: Add background if either the slide or layout has background props // if (props.background || target.background) addBackgroundDefinition(props.background, target) if (props.bkgd) target.bkgd = props.bkgd // DEPRECATED: (remove in v4.0.0) // STEP 2: Add all Slide Master objects in the order they were given if (props.objects && Array.isArray(props.objects) && props.objects.length > 0) { props.objects.forEach((object, idx) => { const key = Object.keys(object)[0] const tgt = target as PresSlide if (MASTER_OBJECTS[key] && key === 'chart') addChartDefinition(tgt, object[key].type, object[key].data, object[key].opts) else if (MASTER_OBJECTS[key] && key === 'image') addImageDefinition(tgt, object[key]) else if (MASTER_OBJECTS[key] && key === 'line') addShapeDefinition(tgt, SHAPE_TYPE.LINE, object[key]) else if (MASTER_OBJECTS[key] && key === 'rect') addShapeDefinition(tgt, SHAPE_TYPE.RECTANGLE, object[key]) else if (MASTER_OBJECTS[key] && key === 'text') addTextDefinition(tgt, [{ text: object[key].text }], object[key].options, false) else if (MASTER_OBJECTS[key] && key === 'placeholder') { // TODO: 20180820: Check for existing `name`? object[key].options.placeholder = object[key].options.name delete object[key].options.name // remap name for earlier handling internally object[key].options._placeholderType = object[key].options.type delete object[key].options.type // remap name for earlier handling internally object[key].options._placeholderIdx = 100 + idx addTextDefinition(tgt, [{ text: object[key].text }], object[key].options, true) // TODO: ISSUE#599 - only text is supported now (add more below) // else if (object[key].image) addImageDefinition(tgt, object[key].image) /* 20200120: So... image placeholders go into the "slideLayoutN.xml" file and addImage doesn't do this yet... */ } }) } // STEP 3: Add Slide Numbers (NOTE: Do this last so numbers are not covered by objects!) if (props.slideNumber && typeof props.slideNumber === 'object') target._slideNumberProps = props.slideNumber } /** * Generate the chart based on input data. * OOXML Chart Spec: ISO/IEC 29500-1:2016(E) * * @param {CHART_NAME | IChartMulti[]} `type` should belong to: 'column', 'pie' * @param {[]} `data` a JSON object with follow the following format * @param {IChartOptsLib} `opt` chart options * @param {PresSlide} `target` slide object that the chart will be added to * @return {object} chart object * { * title: 'eSurvey chart', * data: [ * { * name: 'Income', * labels: ['2005', '2006', '2007', '2008', '2009'], * values: [23.5, 26.2, 30.1, 29.5, 24.6] * }, * { * name: 'Expense', * labels: ['2005', '2006', '2007', '2008', '2009'], * values: [18.1, 22.8, 23.9, 25.1, 25] * } * ] * } */ export function addChartDefinition(target: PresSlide, type: CHART_NAME | IChartMulti[], data: IOptsChartData[], opt: IChartOptsLib): object { function correctGridLineOptions(glOpts: OptsChartGridLine): void { if (!glOpts || glOpts.style === 'none') return if (glOpts.size !== undefined && (isNaN(Number(glOpts.size)) || glOpts.size <= 0)) { console.warn('Warning: chart.gridLine.size must be greater than 0.') delete glOpts.size // delete prop to used defaults } if (glOpts.style && !['solid', 'dash', 'dot'].includes(glOpts.style)) { console.warn('Warning: chart.gridLine.style options: `solid`, `dash`, `dot`.') delete glOpts.style } if (glOpts.cap && !['flat', 'square', 'round'].includes(glOpts.cap)) { console.warn('Warning: chart.gridLine.cap options: `flat`, `square`, `round`.') delete glOpts.cap } } const chartId = ++_chartCounter const resultObject = { _type: null, text: null, options: null, chartRid: null, } // DESIGN: `type` can an object (ex: `pptx.charts.DOUGHNUT`) or an array of chart objects // EX: addChartDefinition([ { type:pptx.charts.BAR, data:{name:'', labels:[], values[]} }, {} ]) // Multi-Type Charts let tmpOpt = null let tmpData = [] if (Array.isArray(type)) { // For multi-type charts there needs to be data for each type, // as well as a single data source for non-series operations. // The data is indexed below to keep the data in order when segmented // into types. type.forEach(obj => { tmpData = tmpData.concat(obj.data) }) tmpOpt = data || opt } else { tmpData = data tmpOpt = opt } tmpData.forEach((item, i) => { item._dataIndex = i // Converts the 'labels' array from string[] to string[][] (or the respective primitive type), if needed if (item.labels !== undefined && !Array.isArray(item.labels[0])) { item.labels = [item.labels as string[]] } }) const options: IChartOptsLib = tmpOpt && typeof tmpOpt === 'object' ? tmpOpt : {} // STEP 1: TODO: check for reqd fields, correct type, etc // `type` exists in CHART_TYPE // Array.isArray(data) /* if ( Array.isArray(rel.data) && rel.data.length > 0 && typeof rel.data[0] === 'object' && rel.data[0].labels && Array.isArray(rel.data[0].labels) && rel.data[0].values && Array.isArray(rel.data[0].values) ) { obj = rel.data[0]; } else { console.warn("USAGE: addChart( 'pie', [ {name:'Sales', labels:['Jan','Feb'], values:[10,20]} ], {x:1, y:1} )"); return; } */ // STEP 2: Set default options/decode user options // A: Core options._type = type options.x = typeof options.x !== 'undefined' && options.x != null && !isNaN(Number(options.x)) ? options.x : 1 options.y = typeof options.y !== 'undefined' && options.y != null && !isNaN(Number(options.y)) ? options.y : 1 options.w = options.w || '50%' options.h = options.h || '50%' options.objectName = options.objectName ? encodeXmlEntities(options.objectName) : `Chart ${target._slideObjects.filter(obj => obj._type === SLIDE_OBJECT_TYPES.chart).length}` // B: Options: misc if (!['bar', 'col'].includes(options.barDir || '')) options.barDir = 'col' // barGrouping: "21.2.3.17 ST_Grouping (Grouping)" // barGrouping must be handled before data label validation as it can affect valid label positioning if (options._type === CHART_TYPE.AREA) { if (!['stacked', 'standard', 'percentStacked'].includes(options.barGrouping || '')) options.barGrouping = 'standard' } if (options._type === CHART_TYPE.BAR) { if (!['clustered', 'stacked', 'percentStacked'].includes(options.barGrouping || '')) options.barGrouping = 'clustered' } if (options._type === CHART_TYPE.BAR3D) { if (!['clustered', 'stacked', 'standard', 'percentStacked'].includes(options.barGrouping || '')) options.barGrouping = 'standard' } if (options.barGrouping?.includes('tacked')) { if (!options.barGapWidthPct) options.barGapWidthPct = 50 } // Clean up and validate data label positions // REFERENCE: https://docs.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/e2b1697c-7adc-463d-9081-3daef72f656f?redirectedfrom=MSDN if (options.dataLabelPosition) { if (options._type === CHART_TYPE.AREA || options._type === CHART_TYPE.BAR3D || options._type === CHART_TYPE.DOUGHNUT || options._type === CHART_TYPE.RADAR) { delete options.dataLabelPosition } if (options._type === CHART_TYPE.PIE) { if (!['bestFit', 'ctr', 'inEnd', 'outEnd'].includes(options.dataLabelPosition)) delete options.dataLabelPosition } if (options._type === CHART_TYPE.BUBBLE || options._type === CHART_TYPE.BUBBLE3D || options._type === CHART_TYPE.LINE || options._type === CHART_TYPE.SCATTER) { if (!['b', 'ctr', 'l', 'r', 't'].includes(options.dataLabelPosition)) delete options.dataLabelPosition } if (options._type === CHART_TYPE.BAR) { if (!['stacked', 'percentStacked'].includes(options.barGrouping || '')) { if (!['ctr', 'inBase', 'inEnd'].includes(options.dataLabelPosition)) delete options.dataLabelPosition } if (!['clustered'].includes(options.barGrouping || '')) { if (!['ctr', 'inBase', 'inEnd', 'outEnd'].includes(options.dataLabelPosition)) delete options.dataLabelPosition } } } options.dataLabelBkgrdColors = options.dataLabelBkgrdColors || !options.dataLabelBkgrdColors ? options.dataLabelBkgrdColors : false if (!['b', 'l', 'r', 't', 'tr'].includes(options.legendPos || '')) options.legendPos = 'r' // 3D bar: ST_Shape if (!['cone', 'coneToMax', 'box', 'cylinder', 'pyramid', 'pyramidToMax'].includes(options.bar3DShape || '')) options.bar3DShape = 'box' // lineDataSymbol: http://www.datypic.com/sc/ooxml/a-val-32.html // Spec has [plus,star,x] however neither PPT2013 nor PPT-Online support them if (!['circle', 'dash', 'diamond', 'dot', 'none', 'square', 'triangle'].includes(options.lineDataSymbol || '')) options.lineDataSymbol = 'circle' if (!['gap', 'span'].includes(options.displayBlanksAs || '')) options.displayBlanksAs = 'span' if (!['standard', 'marker', 'filled'].includes(options.radarStyle || '')) options.radarStyle = 'standard' options.lineDataSymbolSize = options.lineDataSymbolSize && !isNaN(options.lineDataSymbolSize) ? options.lineDataSymbolSize : 6 options.lineDataSymbolLineSize = options.lineDataSymbolLineSize && !isNaN(options.lineDataSymbolLineSize) ? valToPts(options.lineDataSymbolLineSize) : valToPts(0.75) // `layout` allows the override of PPT defaults to maximize space if (options.layout) { ['x', 'y', 'w', 'h'].forEach(key => { const val = options.layout[key] if (isNaN(Number(val)) || val < 0 || val > 1) { console.warn('Warning: chart.layout.' + key + ' can only be 0-1') delete options.layout[key] // remove invalid value so that default will be used } }) } // Set gridline defaults options.catGridLine = options.catGridLine || (options._type === CHART_TYPE.SCATTER ? { color: 'D9D9D9', size: 1 } : { style: 'none' }) options.valGridLine = options.valGridLine || (options._type === CHART_TYPE.SCATTER ? { color: 'D9D9D9', size: 1 } : {}) options.serGridLine = options.serGridLine || (options._type === CHART_TYPE.SCATTER ? { color: 'D9D9D9', size: 1 } : { style: 'none' }) correctGridLineOptions(options.catGridLine) correctGridLineOptions(options.valGridLine) correctGridLineOptions(options.serGridLine) correctShadowOptions(options.shadow) // C: Options: plotArea options.showDataTable = options.showDataTable || !options.showDataTable ? options.showDataTable : false options.showDataTableHorzBorder = options.showDataTableHorzBorder || !options.showDataTableHorzBorder ? options.showDataTableHorzBorder : true options.showDataTableVertBorder = options.showDataTableVertBorder || !options.showDataTableVertBorder ? options.showDataTableVertBorder : true options.showDataTableOutline = options.showDataTableOutline || !options.showDataTableOutline ? options.showDataTableOutline : true options.showDataTableKeys = options.showDataTableKeys || !options.showDataTableKeys ? options.showDataTableKeys : true options.showLabel = options.showLabel || !options.showLabel ? options.showLabel : false options.showLegend = options.showLegend || !options.showLegend ? options.showLegend : false options.showPercent = options.showPercent || !options.showPercent ? options.showPercent : true options.showTitle = options.showTitle || !options.showTitle ? options.showTitle : false options.showValue = options.showValue || !options.showValue ? options.showValue : false options.showLeaderLines = options.showLeaderLines || !options.showLeaderLines ? options.showLeaderLines : false options.catAxisLineShow = typeof options.catAxisLineShow !== 'undefined' ? options.catAxisLineShow : true options.valAxisLineShow = typeof options.valAxisLineShow !== 'undefined' ? options.valAxisLineShow : true options.serAxisLineShow = typeof options.serAxisLineShow !== 'undefined' ? options.serAxisLineShow : true options.v3DRotX = !isNaN(options.v3DRotX) && options.v3DRotX >= -90 && options.v3DRotX <= 90 ? options.v3DRotX : 30 options.v3DRotY = !isNaN(options.v3DRotY) && options.v3DRotY >= 0 && options.v3DRotY <= 360 ? options.v3DRotY : 30 options.v3DRAngAx = options.v3DRAngAx || !options.v3DRAngAx ? options.v3DRAngAx : true options.v3DPerspective = !isNaN(options.v3DPerspective) && options.v3DPerspective >= 0 && options.v3DPerspective <= 240 ? options.v3DPerspective : 30 // D: Options: chart options.barGapWidthPct = !isNaN(options.barGapWidthPct) && options.barGapWidthPct >= 0 && options.barGapWidthPct <= 1000 ? options.barGapWidthPct : 150 options.barGapDepthPct = !isNaN(options.barGapDepthPct) && options.barGapDepthPct >= 0 && options.barGapDepthPct <= 1000 ? options.barGapDepthPct : 150 options.chartColors = Array.isArray(options.chartColors) ? options.chartColors : options._type === CHART_TYPE.PIE || options._type === CHART_TYPE.DOUGHNUT ? PIECHART_COLORS : BARCHART_COLORS options.chartColorsOpacity = options.chartColorsOpacity && !isNaN(options.chartColorsOpacity) ? options.chartColorsOpacity : null // DEPRECATED: v3.11.0 - use `plotArea.border` vvv options.border = options.border && typeof options.border === 'object' ? options.border : null if (options.border && (!options.border.pt || isNaN(options.border.pt))) options.border.pt = DEF_CHART_BORDER.pt if (options.border && (!options.border.color || typeof options.border.color !== 'string')) options.border.color = DEF_CHART_BORDER.color // DEPRECATED: (remove above in v4.0) ^^^ options.plotArea = options.plotArea || {} options.plotArea.border = options.plotArea.border && typeof options.plotArea.border === 'object' ? options.plotArea.border : null if (options.plotArea.border && (!options.plotArea.border.pt || isNaN(options.plotArea.border.pt))) options.plotArea.border.pt = DEF_CHART_BORDER.pt if (options.plotArea.border && (!options.plotArea.border.color || typeof options.plotArea.border.color !== 'string')) { options.plotArea.border.color = DEF_CHART_BORDER.color } if (options.border) options.plotArea.border = options.border // @deprecated [[remove in v4.0]] options.plotArea.fill = options.plotArea.fill || { color: null, transparency: null } if (options.fill) options.plotArea.fill.color = options.fill // @deprecated [[remove in v4.0]] // options.chartArea = options.chartArea || {} options.chartArea.border = options.chartArea.border && typeof options.chartArea.border === 'object' ? options.chartArea.border : null if (options.chartArea.border) { options.chartArea.border = { color: options.chartArea.border.color || DEF_CHART_BORDER.color, pt: options.chartArea.border.pt || DEF_CHART_BORDER.pt, } } options.chartArea.roundedCorners = typeof options.chartArea.roundedCorners === 'boolean' ? options.chartArea.roundedCorners : true // options.dataBorder = options.dataBorder && typeof options.dataBorder === 'object' ? options.dataBorder : null if (options.dataBorder && (!options.dataBorder.pt || isNaN(options.dataBorder.pt))) options.dataBorder.pt = 0.75 if (options.dataBorder && options.dataBorder.color) { const isHexColor = typeof options.dataBorder.color === 'string' && options.dataBorder.color.length === 6 && /^[0-9A-Fa-f]{6}$/.test(options.dataBorder.color) const isSchemeColor = Object.values(SCHEME_COLOR_NAMES).includes(options.dataBorder.color as SCHEME_COLOR_NAMES) if (!isHexColor && !isSchemeColor) { options.dataBorder.color = 'F9F9F9' // Fallback if neither hex nor scheme color } } // if (!options.dataLabelFormatCode && options._type === CHART_TYPE.SCATTER) options.dataLabelFormatCode = 'General' if (!options.dataLabelFormatCode && (options._type === CHART_TYPE.PIE || options._type === CHART_TYPE.DOUGHNUT)) { options.dataLabelFormatCode = options.showPercent ? '0%' : 'General' } options.dataLabelFormatCode = options.dataLabelFormatCode && typeof options.dataLabelFormatCode === 'string' ? options.dataLabelFormatCode : '#,##0' // // Set default format for Scatter chart labels to custom string if not defined if (!options.dataLabelFormatScatter && options._type === CHART_TYPE.SCATTER) options.dataLabelFormatScatter = 'custom' // options.lineSize = typeof options.lineSize === 'number' ? options.lineSize : 2 options.valAxisMajorUnit = typeof options.valAxisMajorUnit === 'number' ? options.valAxisMajorUnit : null if (options._type === CHART_TYPE.AREA || options._type === CHART_TYPE.BAR || options._type === CHART_TYPE.BAR3D || options._type === CHART_TYPE.LINE) { options.catAxisMultiLevelLabels = !!options.catAxisMultiLevelLabels } else { delete options.catAxisMultiLevelLabels } // STEP 4: Set props resultObject._type = 'chart' resultObject.options = options resultObject.chartRid = getNewRelId(target) // STEP 5: Add this chart to this Slide Rels (rId/rels count spans all slides! Count all images to get next rId) target._relsChart.push({ rId: getNewRelId(target), data: tmpData, opts: options, type: options._type, globalId: chartId, fileName: `chart${chartId}.xml`, Target: `/ppt/charts/chart${chartId}.xml`, }) target._slideObjects.push(resultObject) return resultObject } /** * Adds an image object to a slide definition. * This method can be called with only two args (opt, target) - this is supposed to be the only way in future. * @param {ImageProps} `opt` - object containing `path`/`data`, `x`, `y`, etc. * @param {PresSlide} `target` - slide that the image should be added to (if not specified as the 2nd arg) * @note: Remote images (eg: "http://whatev.com/blah"/from web and/or remote server arent supported yet - we'd need to create an , load it, then send to canvas * @see: https://stackoverflow.com/questions/164181/how-to-fetch-a-remote-image-to-display-in-a-canvas) */ export function addImageDefinition(target: PresSlide, opt: ImageProps): void { const newObject: ISlideObject = { _type: null, text: null, options: null, image: null, imageRid: null, hyperlink: null, } // FIRST: Set vars for this image (object param replaces positional args in 1.1.0) const intPosX = opt.x || 0 const intPosY = opt.y || 0 const intWidth = opt.w || 0 const intHeight = opt.h || 0 const sizing = opt.sizing || null const objHyperlink = opt.hyperlink || '' const strImageData = opt.data || '' const strImagePath = opt.path || '' let imageRelId = getNewRelId(target) const objectName = opt.objectName ? encodeXmlEntities(opt.objectName) : `Image ${target._slideObjects.filter(obj => obj._type === SLIDE_OBJECT_TYPES.image).length}` // REALITY-CHECK: if (!strImagePath && !strImageData) { console.error('ERROR: addImage() requires either \'data\' or \'path\' parameter!') return null } else if (strImagePath && typeof strImagePath !== 'string') { console.error(`ERROR: addImage() 'path' should be a string, ex: {path:'/img/sample.png'} - you sent ${String(strImagePath)}`) return null } else if (strImageData && typeof strImageData !== 'string') { console.error(`ERROR: addImage() 'data' should be a string, ex: {data:'image/png;base64,NMP[...]'} - you sent ${String(strImageData)}`) return null } else if (strImageData && typeof strImageData === 'string' && !strImageData.toLowerCase().includes('base64,')) { console.error('ERROR: Image `data` value lacks a base64 header! Ex: \'image/png;base64,NMP[...]\')') return null } // STEP 1: Set extension // NOTE: Split to address URLs with params (eg: `path/brent.jpg?someParam=true`) let strImgExtn = ( strImagePath .substring(strImagePath.lastIndexOf('/') + 1) .split('?')[0] .split('.') .pop() .split('#')[0] || 'png' ).toLowerCase() // However, pre-encoded images can be whatever mime-type they want (and good for them!) if (strImageData && /image\/(\w+);/.exec(strImageData) && /image\/(\w+);/.exec(strImageData).length > 0) { strImgExtn = /image\/(\w+);/.exec(strImageData)[1] } else if (strImageData?.toLowerCase().includes('image/svg+xml')) { strImgExtn = 'svg' } // STEP 2: Set type/path newObject._type = SLIDE_OBJECT_TYPES.image newObject.image = strImagePath || 'preencoded.png' // STEP 3: Set image properties & options // FIXME: Measure actual image when no intWidth/intHeight params passed // ....: This is an async process: we need to make getSizeFromImage use callback, then set H/W... // if ( !intWidth || !intHeight ) { var imgObj = getSizeFromImage(strImagePath); newObject.options = { x: intPosX || 0, y: intPosY || 0, w: intWidth || 1, h: intHeight || 1, altText: opt.altText || '', rounding: typeof opt.rounding === 'boolean' ? opt.rounding : false, sizing, placeholder: opt.placeholder, rotate: opt.rotate || 0, flipV: opt.flipV || false, flipH: opt.flipH || false, transparency: opt.transparency || 0, objectName, shadow: correctShadowOptions(opt.shadow), } // STEP 4: Add this image to this Slide Rels (rId/rels count spans all slides! Count all images to get next rId) if (strImgExtn === 'svg') { // SVG files consume *TWO* rId's: (a png version and the svg image) // // target._relsMedia.push({ path: strImagePath || strImageData + 'png', type: 'image/png', extn: 'png', data: strImageData || '', rId: imageRelId, Target: `../media/image-${target._slideNum}-${target._relsMedia.length + 1}.png`, isSvgPng: true, svgSize: { w: getSmartParseNumber(newObject.options.w, 'X', target._presLayout), h: getSmartParseNumber(newObject.options.h, 'Y', target._presLayout) }, }) newObject.imageRid = imageRelId target._relsMedia.push({ path: strImagePath || strImageData, type: 'image/svg+xml', extn: strImgExtn, data: strImageData || '', rId: imageRelId + 1, Target: `../media/image-${target._slideNum}-${target._relsMedia.length + 1}.${strImgExtn}`, }) newObject.imageRid = imageRelId + 1 } else { // PERF: Duplicate media should reuse existing `Target` value and not create an additional copy const dupeItem = target._relsMedia.filter(item => item.path && item.path === strImagePath && item.type === 'image/' + strImgExtn && !item.isDuplicate)[0] target._relsMedia.push({ path: strImagePath || 'preencoded.' + strImgExtn, type: 'image/' + strImgExtn, extn: strImgExtn, data: strImageData || '', rId: imageRelId, isDuplicate: !!(dupeItem?.Target), Target: dupeItem?.Target ? dupeItem.Target : `../media/image-${target._slideNum}-${target._relsMedia.length + 1}.${strImgExtn}`, }) newObject.imageRid = imageRelId } // STEP 5: Hyperlink support if (typeof objHyperlink === 'object') { if (!objHyperlink.url && !objHyperlink.slide) throw new Error('ERROR: `hyperlink` option requires either: `url` or `slide`') else { imageRelId++ target._rels.push({ type: SLIDE_OBJECT_TYPES.hyperlink, data: objHyperlink.slide ? 'slide' : 'dummy', rId: imageRelId, Target: objHyperlink.url || objHyperlink.slide.toString(), }) objHyperlink._rId = imageRelId newObject.hyperlink = objHyperlink } } // STEP 6: Add object to slide target._slideObjects.push(newObject) } /** * Adds a media object to a slide definition. * @param {PresSlide} `target` - slide object that the media will be added to * @param {MediaProps} `opt` - media options */ export function addMediaDefinition(target: PresSlide, opt: MediaProps): void { const intPosX = opt.x || 0 const intPosY = opt.y || 0 const intSizeX = opt.w || 2 const intSizeY = opt.h || 2 const strData = opt.data || '' const strLink = opt.link || '' const strPath = opt.path || '' const strType = opt.type || 'audio' let strExtn = '' const strCover = opt.cover || IMG_PLAYBTN const objectName = opt.objectName ? encodeXmlEntities(opt.objectName) : `Media ${target._slideObjects.filter(obj => obj._type === SLIDE_OBJECT_TYPES.media).length}` const slideData: ISlideObject = { _type: SLIDE_OBJECT_TYPES.media } // STEP 1: REALITY-CHECK if (!strPath && !strData && strType !== 'online') { throw new Error('addMedia() error: either `data` or `path` are required!') } else if (strData && !strData.toLowerCase().includes('base64,')) { throw new Error('addMedia() error: `data` value lacks a base64 header! Ex: \'video/mpeg;base64,NMP[...]\')') } else if (strCover && !strCover.toLowerCase().includes('base64,')) { throw new Error('addMedia() error: `cover` value lacks a base64 header! Ex: \'data:image/png;base64,iV[...]\')') } // Online Video: requires `link` if (strType === 'online' && !strLink) { throw new Error('addMedia() error: online videos require `link` value') } // FIXME: 20190707 // strType = strData ? strData.split(';')[0].split('/')[0] : strType strExtn = opt.extn || (strData ? strData.split(';')[0].split('/')[1] : strPath.split('.').pop()) || 'mp3' // STEP 2: Set type, media slideData.mtype = strType slideData.media = strPath || 'preencoded.mov' slideData.options = {} // STEP 3: Set media properties & options slideData.options.x = intPosX slideData.options.y = intPosY slideData.options.w = intSizeX slideData.options.h = intSizeY slideData.options.objectName = objectName // STEP 4: Add this media to this Slide Rels (rId/rels count spans all slides! Count all media to get next rId) /** * NOTE: * - rId starts at 2 (hence the intRels+1 below) as slideLayout.xml is rId=1! * * NOTE: * - Audio/Video files consume *TWO* rId's: * * */ if (strType === 'online') { const relId1 = getNewRelId(target) // A: Add video target._relsMedia.push({ path: strPath || 'preencoded' + strExtn, data: 'dummy', type: 'online', extn: strExtn, rId: relId1, Target: strLink, }) slideData.mediaRid = relId1 // B: Add cover (preview/overlay) image target._relsMedia.push({ path: 'preencoded.png', data: strCover, type: 'image/png', extn: 'png', rId: getNewRelId(target), Target: `../media/image-${target._slideNum}-${target._relsMedia.length + 1}.png`, }) } else { // PERF: Duplicate media should reuse existing `Target` value and not create an additional copy const dupeItem = target._relsMedia.filter(item => item.path && item.path === strPath && item.type === strType + '/' + strExtn && !item.isDuplicate)[0] // A: "relationships/video" const relId1 = getNewRelId(target) target._relsMedia.push({ path: strPath || 'preencoded' + strExtn, type: strType + '/' + strExtn, extn: strExtn, data: strData || '', rId: relId1, isDuplicate: !!(dupeItem?.Target), Target: dupeItem?.Target ? dupeItem.Target : `../media/media-${target._slideNum}-${target._relsMedia.length + 1}.${strExtn}`, }) slideData.mediaRid = relId1 // B: "relationships/media" target._relsMedia.push({ path: strPath || 'preencoded' + strExtn, type: strType + '/' + strExtn, extn: strExtn, data: strData || '', rId: getNewRelId(target), isDuplicate: !!(dupeItem?.Target), Target: dupeItem?.Target ? dupeItem.Target : `../media/media-${target._slideNum}-${target._relsMedia.length + 0}.${strExtn}`, }) // C: Add cover (preview/overlay) image target._relsMedia.push({ path: 'preencoded.png', type: 'image/png', extn: 'png', data: strCover, rId: getNewRelId(target), Target: `../media/image-${target._slideNum}-${target._relsMedia.length + 1}.png`, }) } // LAST target._slideObjects.push(slideData) } /** * Adds Notes to a slide. * @param {PresSlide} `target` slide object * @param {string} `notes` * @since 2.3.0 */ export function addNotesDefinition(target: PresSlide, notes: string): void { target._slideObjects.push({ _type: SLIDE_OBJECT_TYPES.notes, text: [{ text: notes }], }) } /** * Adds a formula (Office Math / OMML) object to a slide definition. * @param {PresSlide} target slide object that the formula should be added to * @param {FormulaProps} opts formula options */ export function addFormulaDefinition(target: PresSlide, opts: FormulaProps): void { const newObject: ISlideObject = { _type: SLIDE_OBJECT_TYPES.formula, options: { x: opts.x || 0, y: opts.y || 0, w: opts.w, h: opts.h, objectName: opts.objectName ? encodeXmlEntities(opts.objectName) : `Formula ${target._slideObjects.filter((obj) => obj._type === SLIDE_OBJECT_TYPES.formula).length}`, fontSize: opts.fontSize, color: opts.color, }, formula: opts.omml, formulaAlign: opts.align || 'center', } target._slideObjects.push(newObject) } /** * Adds a shape object to a slide definition. * @param {PresSlide} target slide object that the shape should be added to * @param {SHAPE_NAME} shapeName shape name * @param {ShapeProps} opts shape options */ export function addShapeDefinition(target: PresSlide, shapeName: SHAPE_NAME, opts: ShapeProps): void { const options = typeof opts === 'object' ? opts : {} options.line = options.line || { type: 'none' } const newObject: ISlideObject = { _type: SLIDE_OBJECT_TYPES.text, shape: shapeName || SHAPE_TYPE.RECTANGLE, options, text: null, } // Reality check if (!shapeName) throw new Error('Missing/Invalid shape parameter! Example: `addShape(pptxgen.shapes.LINE, {x:1, y:1, w:1, h:1});`') // 1: ShapeLineProps defaults const newLineOpts: ShapeLineProps = { type: options.line.type || 'solid', color: options.line.color || DEF_SHAPE_LINE_COLOR, transparency: options.line.transparency || 0, width: options.line.width || 1, dashType: options.line.dashType || 'solid', beginArrowType: options.line.beginArrowType || null, endArrowType: options.line.endArrowType || null, } if (typeof options.line === 'object' && options.line.type !== 'none') options.line = newLineOpts // 2: Set options defaults options.x = options.x || (options.x === 0 ? 0 : 1) options.y = options.y || (options.y === 0 ? 0 : 1) options.w = options.w || (options.w === 0 ? 0 : 1) options.h = options.h || (options.h === 0 ? 0 : 1) options.objectName = options.objectName ? encodeXmlEntities(options.objectName) : `Shape ${target._slideObjects.filter(obj => obj._type === SLIDE_OBJECT_TYPES.text).length}` // 3: Handle line (lots of deprecated opts) if (typeof options.line === 'string') { const tmpOpts = newLineOpts tmpOpts.color = String(options.line) // @deprecated `options.line` string (was line color) options.line = tmpOpts } if (typeof options.lineSize === 'number') options.line.width = options.lineSize // @deprecated (part of `ShapeLineProps` now) if (typeof options.lineDash === 'string') options.line.dashType = options.lineDash // @deprecated (part of `ShapeLineProps` now) if (typeof options.lineHead === 'string') options.line.beginArrowType = options.lineHead // @deprecated (part of `ShapeLineProps` now) if (typeof options.lineTail === 'string') options.line.endArrowType = options.lineTail // @deprecated (part of `ShapeLineProps` now) // 4: Create hyperlink rels createHyperlinkRels(target, newObject) // LAST: Add object to slide target._slideObjects.push(newObject) } /** * Adds a table object to a slide definition. * @param {PresSlide} target - slide object that the table should be added to * @param {TableRow[]} tableRows - table data * @param {TableProps} options - table options * @param {SlideLayout} slideLayout - Slide layout * @param {PresLayout} presLayout - Presentation layout * @param {Function} addSlide - method * @param {Function} getSlide - method */ export function addTableDefinition( target: PresSlide, tableRows: TableRow[], options: TableProps, slideLayout: SlideLayout, presLayout: PresLayout, addSlide: (options?: AddSlideProps) => PresSlide, getSlide: (slideNumber: number) => PresSlide ): PresSlide[] { const slides: PresSlide[] = [target] // Create array of Slides as more may be added by auto-paging const opt: TableProps = options && typeof options === 'object' ? options : {} opt.objectName = opt.objectName ? encodeXmlEntities(opt.objectName) : `Table ${target._slideObjects.filter(obj => obj._type === SLIDE_OBJECT_TYPES.table).length}` // STEP 1: REALITY-CHECK { // A: check for empty if (tableRows === null || tableRows.length === 0 || !Array.isArray(tableRows)) { throw new Error('addTable: Array expected! EX: \'slide.addTable( [rows], {options} );\' (https://gitbrent.github.io/PptxGenJS/docs/api-tables.html)') } // B: check for non-well-formatted array (ex: rows=['a','b'] instead of [['a','b']]) if (!tableRows[0] || !Array.isArray(tableRows[0])) { throw new Error( 'addTable: \'rows\' should be an array of cells! EX: \'slide.addTable( [ [\'A\'], [\'B\'], {text:\'C\',options:{align:\'center\'}} ] );\' (https://gitbrent.github.io/PptxGenJS/docs/api-tables.html)' ) } // TODO: FUTURE: This is wacky and wont function right (shows .w value when there is none from demo.js?!) 20191219 /* if (opt.w && opt.colW) { console.warn('addTable: please use either `colW` or `w` - not both (table will use `colW` and ignore `w`)') console.log(`${opt.w} ${opt.colW}`) } */ } // STEP 2: Transform `tableRows` into well-formatted TableCell's // tableRows can be object or plain text array: `[{text:'cell 1'}, {text:'cell 2', options:{color:'ff0000'}}]` | `["cell 1", "cell 2"]` const arrRows: TableCell[][] = [] tableRows.forEach(row => { const newRow: TableCell[] = [] if (Array.isArray(row)) { row.forEach((cell: number | string | TableCell) => { // A: const newCell: TableCell = { _type: SLIDE_OBJECT_TYPES.tablecell, text: '', options: typeof cell === 'object' && cell.options ? cell.options : {}, } // B: if (typeof cell === 'string' || typeof cell === 'number') newCell.text = cell.toString() else if (cell.text) { // Cell can contain complex text type, or string, or number if (typeof cell.text === 'string' || typeof cell.text === 'number') newCell.text = cell.text.toString() else if (cell.text) newCell.text = cell.text // Capture options if (cell.options && typeof cell.options === 'object') newCell.options = cell.options } // C: Set cell borders newCell.options.border = newCell.options.border || opt.border || [{ type: 'none' }, { type: 'none' }, { type: 'none' }, { type: 'none' }] const cellBorder = newCell.options.border // CASE 1: border interface is: BorderOptions | [BorderOptions, BorderOptions, BorderOptions, BorderOptions] if (!Array.isArray(cellBorder) && typeof cellBorder === 'object') newCell.options.border = [cellBorder, cellBorder, cellBorder, cellBorder] // Handle: [null, null, {type:'solid'}, null] if (!newCell.options.border[0]) newCell.options.border[0] = { type: 'none' } if (!newCell.options.border[1]) newCell.options.border[1] = { type: 'none' } if (!newCell.options.border[2]) newCell.options.border[2] = { type: 'none' } if (!newCell.options.border[3]) newCell.options.border[3] = { type: 'none' } // set complete BorderOptions for all sides const arrSides = [0, 1, 2, 3] arrSides.forEach(idx => { newCell.options.border[idx] = { type: newCell.options.border[idx].type || DEF_CELL_BORDER.type, color: newCell.options.border[idx].color || DEF_CELL_BORDER.color, pt: typeof newCell.options.border[idx].pt === 'number' ? newCell.options.border[idx].pt : DEF_CELL_BORDER.pt, } }) // LAST: newRow.push(newCell) }) } else { console.log('addTable: tableRows has a bad row. A row should be an array of cells. You provided:') console.log(row) } arrRows.push(newRow) }) // STEP 3: Set options opt.x = getSmartParseNumber(opt.x || (opt.x === 0 ? 0 : EMU / 2), 'X', presLayout) opt.y = getSmartParseNumber(opt.y || (opt.y === 0 ? 0 : EMU / 2), 'Y', presLayout) if (opt.h) opt.h = getSmartParseNumber(opt.h, 'Y', presLayout) // NOTE: Dont set default `h` - leaving it null triggers auto-rowH in `makeXMLSlide()` opt.fontSize = opt.fontSize || DEF_FONT_SIZE opt.margin = opt.margin === 0 || opt.margin ? opt.margin : DEF_CELL_MARGIN_IN if (typeof opt.margin === 'number') opt.margin = [Number(opt.margin), Number(opt.margin), Number(opt.margin), Number(opt.margin)] // NOTE: dont add default color on tables with hyperlinks! (it causes any textObj's with hyperlinks to have subsequent words to be black) if (JSON.stringify({ arrRows: arrRows }).indexOf('hyperlink') === -1) { if (!opt.color) opt.color = opt.color || DEF_FONT_COLOR // Set default color if needed (table option > inherit from Slide > default to black) } if (typeof opt.border === 'string') { console.warn('addTable `border` option must be an object. Ex: `{border: {type:\'none\'}}`') opt.border = null } else if (Array.isArray(opt.border)) { [0, 1, 2, 3].forEach(idx => { opt.border[idx] = opt.border[idx] ? { type: opt.border[idx].type || DEF_CELL_BORDER.type, color: opt.border[idx].color || DEF_CELL_BORDER.color, pt: opt.border[idx].pt || DEF_CELL_BORDER.pt } : { type: 'none' } }) } opt.autoPage = typeof opt.autoPage === 'boolean' ? opt.autoPage : false opt.autoPageRepeatHeader = typeof opt.autoPageRepeatHeader === 'boolean' ? opt.autoPageRepeatHeader : false opt.autoPageHeaderRows = typeof opt.autoPageHeaderRows !== 'undefined' && !isNaN(Number(opt.autoPageHeaderRows)) ? Number(opt.autoPageHeaderRows) : 1 opt.autoPageLineWeight = typeof opt.autoPageLineWeight !== 'undefined' && !isNaN(Number(opt.autoPageLineWeight)) ? Number(opt.autoPageLineWeight) : 0 if (opt.autoPageLineWeight) { if (opt.autoPageLineWeight > 1) opt.autoPageLineWeight = 1 else if (opt.autoPageLineWeight < -1) opt.autoPageLineWeight = -1 } // autoPage ^^^ // Set/Calc table width // Get slide margins - start with default values, then adjust if master or slide margins exist let arrTableMargin = DEF_SLIDE_MARGIN_IN // Case 1: Master margins if (slideLayout && typeof slideLayout._margin !== 'undefined') { if (Array.isArray(slideLayout._margin)) arrTableMargin = slideLayout._margin else if (!isNaN(Number(slideLayout._margin))) { arrTableMargin = [Number(slideLayout._margin), Number(slideLayout._margin), Number(slideLayout._margin), Number(slideLayout._margin)] } } // Case 2: Table margins /* FIXME: add `_margin` option to slide options else if ( addNewSlide._margin ) { if ( Array.isArray(addNewSlide._margin) ) arrTableMargin = addNewSlide._margin; else if ( !isNaN(Number(addNewSlide._margin)) ) arrTableMargin = [Number(addNewSlide._margin), Number(addNewSlide._margin), Number(addNewSlide._margin), Number(addNewSlide._margin)]; } */ /** * Calc table width depending upon what data we have - several scenarios exist (including bad data, eg: colW doesnt match col count) * The API does not require a `w` value, but XML generation does, hence, code to calc a width below using colW value(s) */ if (opt.colW) { const firstRowColCnt = arrRows[0].reduce((totalLen, c) => { if (c?.options?.colspan && typeof c.options.colspan === 'number') { totalLen += c.options.colspan } else { totalLen += 1 } return totalLen }, 0) if (typeof opt.colW === 'string' || typeof opt.colW === 'number') { // Ex: `colW = 3` or `colW = '3'` opt.w = Math.floor(Number(opt.colW) * firstRowColCnt) opt.colW = null // IMPORTANT: Unset `colW` so table is created using `opt.w`, which will evenly divide cols } else if (opt.colW && Array.isArray(opt.colW) && opt.colW.length === 1 && firstRowColCnt > 1) { // Ex: `colW=[3]` but with >1 cols (same as above, user is saying "use this width for all") opt.w = Math.floor(Number(opt.colW) * firstRowColCnt) opt.colW = null // IMPORTANT: Unset `colW` so table is created using `opt.w`, which will evenly divide cols } else if (opt.colW && Array.isArray(opt.colW) && opt.colW.length !== firstRowColCnt) { // Err: Mismatched colW and cols count console.warn('addTable: mismatch: (colW.length != data.length) Therefore, defaulting to evenly distributed col widths.') opt.colW = null } } else if (opt.w) { opt.w = getSmartParseNumber(opt.w, 'X', presLayout) } else { opt.w = Math.floor(presLayout._sizeW / EMU - arrTableMargin[1] - arrTableMargin[3]) } // STEP 4: Convert units to EMU now (we use different logic in makeSlide->table - smartCalc is not used) if (opt.x && opt.x < 20) opt.x = inch2Emu(opt.x) if (opt.y && opt.y < 20) opt.y = inch2Emu(opt.y) if (opt.w && typeof opt.w === 'number' && opt.w < 20) opt.w = inch2Emu(opt.w) if (opt.h && typeof opt.h === 'number' && opt.h < 20) opt.h = inch2Emu(opt.h) // STEP 5: Loop over cells: transform each to ITableCell; check to see whether to unset `autoPage` while here arrRows.forEach(row => { row.forEach((cell, idy) => { // A: Transform cell data if needed /* Table rows can be an object or plain text - transform into object when needed // EX: var arrTabRows1 = [ [ { text:'A1\nA2', options:{rowspan:2, fill:'99FFCC'} } ] ,[ 'B2', 'C2', 'D2', 'E2' ] ] */ if (typeof cell === 'number' || typeof cell === 'string') { // Grab table formatting `opts` to use here so text style/format inherits as it should row[idy] = { _type: SLIDE_OBJECT_TYPES.tablecell, text: String(row[idy]), options: opt } } else if (typeof cell === 'object') { // ARG0: `text` if (typeof cell.text === 'number') row[idy].text = row[idy].text.toString() else if (typeof cell.text === 'undefined' || cell.text === null) row[idy].text = '' // ARG1: `options`: ensure options exists row[idy].options = cell.options || {} // Set type to tabelcell row[idy]._type = SLIDE_OBJECT_TYPES.tablecell } // B: Check for fine-grained formatting, disable auto-page when found // Since genXmlTextBody already checks for text array ( text:[{},..{}] ) we're done! // Text in individual cells will be formatted as they are added by calls to genXmlTextBody within table builder // if (cell.text && Array.isArray(cell.text)) opt.autoPage = false // TODO: FIXME: WIP: 20210807: We cant do this anymore }) }) // If autoPage = true, we need to return references to newly created slides if any const newAutoPagedSlides: PresSlide[] = [] // STEP 6: Auto-Paging: (via {options} and used internally) // (used internally by `tableToSlides()` to not engage recursion - we've already paged the table data, just add this one) if (opt && !opt.autoPage) { // Create hyperlink rels (IMPORTANT: Wait until table has been shredded across Slides or all rels will end-up on Slide 1!) createHyperlinkRels(target, arrRows) // Add slideObjects (NOTE: Use `extend` to avoid mutation) target._slideObjects.push({ _type: SLIDE_OBJECT_TYPES.table, arrTabRows: arrRows, options: Object.assign({}, opt), }) } else { if (opt.autoPageRepeatHeader) opt._arrObjTabHeadRows = arrRows.filter((_row, idx) => idx < opt.autoPageHeaderRows) // Loop over rows and create 1-N tables as needed (ISSUE#21) getSlidesForTableRows(arrRows, opt, presLayout, slideLayout).forEach((slide, idx) => { // A: Create new Slide when needed, otherwise, use existing (NOTE: More than 1 table can be on a Slide, so we will go up AND down the Slide chain) if (!getSlide(target._slideNum + idx)) slides.push(addSlide({ masterName: slideLayout?._name || null })) // B: Reset opt.y to `option`/`margin` after first Slide (ISSUE#43, ISSUE#47, ISSUE#48) if (idx > 0) opt.y = inch2Emu(opt.autoPageSlideStartY || opt.newSlideStartY || arrTableMargin[0]) // C: Add this table to new Slide { const newSlide: PresSlide = getSlide(target._slideNum + idx) opt.autoPage = false // Create hyperlink rels (IMPORTANT: Wait until table has been shredded across Slides or all rels will end-up on Slide 1!) createHyperlinkRels(newSlide, slide.rows) // Add rows to new slide newSlide.addTable(slide.rows, Object.assign({}, opt)) // Add reference to the new slide so it can be returned, but don't add the first one because the user already has a reference to that one. if (idx > 0) newAutoPagedSlides.push(newSlide) } }) } return newAutoPagedSlides } /** * Adds a text object to a slide definition. * @param {PresSlide} target - slide object that the text should be added to * @param {string|TextProps[]} text text string or object * @param {TextPropsOptions} opts text options * @param {boolean} isPlaceholder whether this a placeholder object * @since: 1.0.0 */ export function addTextDefinition(target: PresSlide, text: TextProps[], opts: TextPropsOptions, isPlaceholder: boolean): void { const newObject: ISlideObject = { _type: isPlaceholder ? SLIDE_OBJECT_TYPES.placeholder : SLIDE_OBJECT_TYPES.text, shape: (opts?.shape) || SHAPE_TYPE.RECTANGLE, text: !text || text.length === 0 ? [{ text: '', options: null }] : text, options: opts || {}, } function cleanOpts(itemOpts: ObjectOptions): TextPropsOptions { // STEP 1: Set some options { // A.1: Color (placeholders should inherit their colors or override them, so don't default them) if (!itemOpts.placeholder) { itemOpts.color = itemOpts.color || newObject.options.color || target.color || DEF_FONT_COLOR } // A.2: Placeholder should inherit their bullets or override them, so don't default them if (itemOpts.placeholder || isPlaceholder) { itemOpts.bullet = itemOpts.bullet || false } // A.3: Text targeting a placeholder need to inherit the placeholders options (eg: margin, valign, etc.) (Issue #640) if (itemOpts.placeholder && target._slideLayout && target._slideLayout._slideObjects) { const placeHold = target._slideLayout._slideObjects.filter( item => item._type === 'placeholder' && item.options && item.options.placeholder && item.options.placeholder === itemOpts.placeholder )[0] if (placeHold?.options) itemOpts = { ...itemOpts, ...placeHold.options } } // A.4: Other options itemOpts.objectName = itemOpts.objectName ? encodeXmlEntities(itemOpts.objectName) : `Text ${target._slideObjects.filter(obj => obj._type === SLIDE_OBJECT_TYPES.text).length}` // B: if (itemOpts.shape === SHAPE_TYPE.LINE) { // ShapeLineProps defaults const newLineOpts: ShapeLineProps = { type: itemOpts.line.type || 'solid', color: itemOpts.line.color || DEF_SHAPE_LINE_COLOR, transparency: itemOpts.line.transparency || 0, width: itemOpts.line.width || 1, dashType: itemOpts.line.dashType || 'solid', beginArrowType: itemOpts.line.beginArrowType || null, endArrowType: itemOpts.line.endArrowType || null, } if (typeof itemOpts.line === 'object') itemOpts.line = newLineOpts // 3: Handle line (lots of deprecated opts) if (typeof itemOpts.line === 'string') { const tmpOpts = newLineOpts if (typeof itemOpts.line === 'string') tmpOpts.color = itemOpts.line // @deprecated [remove in v4.0] // tmpOpts.color = itemOpts.line!.toString() // @deprecated `itemOpts.line`:[string] (was line color) itemOpts.line = tmpOpts } if (typeof itemOpts.lineSize === 'number') itemOpts.line.width = itemOpts.lineSize // @deprecated (part of `ShapeLineProps` now) if (typeof itemOpts.lineDash === 'string') itemOpts.line.dashType = itemOpts.lineDash // @deprecated (part of `ShapeLineProps` now) if (typeof itemOpts.lineHead === 'string') itemOpts.line.beginArrowType = itemOpts.lineHead // @deprecated (part of `ShapeLineProps` now) if (typeof itemOpts.lineTail === 'string') itemOpts.line.endArrowType = itemOpts.lineTail // @deprecated (part of `ShapeLineProps` now) } // C: Line opts itemOpts.line = itemOpts.line || {} itemOpts.lineSpacing = itemOpts.lineSpacing && !isNaN(itemOpts.lineSpacing) ? itemOpts.lineSpacing : null itemOpts.lineSpacingMultiple = itemOpts.lineSpacingMultiple && !isNaN(itemOpts.lineSpacingMultiple) ? itemOpts.lineSpacingMultiple : null // D: Transform text options to bodyProperties as thats how we build XML itemOpts._bodyProp = itemOpts._bodyProp || {} itemOpts._bodyProp.autoFit = itemOpts.autoFit || false // DEPRECATED: (3.3.0) If true, shape will collapse to text size (Fit To shape) itemOpts._bodyProp.anchor = !itemOpts.placeholder ? TEXT_VALIGN.ctr : null // VALS: [t,ctr,b] itemOpts._bodyProp.vert = itemOpts.vert || null // VALS: [eaVert,horz,mongolianVert,vert,vert270,wordArtVert,wordArtVertRtl] itemOpts._bodyProp.wrap = typeof itemOpts.wrap === 'boolean' ? itemOpts.wrap : true // E: Inset // @deprecated 3.10.0 (`inset` - use `margin`) if ((itemOpts.inset && !isNaN(Number(itemOpts.inset))) || itemOpts.inset === 0) { itemOpts._bodyProp.lIns = inch2Emu(itemOpts.inset) itemOpts._bodyProp.rIns = inch2Emu(itemOpts.inset) itemOpts._bodyProp.tIns = inch2Emu(itemOpts.inset) itemOpts._bodyProp.bIns = inch2Emu(itemOpts.inset) } // F: Transform @deprecated props if (typeof itemOpts.underline === 'boolean' && itemOpts.underline === true) itemOpts.underline = { style: 'sng' } } // STEP 2: Transform `align`/`valign` to XML values, store in _bodyProp for XML gen { if ((itemOpts.align || '').toLowerCase().indexOf('c') === 0) itemOpts._bodyProp.align = TEXT_HALIGN.center else if ((itemOpts.align || '').toLowerCase().indexOf('l') === 0) itemOpts._bodyProp.align = TEXT_HALIGN.left else if ((itemOpts.align || '').toLowerCase().indexOf('r') === 0) itemOpts._bodyProp.align = TEXT_HALIGN.right else if ((itemOpts.align || '').toLowerCase().indexOf('j') === 0) itemOpts._bodyProp.align = TEXT_HALIGN.justify if ((itemOpts.valign || '').toLowerCase().indexOf('b') === 0) itemOpts._bodyProp.anchor = TEXT_VALIGN.b else if ((itemOpts.valign || '').toLowerCase().indexOf('m') === 0) itemOpts._bodyProp.anchor = TEXT_VALIGN.ctr else if ((itemOpts.valign || '').toLowerCase().indexOf('t') === 0) itemOpts._bodyProp.anchor = TEXT_VALIGN.t } // STEP 3: ROBUST: Set rational values for some shadow props if needed correctShadowOptions(itemOpts.shadow) return itemOpts } // STEP 1: Create/Clean object options newObject.options = cleanOpts(newObject.options) // STEP 2: Create/Clean text options newObject.text.forEach(item => (item.options = cleanOpts(item.options || {}))) // STEP 3: Create hyperlinks createHyperlinkRels(target, newObject.text || '') // LAST: Add object to Slide target._slideObjects.push(newObject) } /** * Adds placeholder objects to slide * @param {PresSlide} slide - slide object containing layouts */ export function addPlaceholdersToSlideLayouts(slide: PresSlide): void { // Add all placeholders on this Slide that dont already exist (slide._slideLayout._slideObjects || []).forEach(slideLayoutObj => { if (slideLayoutObj._type === SLIDE_OBJECT_TYPES.placeholder) { // A: Search for this placeholder on Slide before we add // NOTE: Check to ensure a placeholder does not already exist on the Slide // They are created when they have been populated with text (ex: `slide.addText('Hi', { placeholder:'title' });`) if (slide._slideObjects.filter(slideObj => slideObj.options && slideObj.options.placeholder === slideLayoutObj.options.placeholder).length === 0) { addTextDefinition(slide, [{ text: '' }], slideLayoutObj.options, false) } } }) } /* -------------------------------------------------------------------------------- */ /** * Adds a background image or color to a slide definition. * @param {BackgroundProps} props - color string or an object with image definition * @param {PresSlide} target - slide object that the background is set to */ export function addBackgroundDefinition(props: BackgroundProps, target: SlideLayout): void { // A: @deprecated if (target.bkgd) { if (!target.background) target.background = {} if (typeof target.bkgd === 'string') target.background.color = target.bkgd else { if (target.bkgd.data) target.background.data = target.bkgd.data if (target.bkgd.path) target.background.path = target.bkgd.path if (target.bkgd.src) target.background.path = target.bkgd.src // @deprecated (drop in 4.x) } } if (target.background?.fill) target.background.color = target.background.fill // B: Handle media if (props && (props.path || props.data)) { // Allow the use of only the data key (`path` isnt reqd) props.path = props.path || 'preencoded.png' let strImgExtn = (props.path.split('.').pop() || 'png').split('?')[0] // Handle "blah.jpg?width=540" etc. if (strImgExtn === 'jpg') strImgExtn = 'jpeg' // base64-encoded jpg's come out as "data:image/jpeg;base64,/9j/[...]", so correct exttnesion to avoid content warnings at PPT startup target._relsMedia = target._relsMedia || [] const intRels = target._relsMedia.length + 1 // NOTE: `Target` cannot have spaces (eg:"Slide 1-image-1.jpg") or a "presentation is corrupt" warning comes up target._relsMedia.push({ path: props.path, type: SLIDE_OBJECT_TYPES.image, extn: strImgExtn, data: props.data || null, rId: intRels, Target: `../media/${(target._name || '').replace(/\s+/gi, '-')}-image-${target._relsMedia.length + 1}.${strImgExtn}`, }) target._bkgdImgRid = intRels } } /** * Parses text/text-objects from `addText()` and `addTable()` methods; creates 'hyperlink'-type Slide Rels for each hyperlink found * @param {PresSlide} target - slide object that any hyperlinks will be be added to * @param {number | string | TextProps | TextProps[] | ITableCell[][]} text - text to parse */ function createHyperlinkRels( target: PresSlide, text: number | string | ISlideObject | TextProps | TextProps[] | TableCell[][], options?: TextPropsOptions[], ): void { let textObjs = [] // Only text objects can have hyperlinks, bail when text param is plain text if (typeof text === 'string' || typeof text === 'number') return // IMPORTANT: "else if" Array.isArray must come before typeof===object! Otherwise, code will exhaust recursion! else if (Array.isArray(text)) textObjs = text else if (typeof text === 'object') textObjs = [text] textObjs.forEach((text: TextProps, idx: number) => { // IMPORTANT: `options` are lost due to recursion/copy! if (options && options[idx] && options[idx].hyperlink) text.options = { ...text.options, ...options[idx] } // NOTE: `text` can be an array of other `text` objects (table cell word-level formatting), continue parsing using recursion if (Array.isArray(text)) { const cellOpts = [] text.forEach((tablecell) => { if (tablecell.options && !tablecell.text.options) { cellOpts.push(tablecell.options) } }) createHyperlinkRels(target, text, cellOpts) } else if (Array.isArray(text.text)) { createHyperlinkRels(target, text.text, options && options[idx] ? [options[idx]] : undefined) } else if (text && typeof text === 'object' && text.options && text.options.hyperlink && !text.options.hyperlink._rId) { if (typeof text.options.hyperlink !== 'object') { console.log('ERROR: text `hyperlink` option should be an object. Ex: `hyperlink: {url:\'https://github.com\'}` ') } else if (!text.options.hyperlink.url && !text.options.hyperlink.slide) { console.log('ERROR: \'hyperlink requires either: `url` or `slide`\'') } else { const relId = getNewRelId(target) target._rels.push({ type: SLIDE_OBJECT_TYPES.hyperlink, data: text.options.hyperlink.slide ? 'slide' : 'dummy', rId: relId, Target: encodeXmlEntities(text.options.hyperlink.url) || text.options.hyperlink.slide.toString(), }) text.options.hyperlink._rId = relId } } else if (text && typeof text === 'object' && text.options && text.options.hyperlink && text.options.hyperlink._rId) { // NOTE: auto-paging will create new slides, but skip above as _rId exists, BUT this is a new slide, so add rels! if (target._rels.filter(rel => rel.rId === text.options.hyperlink._rId).length === 0) { target._rels.push({ type: SLIDE_OBJECT_TYPES.hyperlink, data: text.options.hyperlink.slide ? 'slide' : 'dummy', rId: text.options.hyperlink._rId, Target: encodeXmlEntities(text.options.hyperlink.url) || text.options.hyperlink.slide.toString(), }) } } }) }