UNPKG

html-to-pdfmake

Version:
1,124 lines (1,054 loc) 51.8 kB
// source: https://github.com/OpenSlides/OpenSlides/blob/f4f8b8422f9b3fbab58e35ac3f8f870d35813b7d/client/src/app/core/ui-services/html-to-pdf.service.ts // and https://github.com/bpampuch/pdfmake/issues/205 /** To use it: import htmlToPdfMake from 'html-to-pdfmake.js' htmlToPdfMake('<b>my bold text</b>'); */ /** * Transform HTML code to a PdfMake object * @param {String} htmlText The HTML code to transform * @param {Object} [options] * @param {Object} [defaultStyles] An object with the default styles for each elements * @param {Boolean} [tableAutoSize=false] It permits to use the width/height defined in styles for a table's cells and rows * @param {Boolean} [imagesByReference=false] It permits to return two objets ({content, images}) to handle the `<img>` tags by reference * @param {Boolean} [removeExtraBlanks=false] Some blank spaces in your code may cause extra blank lines in the PDF – use this option to remove them * @param {Boolean} [showHidden=false] TRUE if the 'display:none' elements should be displayed * @param {Boolean} [removeTagClasses=false] TRUE if we don't want to have 'html-TAG' added as a class for each node * @param {Array} [ignoreStyles=[]] An array of style property to ignore * @param {Function} [customTag] It permits to handle non-regular HTML tag * @param {Object} [window] The `window` object (required for NodeJS server side use) * @return {Object} it returns a PdfMake object * * @example * // Some styles are applied by defaults for the supported HTML elements * // but you can pass your own styles if you prefer * htmlToPdfMake('<div><h1>My Title</h1><p>My paragraph</p></div>'); * * // If you want to overwrite the default styles, e.g. you want <li> to not have a margin-left, and links to be 'purple' and not 'blue', and links without 'underline' * htmlToPdfMake('<ul><li>this is <a href="...">a link</a></li><li>another item</li></ul>', { * defaultStyles:{ * a:{ * color:'purple', * decoration:null * }, * li:null * } * }); */ //var util = require("util"); // to debug function htmlToPdfMake(htmlText, options) { 'use strict'; this.wndw = (options && options.window ? options.window : window); this.tableAutoSize = (options && typeof options.tableAutoSize === "boolean" ? options.tableAutoSize : false); this.imagesByReference = (options && typeof options.imagesByReference === "boolean" ? options.imagesByReference : false); this.removeExtraBlanks = (options && typeof options.removeExtraBlanks === "boolean" ? options.removeExtraBlanks : false); this.showHidden = (options && typeof options.showHidden === "boolean" ? options.showHidden : false); this.removeTagClasses = (options && typeof options.removeTagClasses === "boolean" ? options.removeTagClasses : false); this.ignoreStyles = (options && Array.isArray(options.ignoreStyles) ? options.ignoreStyles : []); // A random string to be used in the image references var imagesByReferenceSuffix = (Math.random().toString(36).slice(2,8)); // Used with the size attribute on the font elements to calculate relative font size this.fontSizes = (options && Array.isArray(options.fontSizes) ? options.fontSizes : [10, 14, 16, 18, 20, 24, 28]); // set default styles this.defaultStyles = { b: {bold:true}, strong: {bold:true}, u: {decoration:'underline'}, del: {decoration:'lineThrough'}, s: {decoration: 'lineThrough'}, em: {italics:true}, i: {italics:true}, h1: {fontSize:24, bold:true, marginBottom:5}, h2: {fontSize:22, bold:true, marginBottom:5}, h3: {fontSize:20, bold:true, marginBottom:5}, h4: {fontSize:18, bold:true, marginBottom:5}, h5: {fontSize:16, bold:true, marginBottom:5}, h6: {fontSize:14, bold:true, marginBottom:5}, a: {color:'blue', decoration:'underline'}, strike: {decoration: 'lineThrough'}, p: {margin:[0, 5, 0, 10]}, ul: {marginBottom:5,marginLeft:5}, table: {marginBottom:5}, th: {bold:true, fillColor:'#EEEEEE'} } // store the references to the images this.imagesRef = []; /** * Permit to change the default styles based on the options */ this.changeDefaultStyles = function () { for (var keyStyle in options.defaultStyles) { if (this.defaultStyles.hasOwnProperty(keyStyle)) { // if we want to remove a default style if (options.defaultStyles.hasOwnProperty(keyStyle) && !options.defaultStyles[keyStyle]) { delete this.defaultStyles[keyStyle]; } else { for (var k in options.defaultStyles[keyStyle]) { // if we want to delete a specific property if (options.defaultStyles[keyStyle][k] === '') delete this.defaultStyles[keyStyle][k]; else this.defaultStyles[keyStyle][k] = options.defaultStyles[keyStyle][k]; } } } else { // if we add default styles this.defaultStyles[keyStyle] = {} for (var ks in options.defaultStyles[keyStyle]) { this.defaultStyles[keyStyle][ks] = options.defaultStyles[keyStyle][ks]; } } } } if (options && options.defaultStyles) { this.changeDefaultStyles(); } /** * Takes an HTML string, converts to HTML using a DOM parser and recursivly parses * the content into pdfmake compatible doc definition * * @param htmlText the html text to translate as string * @returns pdfmake doc definition as object */ this.convertHtml = function(htmlText) { // Create a HTML DOM tree out of html string var parser = new this.wndw.DOMParser(); if (this.removeExtraBlanks) htmlText = htmlText.replace(/(<\/?(div|p|h1|h2|h3|h4|h5|h6|ol|ul|li)([^>]+)?>)\s+(<\/?(div|p|h1|h2|h3|h4|h5|h6|ol|ul|li))/gi, "$1$4").replace(/(<\/?(div|p|h1|h2|h3|h4|h5|h6|ol|ul|li)([^>]+)?>)\s+(<\/?(div|p|h1|h2|h3|h4|h5|h6|ol|ul|li))/gi, "$1$4").replace(/(<td([^>]+)?>)\s+(<table)/gi, "$1$3").replace(/(<\/table>)\s+(<\/td>)/gi, "$1$2"); var parsedHtml = parser.parseFromString(htmlText, 'text/html'); var docDef = this.parseElement(parsedHtml.body, []); // remove first level return docDef.stack || docDef.text; } /** * Converts a single HTML element to pdfmake, calls itself recursively for child html elements * * @param element can be an HTML element (<p>) or plain text ("Hello World") * @param parentNode the parent node for the current element * @param parents Array of node names of all the parents for the element * @returns the doc def to the given element in consideration to the given paragraph and styles */ this.parseElement = function(element, parents) { var nodeName = element.nodeName.toUpperCase(); var nodeNameLowerCase = nodeName.toLowerCase(); var ret = {text:[]}; var text, needStack=false; var dataset, i, key, _this=this; // ignore some HTML tags if (['COLGROUP','COL'].indexOf(nodeName) > -1) return ''; switch(element.nodeType) { case 3: { // TEXT_NODE if (element.textContent) { text = element.textContent; // check if we have 'white-space' in the parent's style // or if a parent is a <PRE> var styleParentTextNode = this.parseStyle(parents[parents.length-1], true); var hasWhiteSpace = (parents.findIndex(function(p) { return p.nodeName === "PRE" })>-1); for (i=0; i<styleParentTextNode.length; i++) { if (styleParentTextNode[i].key === "preserveLeadingSpaces") { hasWhiteSpace=styleParentTextNode[i].value; break; } } // if no 'white-space' style, then deal with white spaces if (!hasWhiteSpace) text = text.replace(/\s*\n\s*/g, " "); if (options && typeof options.replaceText === "function") text = options.replaceText(text, parents); // for table, thead, tbody, tfoot, tr, ul, ol: remove all empty space if (['TABLE','THEAD','TBODY','TFOOT','TR','UL','OL'].indexOf(parents[parents.length-1].nodeName) > -1) text = text.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, ''); if (text) { ret = {'text':text}; ret = this.applyStyle({ret:ret, parents:parents}); return ret; } } return ''; } case 1: { // ELEMENT_NODE if (!this.showHidden && (element.style.display && element.style.display === 'none') || (element.style.visibility && element.style.visibility === 'hidden')) { return; } ret.nodeName = nodeName; if (element.id) ret.id = element.id; parents.push(element); if (element.childNodes && element.childNodes.length>0) { [].forEach.call(element.childNodes, function(child) { var res = _this.parseElement(child, parents); if (res) { if (Array.isArray(res.text) && res.text.length===0) res.text=''; ret.text.push(res); } }); //console.log(nodeName,'=>',util.inspect(ret.text, {showHidden: false, depth: null})); // to debug // find if we need a 'stack' instead of a 'text' needStack = this.searchForStack(ret); if (needStack) { ret.stack = ret.text.slice(0); delete ret.text; } else { // apply all the inhirent classes and styles from the parents ret = this.applyStyle({ret:ret, parents:parents}); } } parents.pop(); switch(nodeName) { case "TABLE":{ var rowIndex; // the format for the table is table.body[[], [], …] ret.table = {body:[]}; var tbodies = (ret.stack || ret.text); if (Array.isArray(tbodies)) { rowIndex = 0; // Array with All Rows including THEAD var hasRowSpan = false; // TRUE if we have some rowspan // first round is to deal with colspan // for each THEAD / TBODY tbodies.forEach(function(tbody) { // for each row var rows = (tbody.stack || tbody.text); if (Array.isArray(rows)) { rows.forEach(function(row) { var cells = (row.stack || row.text); // for each cell if (Array.isArray(cells)) { ret.table.body[rowIndex] = []; cells.forEach(function(cell) { ret.table.body[rowIndex].push(cell); // do we have a colSpan? // if yes, insert empty cells due to colspan if (cell.colSpan>1) { i = cell.colSpan; // insert an empty cell for each colspan while (--i > 0) { ret.table.body[rowIndex].push({text:''}); } } // do we have a rowSpan ? if (cell.rowSpan > 1) hasRowSpan=true; }); rowIndex++; } }); } }); if (hasRowSpan) { var header = ret.table.body[0]; if (Array.isArray(header)) { // determine the number of columns var columnsCount = header.length; // determine the number of rows var rowsCount = ret.table.body.length; // for each column for (var columnInd=0; columnInd<columnsCount; columnInd++) { for (var rowInd=0; rowInd<rowsCount; rowInd++) { var row = ret.table.body[rowInd]; if (Array.isArray(row)) { var cell = row[columnInd]; // do we have a rowSpan? if (cell.rowSpan>1) { var len = cell.rowSpan; var cs, colspan = (cell.colSpan ? cell.colSpan : 1); for (var j=1; j<=len-1; j++) { cs = colspan; if (ret.table.body[rowInd+j]) { while (cs--) ret.table.body[rowInd+j].splice(columnInd, 0, {text:''}); } else { // if we have an empty <tr></tr> cell.rowSpan--; } } // increase rowInd to skip processed rows rowInd += (len-1); } } } } } } } delete ret.stack; delete ret.text; // apply all the inhirent classes and styles from the parents, or for the current element ret = this.applyStyle({ret:ret, parents:parents.concat([element])}); // if option tableAutoSize, then we try to apply the correct width/height on the table if (this.tableAutoSize) { var cellsWidths = []; var cellsHeights = []; var tableWidths = []; var tableHeights = []; // determine if we have "width:100%" on the TABLE var fullWidth = (element.getAttribute("width") === "100%" || element.style.width === "100%"); // remove everything from percent string except numbers and dot var elementAttrWidth = element.getAttribute( "width" )||""; // check if table have width defined var tableHaveWidth = (element.style.width||elementAttrWidth).endsWith("%"); if ( tableHaveWidth ) { // get only numbers of percentage var tableWidth = (element.style.width||elementAttrWidth).replace(/[^0-9.]/g, "" ); } var tableHaveColgroup = false; var tableColgroupIndex = -1; // check if any of table children is a colgroup with cells widths for ( var x = 0; x < element.children.length; x++ ) { var child = element.children[x]; if ( !tableHaveColgroup ) tableColgroupIndex++; if ( child.nodeName.toUpperCase() === "COLGROUP" ) tableHaveColgroup = true; }; ret.table.body.forEach(function(row, rowIndex) { cellsWidths.push([]); cellsHeights.push([]); row.forEach(function(cell, cellIndex) { // we want to remember the different sizes var width = typeof cell.width !== 'undefined' ? cell.width : 'auto'; if (width === '*') width='auto'; // tinymce could generate 'width:*', but it's invalid, so we use 'auto' instead var height = typeof cell.height !== 'undefined' ? cell.height : 'auto'; if (height === '*') height='auto'; // tinymce could generate 'height:*', but it's invalid, so we use 'auto' instead // check if we have colspan or rowspan // if yes, and if width/height is a number, we divide by the col/rowspan, otherwise we use 'auto' if (width !== 'auto' && cell.colSpan>1) { if (!isNaN(width)) width /= cell.colSpan; else width = 'auto'; } if (height !== 'auto' && cell.rowSpan>1) { if (!isNaN(height)) height /= cell.rowSpan; else height = 'auto'; } // if we have colgroups defining cells widths if ( tableHaveColgroup ) { var colGroups = element.children[ tableColgroupIndex ]; // get colgroup by cell index var colElement = colGroups.children[ cellIndex ]; if (colElement) { var colAttrWidth = colElement.getAttribute( "width" ) || ""; var colStyleWidth = colElement.style.width; if ((colAttrWidth||colStyleWidth).endsWith("%")) { // update cell width to its percentage in colgroup width = (colAttrWidth||colStyleWidth); } } } cellsWidths[rowIndex].push(width); cellsHeights[rowIndex].push(height); }); }); // determine the max width for each cell cellsWidths.forEach(function(row) { row.forEach(function(cellWidth, cellIndex) { var type = typeof tableWidths[cellIndex]; if (type === "undefined" || (cellWidth !== 'auto' && type === "number" && cellWidth > tableWidths[cellIndex]) || (cellWidth !== 'auto' && tableWidths[cellIndex] === 'auto')) { if ( tableHaveWidth ) { // if table have defined widths we need to make a // rule of three to get cell's proportional width var cellPercentage = cellWidth === 'auto' ? tableWidth / row.length : ( cellWidth.toString().replace( '%', "" ) * tableWidth ) / 100; cellWidth = String(cellPercentage) + "%"; } tableWidths[cellIndex] = cellWidth; } }); }); // determine the max height for each row cellsHeights.forEach(function(row, rowIndex) { row.forEach(function(cellHeight) { var type = typeof tableHeights[rowIndex]; if (type === "undefined" || (cellHeight !== 'auto' && type === "number" && cellHeight > tableHeights[rowIndex]) || (cellHeight !== 'auto' && tableHeights[rowIndex] === 'auto')) { tableHeights[rowIndex] = cellHeight; } }); }); if (tableWidths.length > 0) { // if all columns are in 'auto' and if we have 'width:"100%"' for the table // then put widths:['*', '*' …], for all columns //if (fullWidth && tableWidths.filter(function(w) { return w==='auto' }).length === tableWidths.length) tableWidths=tableWidths.map(function() { return '*' }); // see https://github.com/Aymkdn/html-to-pdfmake/issues/151#issuecomment-1273015585 // if we have 'width:"100%"' for the table, replace "auto" width to "*" if (fullWidth) tableWidths=tableWidths.map(function(w) { return w==='auto' ? '*' : w }); ret.table.widths = tableWidths; } if (tableHeights.length > 0) ret.table.heights = tableHeights; } // check if we have some data-pdfmake to apply if (element.dataset && element.dataset.pdfmake) { // handle when people will use simple quotes, e.g. <table data-pdfmake="{'layout':'noBorders'}"> dataset = element.dataset.pdfmake.replace(/'/g,'"'); try { dataset = JSON.parse(dataset); for (key in dataset) { if (key === "layout") { ret.layout = dataset[key]; } else { ret.table[key] = dataset[key]; } } } catch(e) { console.error(e); } } break; } case "TH": case "TD":{ if (element.getAttribute("rowspan")) ret.rowSpan = element.getAttribute("rowspan")*1; if (element.getAttribute("colspan")) ret.colSpan = element.getAttribute("colspan")*1; // apply all the inhirent classes and styles from the parents, or for the current element ret = this.applyStyle({ret:ret, parents:parents.concat([element])}); break; } case "SVG": { ret = { svg:element.outerHTML.replace(/\n(\s+)?/g, ""), nodeName:'SVG' } if (!this.removeTagClasses) ret.style=['html-svg']; break; } case "BR": { // for BR we return '\n' ret.text = [{text:'\n'}]; break; } case "SUB": case "SUP": { ret[nodeName.toLowerCase()] = { offset: '30%', fontSize: 8 }; break; } case "HR": { // default style for the HR var styleHR = { width: 514, type: "line", margin: [0, 12, 0, 12], thickness: 0.5, color: "#000000", left: 0 }; // we can override the default HR style with "data-pdfmake" if (element.dataset && element.dataset.pdfmake) { dataset = element.dataset.pdfmake.replace(/'/g, '"'); try { dataset = JSON.parse(dataset); for (key in dataset) { styleHR[key] = dataset[key]; } } catch (e) { console.error(e); } } ret.margin = styleHR.margin; ret.canvas = [ { type: styleHR.type, x1: styleHR.left, y1: 0, x2: styleHR.width, y2: 0, lineWidth: styleHR.thickness, lineColor: styleHR.color } ]; delete ret.text; break; } case "OL": case "UL": { ret[nodeNameLowerCase] = (ret.stack || ret.text).slice(0); delete ret.stack; delete ret.text; // apply all the inhirent classes and styles from the parents, or for the current element ret = this.applyStyle({ret:ret, parents:parents.concat([element])}); // check if we have `start` if (element.getAttribute("start")) { ret.start = element.getAttribute("start")*1; } // check if we have "type" switch (element.getAttribute("type")) { case 'A': ret.type = 'upper-alpha'; break; case 'a': ret.type = 'lower-alpha'; break; case 'I': ret.type = 'upper-roman'; break; case 'i': ret.type = 'lower-roman'; break; } // check if we have `list-style-type` or `list-style` if (ret.listStyle || ret.listStyleType) ret.type = ret.listStyle || ret.listStyleType; break; } case "LI": { // if it's a stack, then check if the last child has a "text" if (ret.stack && !ret.stack[ret.stack.length-1].text) { // if not, we restructure our node // by moving the non-stack stuff inside a "text" text = ret.stack.slice(0, -1); // make sure we only have 'text' as a child // otherwise we switch to a stack ret = [ (Array.isArray(text) && text.filter(function(child) { return !child.text; }).length > 0 ? { "stack": text } : { "text": text }), ret.stack[ret.stack.length-1] ]; } // we don't want a child of UL/OL to be an array, but it should be a "stack" if (Array.isArray(ret)) { ret = {stack:ret}; } break; } case "PRE":{ ret.preserveLeadingSpaces = true; break; } case "IMG": { if (this.imagesByReference) { var src = element.getAttribute("data-src") || element.getAttribute("src"); var index = this.imagesRef.indexOf(src); if (index>-1) ret.image = 'img_ref_'+imagesByReferenceSuffix+index; else { ret.image = 'img_ref_'+imagesByReferenceSuffix+this.imagesRef.length; this.imagesRef.push(src); } } else { ret.image = element.getAttribute("src"); } delete ret.stack; delete ret.text; // apply all the inhirent classes and styles from the parents, or for the current element ret = this.applyStyle({ret:ret, parents:parents.concat([element])}); break; } case "A": { // the link must be applied to the deeper `text` or stacked element (e.g. `image`) var setLink = function(pointer, href) { pointer = pointer || {text:''}; // for link without any text if (Array.isArray(pointer.text)) { pointer.text = pointer.text.map(function(text) { return setLink(text, href); }); return pointer; } else if (Array.isArray(pointer.stack)) { // if we have a more complex layer pointer.stack = pointer.stack.map(function(stack) { return setLink(stack, href); }); return pointer; } // if 'href' starts with '#' then it's an internal link if (href.indexOf('#') === 0) pointer.linkToDestination=href.slice(1); else pointer.link = href; return pointer; } if (element.getAttribute("href")) { ret = setLink(ret, element.getAttribute("href")); // reduce the complexity when only 1 text if (Array.isArray(ret.text) && ret.text.length === 1) ret = ret.text[0]; ret.nodeName = "A"; } break; } default: { // handle other cases if (nodeName === "DIV" && element.dataset && element.dataset.pdfmakeType === "columns") { // if it's a <DIV> with data-pdfmake-type="columns" // then we interpret it as the COLUMNS in PDFMake if (ret.stack) { ret.columns = ret.stack; delete ret.stack; } } } } if (options && typeof options.customTag === "function") { // handle custom tags ret = options.customTag.call(this, { element: element, parents: parents, ret: ret }); } // reduce the number of JSON properties if (Array.isArray(ret.text) && ret.text.length === 1 && ret.text[0].text && !ret.text[0].nodeName) { ret.text = ret.text[0].text; } // if we are inside <LI> and the text is just empty, PDFMake will ignore it (see https://github.com/Aymkdn/html-to-pdfmake/issues/247) if ( ( (parents.length > 0 && parents[parents.length-1].nodeName === "LI") || nodeName === "LI" ) && ( (Array.isArray(ret.text) && ret.text.length === 0) || (typeof ret.text === 'string' && ret.text === '')) ) { // so we replace it with a space ret.text = ' '; } // check if we have some data-pdfmake to apply if (['HR','TABLE'].indexOf(nodeName) === -1 && element.dataset && element.dataset.pdfmake) { // handle when people will use simple quotes dataset = element.dataset.pdfmake.replace(/'/g, '"'); try { dataset = JSON.parse(dataset); for (key in dataset) { ret[key] = dataset[key]; } } catch (e) { console.error(e); } } return ret; } } } this.searchForStack = function(ret) { if (Array.isArray(ret.text)) { for (var i=0; i<ret.text.length; i++) { if (ret.text[i].stack || ['P','DIV','TABLE','SVG','UL','OL','IMG','H1','H2','H3','H4','H5','H6'].indexOf(ret.text[i].nodeName) > -1) return true; if (this.searchForStack(ret.text[i]) === true) return true; } } return false; } /** * Apply style and classes from all the parents * * @param {Object} params * @param {Object} ret The object that will receive the 'style' and other properties * @param {Array} parents Array of node elements * @return {Object} the modified 'ret' */ this.applyStyle = function(params) { var cssClass = []; var lastIndex = params.parents.length-1; var _this = this; params.parents.forEach(function(parent, parentIndex) { // classes var parentNodeName = parent.nodeName.toLowerCase(); if (!_this.removeTagClasses) { var htmlClass = 'html-' + parentNodeName; if (htmlClass !== 'html-body' && cssClass.indexOf(htmlClass) === -1) cssClass.unshift(htmlClass); } var parentClass = (parent.getAttribute("class")||"").split(' '); parentClass.forEach(function(p) { if (p) cssClass.push(p); }); // styles var style; // not all the CSS properties should be inherent var ignoreNonDescendentProperties = (parentIndex!==lastIndex); // 1) the default styles if (_this.defaultStyles[parentNodeName]) { for (style in _this.defaultStyles[parentNodeName]) { if (_this.defaultStyles[parentNodeName].hasOwnProperty(style)) { if (!ignoreNonDescendentProperties || (ignoreNonDescendentProperties && style.indexOf('margin') === -1 && style.indexOf('border') === -1 ) ) { // 'decoration' can be an array if (style === 'decoration') { if (!Array.isArray(params.ret[style])) params.ret[style]=[]; // do not apply twice the same (e.g. applying 2 "underline" will cause an extra blank space with an underline) if (_this.defaultStyles[parentNodeName][style] && params.ret[style].indexOf(_this.defaultStyles[parentNodeName][style]) === -1) { params.ret[style].push(_this.defaultStyles[parentNodeName][style]); } } else { params.ret[style] = JSON.parse(JSON.stringify(_this.defaultStyles[parentNodeName][style])); } } } } } // 2) element's style // we want TD/TH to receive descendant properties from TR if (parentNodeName === 'tr') ignoreNonDescendentProperties=false; style = _this.parseStyle(parent, ignoreNonDescendentProperties); style.forEach(function(stl) { // 'decoration' can be an array if (stl.key === "decoration") { if (!Array.isArray(params.ret[stl.key])) params.ret[stl.key]=[]; params.ret[stl.key].push(stl.value); } // ignore the "alignment" for the <ol> and <ul> elements (see https://github.com/Aymkdn/html-to-pdfmake/issues/245) else if (["UL", "OL"].includes(params.ret.nodeName) && stl.key === "alignment") { // do nothing } else { // when 'params.ret.margin' is defined but also a 'marginXYZ' is defined in `stl.key`, // then we should change the correct index in `params.ret.margin` to reflect it if (params.ret.margin && stl.key.indexOf('margin') === 0) { // order: left | top | right | bottom switch(stl.key) { case "marginLeft": params.ret.margin[0]=stl.value; break; case "marginTop": params.ret.margin[1]=stl.value; break; case "marginRight": params.ret.margin[2]=stl.value; break; case "marginBottom": params.ret.margin[3]=stl.value; break; } } else { params.ret[stl.key] = stl.value; } } }); }); if (cssClass.length>0) params.ret.style = cssClass; return params.ret; } /** * Border Value Rearrange a CSS expression (e.g. 'border:solid 10px red' to 'border:10px solid red') * * @param {String} styleStr The CSS expression values * @returns {String} border value in global accepted format (e.g. 'border:10px solid red') */ this.borderValueRearrange = function(styleStr) { try { var styleArray = styleStr.split(' '); if (styleArray.length!==3) return styleStr; var v1 = "0px", v2 = "none", v3 = "transparent"; var style = ["dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset", "none", "hidden", "mix"]; styleArray.forEach(function (v) { if (v.match(/^\d/)) { v1 = v; } else if (style.indexOf(v) > -1) { v2 = v; } else { v3 = v; } }); return v1 + ' ' + v2 + ' ' + v3; } catch (e) { return styleStr; } } /** * Transform a CSS expression (e.g. 'margin:10px') in the PDFMake version * * @param {String} style The CSS expression to transform * @param {DOMElement} element * @param {Boolean} ignoreProperties TRUE when we have to ignore some properties, like border, padding, margin * @returns {Array} array of {key, value} */ this.parseStyle = function(element, ignoreProperties) { var style = element.getAttribute("style") || ""; var ret = []; style = style.replace(/!important/g, '').split(';'); // check if we have "width" or "height" var width = element.getAttribute("width"); var height = element.getAttribute("height"); if (width) { style.unshift("width:" + this.convertToUnit(width + (isNaN(width) ? "" : "px"))); } if (height) { style.unshift("height:" + this.convertToUnit(height + (isNaN(height) ? "" : "px"))); } // check if we have 'color' or 'size' -- mainly for '<font>' var color = element.getAttribute("color"); if (color) { ret.push({key:"color", value:this.parseColor(color).color}); } var size = element.getAttribute("size"); if (size !== null) { // Getting and sanitizing the size value: it should be included between 1 and 7 size = Math.min(Math.max(1, parseInt(size)), 7); // Assigning the font size ret.push({key:'fontSize', value:Math.max(this.fontSizes[0], this.fontSizes[size - 1])}); } var styleDefs = style.map(function(style) { return style.toLowerCase().split(':') }); var borders = []; // special treatment for borders var nodeName = element.nodeName.toUpperCase(); var _this=this; styleDefs.forEach(function(styleDef) { if (styleDef.length===2) { var key = styleDef[0].trim().toLowerCase(); var value = styleDef[1].trim(); var res; if (_this.ignoreStyles.indexOf(key) === -1) { switch (key) { case "margin": { if (ignoreProperties) break; // pdfMake uses a different order than CSS value = value.split(' '); if (value.length===1) value=[value[0], value[0], value[0], value[0]]; else if (value.length===2) value=[value[1], value[0]]; // vertical | horizontal ==> horizontal | vertical else if (value.length===3) value=[value[1], value[0], value[1], value[2]]; // top | horizontal | bottom ==> left | top | right | bottom else if (value.length===4) value=[value[3], value[0], value[1], value[2]]; // top | right | bottom | left ==> left | top | right | bottom // we now need to convert to PT value.forEach(function(val, i) { // PDFMake doesn't support "auto" as a value if (val === 'auto') value[i] = ''; else value[i] = _this.convertToUnit(val); }); // ignore if we have a FALSE in the table if (value.indexOf(false) === -1) ret.push({key:key, value:value}); break; } case "line-height": { // change % unit if (typeof value === "string" && value.slice(-1) === '%') { value = value.slice(0,-1) / 100; } else { value = _this.convertToUnit(value); } ret.push({key:"lineHeight", value:value}); break; } case "text-align": { ret.push({key:"alignment", value:value}); break; } case "font-weight": { if (value === "bold" || value >= 700) ret.push({key:"bold", value:true}); else ret.push({ key: "bold", value: false }); break; } case "text-decoration": { // verify the value is valid value = _this.toCamelCase(value); if (["underline", "lineThrough", "overline"].includes(value)) { ret.push({key:"decoration", value:value}) } break; } case "font-style": { if (value==="italic") ret.push({key:"italics", value:true}); break; } case "font-family": { ret.push({ key: "font", value: value.split(',')[0].replace(/"|^'|^\s*|\s*$|'$/g, "").replace(/^([a-z])/g, function (g) { return g[0].toUpperCase(); }).replace(/ ([a-z])/g, function (g) { return g[1].toUpperCase(); }) }); break; } case "color": { res = _this.parseColor(value); ret.push({key:"color", value:res.color}); if (res.opacity < 1) ret.push({key:"opacity", value:res.opacity}); break; } case "background-color": { // if TH/TD and key is 'background', then we use 'fillColor' instead res = _this.parseColor(value); // if the color is "transparent", we ignore it if (res.color !== "transparent") { ret.push({key:(nodeName === 'TD' || nodeName === 'TH' ? "fillColor" : "background"), value:res.color}); if (res.opacity < 1) ret.push({key:(nodeName === 'TD' || nodeName === 'TH' ? "fillOpacity" : "opacity"), value:res.opacity}); } break; } case "text-indent": { ret.push({key:"leadingIndent", value:_this.convertToUnit(value)}); break; } case "white-space": { if (value==='nowrap') { ret.push({key:"noWrap", value:true}); } else { ret.push({key:"preserveLeadingSpaces", value:(value==='break-spaces' || value.slice(0,3) === 'pre')}); } break; } default: { // do we have borders properties? if (key.indexOf('border') === 0) { if (!ignoreProperties) borders.push({key:key, value:value}); } else { // ignore some properties if (ignoreProperties && (key.indexOf("margin-") === 0 || key === 'width' || key === 'height')) break; // for IMG only (see issue #181) if (nodeName === "IMG" && (key === 'width' || key === 'height')) { ret.push({key:key, value: _this.convertToUnit(value)}); break; } // padding is not supported by PDFMake if (key.indexOf("padding") === 0) break; if (key.indexOf("-") > -1) key=_this.toCamelCase(key); if (value) { // convert value to a 'pt' when possible var parsedValue = _this.convertToUnit(value); // if we have 'font-size' with a parsedValue at false, then: // check if it's one of know keywords (like medium, small, x-small, etc), otherwise ignore it if (key === 'fontSize' && parsedValue === false) { if (["xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large", "xxx-large"].includes(value)) { // we use 12pt as the medium value switch(value) { case "xx-small": value=7.2; break; // 60% case "x-small": value=9; break; // 75% case "small": value=10.7; break; // 89% case "medium": value=12; break; case "large": value=14.4; break; // 120% case "x-large": value=18; break; // 150% case "xx-large": value=24; break; // 200% case "xxx-large": value=36; break; // 300% } } else { break; } } // PDFMake doesn't support "auto" as a value for "margin" (at least) if (key.indexOf("margin") === 0 && value === 'auto') break; ret.push({key:key, value:(parsedValue === false ? value : parsedValue)}); } } } } } } }); // deal with the borders if (borders.length > 0) { // pdfmake supports only 2 properties: // - "border" (true/false) to indicate if the border should be visible // - "borderColor" (string) to indicate the color of the border var border = []; // array of boolean var borderColor = []; // array of colors borders.forEach(function(b) { var index = -1, i; // determine if the current property is for 'border-left', or 'border-right', or 'border-top', or 'border-bottom' if (b.key.indexOf('-left') > -1) index=0; else if (b.key.indexOf('-top') > -1) index=1; else if (b.key.indexOf('-right') > -1) index=2; else if (b.key.indexOf('-bottom') > -1) index=3; // for 'border', 'border-left', 'border-right', 'border-top', and 'border-bottom', then we should have three values: width style color // so we try to find them var splitKey = b.key.split('-'), properties, width; if (splitKey.length === 1 /* border */ || (splitKey.length === 2 && index >= 0 /* border-left|right|top|bottom */)) { b.value = _this.borderValueRearrange(b.value); properties = b.value.split(' '); width = properties[0].replace(/(\d*)(\.\d+)?([^\d]+)/g,"$1$2 ").trim(); // for the width if (index > -1) { border[index] = (width > 0); } else { for (i=0; i<4; i++) border[i] = (width > 0); } // for the color if (properties.length > 2) { var color = properties.slice(2).join(' '); if (index > -1) { borderColor[index] = _this.parseColor(color).color; } else { for (i=0; i<4; i++) borderColor[i] = _this.parseColor(color).color; } } } // otherwise it means we could have a specific value for a specific property // e.g. 'border-left-color' or 'border-top-width' // only 'color' and 'width' are supported else if (index >= 0 && splitKey[2] === 'color') { borderColor[index] = _this.parseColor(b.value).color; } else if (index >= 0 && splitKey[2] === 'width') { border[index] = !/^0[a-z%]*$/.test(String(b.value)); } // otherwise we could have 'border-color' or 'border-width' else if (b.key === 'border-color') { properties = _this.topRightBottomLeftToObject(b.value); borderColor = [ _this.parseColor(properties.left).color, _this.parseColor(properties.top).color, _this.parseColor(properties.right).color, _this.parseColor(properties.bottom).color ]; } else if (b.key === 'border-width') { properties = _this.topRightBottomLeftToObject(b.value); border = [ !/^0[a-z%]*$/.test(properties.left), !/^0[a-z%]*$/.test(properties.top), !/^0[a-z%]*$/.test(properties.right), !/^0[a-z%]*$/.test(properties.bottom) ]; } }); // fill the gaps for (var i=0; i<4; i++) { if (border.length > 0 && typeof border[i] === "undefined") border[i]=true; if (borderColor.length > 0 && typeof borderColor[i] === "undefined") borderColor[i]='#000000'; } if (border.length > 0) ret.push({key:'border', value:border}); if (borderColor.length > 0) ret.push({key:'borderColor', value:borderColor}); } return ret; } this.toCamelCase = function(str) { return str.replace(/-([a-z])/g, function (g) { return g[1].toUpperCase() }); } // Convert the CSS properties like: // - top right bottom left // - top (left/right) bottom // - (top/bottom) (left/right) // - (top/bottom/left/right) // // to an object that gives {top, right, bottom, left} this.topRightBottomLeftToObject = function(props) { // regexp to capture the colors (hex, rgb, rgba, hsl, hsla, color name) var colorRegex = /#[0-9a-fA-F]{3,6}|\b(?:rgba?|hsla?)\([^)]*\)|\b[a-zA-Z]+\b/g; var colors = props.match(colorRegex) || []; var top=colors[0], right=colors[1], bottom=colors[2], left=colors[3]; switch (colors.length) { case 1: right = bottom = left = top; break; case 2: bottom = top; left = right; break; case 3: left = right; break; } return { top:top, right:right, bottom:bottom, left:left }; } // input: h in [0,360] and s,v in [0,1] - output: "rgb(0–255,0–255,0–255)"" // source: https://stackoverflow.com/a/54014428/1134119 + https://stackoverflow.com/questions/2353211/hsl-to-rgb-color-conversion#comment58413965_9493060 this.hsl2rgb = function(h,s,l) { var a = s*Math.min(l,1-l); var f = function(n) { var k=(n+h/30)%12; return Math.min(Math.floor((l - a*Math.max(Math.min(k-3,9-k,1),-1))*256),255); } return "rgb("+f(0)+","+f(8)+","+f(4)+")"; } /** * Returns the color in a hex format (e.g. #12ff00). * Also tries to convert RGB colors into hex values * * @param color color as string representation * @returns {color (as hex values for pdfmake), opacity} */ this.parseColor = function(color) { var opacity = 1; // e.g. `#fff` or `#ff0048` var haxRegex = new RegExp('^#([0-9a-f]{3}|[0-9a-f]{6})$', 'i'); // e.g. rgb(0,255,34) or rgb(22, 0, 0) or rgb(100%, 100%, 100%) or rgba(0,125,250,0.8) var rgbRegex = /^rgba?\(\s*(\d+(\.\d+)?%?),\s*(\d+(\.\d+)?%?),\s*(\d+(\.\d+)?%?)(,\s*\d+(\.\d+)?)?\)$/; // e.g. hsl(300, 10%, 20%) var hslRegex = new RegExp('^hsl\\((\\d+(\\.\\d+)?%?),\\s*(\\d+(\\.\\d+)?%?),\\s*(\\d+(\\.\\d+)?%?)\\)$'); // e.g. "white" or "red" var nameRegex = new RegExp('^[a-z]+$', 'i'); var decimalColors, decimalValue, hexString, ret=[]; if (haxRegex.test(color)) { return {color:color, opacity:opacity}; } if (hslRegex.test(color)) { // we want to convert to RGB decimalColors = hslRegex.exec(color).slice(1); // first value should be from 0 to 360 if (decimalColors[0].endsWith('%')) decimalValue = decimalColors[0].slice(0,-1) * 360 / 100; else decimalValue = decimalColors[0]*1; ret.push(decimalValue); // next values should be % to convert to base 1 ret.push(decimalColors[2].slice(0,-1) / 100); ret.push(decimalColors[4].slice(0,-1) / 100); color = this.hsl2rgb(ret[0], ret[1], ret[2]); ret = []; } if (rgbRegex.test(color)) { decimalColors = rgbRegex.exec(color).slice(1).filter(function(v,i) { return i%2===0 && typeof v !== "undefined"; }); decimalColors.forEach(function(decimalValue, i) { // for the alpha number if (i === 3) { opacity = decimalValue.slice(1)*1; } else { // if it ends with '%', we calculcate based on 100%=255 if (decimalValue.endsWith('%')) { decimalValue = Math.round(decimalValue.slice(0,-1) * 255 / 100); } else decimalValue = decimalValue*1; if (decimalValue > 255) { decimalValue = 255; } hexString = '0' + decimalValue.toString(16); hexString = hexString.slice(-2); ret.push(hexString); } }) return {color:'#' + ret.join(''), opacity:opacity}; } if (nameRegex.test(color)) return {color:color, opacity:opacity}; console.error('Could not parse co