diff --git a/dev-playground/public/index.html b/dev-playground/public/index.html index 66cecf303..9e77322e9 100644 --- a/dev-playground/public/index.html +++ b/dev-playground/public/index.html @@ -58,10 +58,10 @@

Playground does not make too much sense when horizontal resolution is below var editor = ace.edit('editor'); setupEditor(editor); - var names = ['basics', 'styles1', 'styles2', 'styles3', 'columns', 'tables', 'lists', 'margin', 'images', 'svgs']; + var names = ['basics', 'styles1', 'styles2', 'styles3', 'columns', 'tables', 'lists', 'margin', 'images', 'svgs', 'acroforms']; var i = 0; - ['basics', 'named-styles', 'inline-styling', 'style-overrides', 'columns', 'tables', 'lists', 'margins', 'images', 'svgs'].forEach(function (example) { + ['basics', 'named-styles', 'inline-styling', 'style-overrides', 'columns', 'tables', 'lists', 'margins', 'images', 'svgs', 'acroforms'].forEach(function (example) { $scope.examples.push({ name: names[i++], activate: function () { diff --git a/dev-playground/public/samples/acroforms b/dev-playground/public/samples/acroforms new file mode 100644 index 000000000..9abcca983 --- /dev/null +++ b/dev-playground/public/samples/acroforms @@ -0,0 +1,338 @@ +subsetFonts: false, +content: [ + {text: 'Acroforms', style: 'header'}, + { + text: [ + "It is recommended that you test your PDF form ", + "documents across all platforms and viewers that you wish to ", + "support. Refer to ", + "https://pdfkit.org/docs/forms or the PDF ", + "reference for form options and advanced form field use.\n\n" + ], + style: 'description' + }, + + {text: 'Components\n', style: 'subHeader'}, + { + text: [ + "Make sure you set the subsetFonts flag to false when using ", + "form fields with text.\n\n" + ], + style: {bold: true} + }, + {text: 'Text field', style: 'formHeader'}, + { + columns: [ + [ + { + acroform: { + type: 'text', + id: 'text_placeholder', + options: { + value: 'Placeholder ...' + } + + }, + style: 'textFieldStyle', + height: 15, + italics: true, + }, + { + acroform: { + type: 'text', + id: 'text_date', + options: { + format: { + type: 'date', + } , + align: 'center', + value: '10/12' + } + + }, + style: 'textFieldStyle', + height: 15, + + }, + { + acroform: { + type: 'text', + id: 'text_multi', + options: { + multiline: true, + value: "multiline text form " + } + }, + style: 'textFieldStyle', + height: 50, + }, + ], + [ + + { + acroform: { + type: 'text', + id: 'text_alignment', + options: { + align: 'right', + value: 'right alignment' + } + + }, + style: 'textFieldStyle', + height: 15, + }, + + { + acroform: { + type: 'text', + id: 'text_color', + options: { + align: 'center', + required: true, + value: 'Ω©®℅¥123' + }, + }, + style: 'textFieldStyle', + height: 15, + + }, + ], + [ + { + acroform: { + type: 'text', + id: 'text_currency', + options: { + align: 'right', + format: { + type: 'number', + nDec: 2, + sepComma: true, + negStyle: 'ParensRed', + currency: '$', + currencyPrepend: true + }, + value: '$123,346.99' + } + }, + style: 'textFieldStyle', + height: 15, + }, + { + acroform: { + type: 'text', + id: 'text_backgroundcolor', + options: { + backgroundColor: 'yellow', + borderColor: 'green', + align: 'center', + value: 'background color' + } + }, + style: 'textFieldStyle', + height: 15, + }, + ], + ], + + }, + { + columns: [ + [ + {text: '\nList', style: 'formHeader'}, + { + acroform: { + type: 'list', + id: 'list1', + options: { + select: ['', 'A', 'B', 'C'], + } + + }, + width: 100, + height: 60, + + }, + ], + [ + {text: '\nCombobox', style: 'formHeader'}, + { + acroform: { + type: 'combo', + id: 'combo1', + options: { + select: ['', 'A', 'B', 'C'], + defaultValue: '' + } + + }, + width: 100, + height: 20, + + }, + ], + [ + {text: '\nCheckbox form', style: 'formHeader'}, + { + acroform: { + type: 'checkbox', + id: 'checkbox1', + options: { + selected: false, + } + }, + width: 20, + height: 20, + + }, + ], + [ + {text: '\nRadio form', style: 'formHeader'}, + { + acroform: { + type: 'radio', + id: 'radioChild1', + options: { + parentId: 'radioForm1', + selected: true, + }, + }, + width: 50, + height: 30, + + }, + { + acroform: { + type: 'radio', + id: 'radioChild2', + options: { + parentId: 'radioForm1', + }, + }, + width: 40, + height: 25, + + }, + { + acroform: { + type: 'radio', + id: 'radioChild3', + options: { + parentId: 'radioForm1', + }, + }, + width: 30, + height: 20, + }, + ], + ] + }, + { + text: '\nYou can also use forms inline with text\n\n', + style: 'subHeader' + }, + { + text: [ + "Check this box! ", + { + acroform: { + type: 'checkbox', + id: 'checkbox2', + options: { + selected: false + } + }, + width: 15, + height: 15, + + }, + '\n\n' + ], + alignment: 'center' + }, + { + text: [ + "The weather was very ", + { + acroform: { + type: 'combo', + id: 'combo_story1', + options: { + select: ['', 'nice', 'bad'], + defaultValue: '' + } + + }, + width: 50, + height: 9.5, + + }, + " and the skies were ", + { + acroform: { + type: 'combo', + id: 'combo_story2', + options: { + select: ['', 'clear', 'cloudy'], + defaultValue: '' + } + + }, + width: 50, + height: 9.5, + + }, + ". A fox named ", + { + acroform: { + type: 'text', + id: 'text_story1', + }, + width: 50, + height: 9.5, + }, + " and friends were playing a game at the park, when suddenly", + "... ", + { + text: 'finish the story', + style: {italics: true, bold: true} + + }, + ], + }, + { + acroform: { + type: 'text', + id: 'text_story2', + options: { + multiline: true + } + }, + width: '*', + height: 100, + }, +], +styles: { + header: { + fontSize: 18, + bold: true, + margin: [0, 0, 0, 10], + lineHeight: 0.3, + }, + description: { + fontSize: 13, + margin: [0, 10, 0, 5] + }, + subHeader: { bold: true, fontSize: 13 }, + formHeader: { + fontSize: 12, + color: 'black' + }, + tableCell: { + margin: [0, 5, 0, 5] + }, + textFieldStyle: { + margin: [0, 0, 5, 5] + } +} \ No newline at end of file diff --git a/dev-playground/server.js b/dev-playground/server.js index ff2fe12f9..10519ba48 100644 --- a/dev-playground/server.js +++ b/dev-playground/server.js @@ -35,6 +35,7 @@ app.post('/pdf', function (req, res) { res.contentType('application/pdf'); res.send(binary); }, function (error) { + console.log(error) //print in console res.send('ERROR:' + error); }); diff --git a/package.json b/package.json index 4cd51f230..c5b7f9f48 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,8 @@ "build:fonts": "shx mkdir -p build/fonts && shx mkdir -p build/fonts/Roboto && shx cp -r fonts/Roboto/*.* build/fonts/Roboto && brfs \"./src/browser-extensions/fonts/Roboto.js\" > build/fonts/Roboto.js", "lint": "eslint \"./src/**/*.js\" \"./tests/**/*.js\" \"./examples/**/*.js\" \"./standard-fonts/**/*.js\" \"./fonts/**/*.js\"", "mocha": "mocha --reporter spec \"./tests/**/*.spec.js\"", - "playground": "node dev-playground/server.js" + "playground": "node dev-playground/server.js", + "playground:watch": "nodemon dev-playground/server.js" }, "repository": { "type": "git", diff --git a/src/DocMeasure.js b/src/DocMeasure.js index 7bb6653d7..ed72e1873 100644 --- a/src/DocMeasure.js +++ b/src/DocMeasure.js @@ -61,7 +61,10 @@ class DocMeasure { return extendMargins(this.measureCanvas(node)); } else if (node.qr) { return extendMargins(this.measureQr(node)); - } else { + } else if (node.acroform) { + return extendMargins(this.measureAcroForm(node)); + } + else { throw new Error(`Unrecognized document structure: ${stringifyNode(node)}`); } }); @@ -686,7 +689,24 @@ class DocMeasure { measureQr(node) { node = qrEncoder.measure(node); - node._alignment = this.styleStack.getProperty('alignment'); + node._styleStack = this.styleStack.getProperty('alignment'); + return node; + } + + measureAcroForm(node) { + node._minWidth = 10; + node._minHeight = 10; + + let font = StyleContextStack.getStyleProperty(node, this.styleStack, 'font', 'Roboto'); + let bold = StyleContextStack.getStyleProperty(node, this.styleStack, 'bold', false); + let italics = StyleContextStack.getStyleProperty(node, this.styleStack, 'italics', false); + + node.font = font; + node.bold = bold; + node.italics = italics; + + node.alignment = StyleContextStack.getStyleProperty(node, this.styleStack, 'alignment', 'left'); + return node; } } diff --git a/src/DocPreprocessor.js b/src/DocPreprocessor.js index ce1a4cc80..d4971db82 100644 --- a/src/DocPreprocessor.js +++ b/src/DocPreprocessor.js @@ -32,7 +32,6 @@ class DocPreprocessor { } else if ('text' in node) { // cast value in text property node.text = convertValueToString(node.text); } - if (node.columns) { return this.preprocessColumns(node); } else if (node.stack) { @@ -57,6 +56,8 @@ class DocPreprocessor { return this.preprocessQr(node); } else if (node.pageReference || node.textReference) { return this.preprocessText(node); + } else if (node.acroform) { + return this.preprocessAcroForm(node); } else { throw new Error(`Unrecognized document structure: ${stringifyNode(node)}`); } @@ -242,6 +243,10 @@ class DocPreprocessor { return node; } + preprocessAcroForm(node) { + return node; + } + preprocessQr(node) { return node; } diff --git a/src/ElementWriter.js b/src/ElementWriter.js index ec70cb219..b4c05cbb4 100644 --- a/src/ElementWriter.js +++ b/src/ElementWriter.js @@ -23,7 +23,7 @@ class ElementWriter extends EventEmitter { let context = this.context(); let page = context.getCurrentPage(); let position = this.getCurrentPositionOnPage(); - + if (context.availableHeight < height || !page) { return false; } @@ -42,7 +42,7 @@ class ElementWriter extends EventEmitter { if (!dontUpdateContextPosition) { context.moveDown(height); } - + return position; } @@ -196,6 +196,33 @@ class ElementWriter extends EventEmitter { return position; } + addAcroForm(node, index) { + let context = this.context(); + let page = context.getCurrentPage(); + let position = this.getCurrentPositionOnPage(); + + if (!page) { + return false; + } + + if (node._x === undefined) { + node._x = node.x || 0; + } + + addPageItem(page, { + type: 'acroform', + item: node + }, index); + + node.x = context.x + node._x; + node.y = context.y; + + + context.moveDown(node.height || node._minHeight); + + return position; + } + alignImage(image) { let width = this.context().availableWidth; let imageWidth = image._minWidth; diff --git a/src/LayoutBuilder.js b/src/LayoutBuilder.js index 982754cdf..651c240f1 100644 --- a/src/LayoutBuilder.js +++ b/src/LayoutBuilder.js @@ -77,7 +77,7 @@ class LayoutBuilder { let nodeInfo = {}; [ 'id', 'text', 'ul', 'ol', 'table', 'image', 'qr', 'canvas', 'svg', 'columns', - 'headlineLevel', 'style', 'pageBreak', 'pageOrientation', + 'headlineLevel', 'style', 'pageBreak', 'pageOrientation', 'acroForm', 'type', 'options', 'width', 'height' ].forEach(key => { if (node[key] !== undefined) { @@ -439,6 +439,8 @@ class LayoutBuilder { this.processCanvas(node); } else if (node.qr) { this.processQr(node); + } else if (node.acroform) { + this.processAcroForm(node); } else if (!node._span) { throw new Error(`Unrecognized document structure: ${stringifyNode(node)}`); } @@ -645,7 +647,7 @@ class LayoutBuilder { processor.endTable(this.writer); } - // leafs (texts) + // leafs (texts, acroform) processLeaf(node) { let line = this.buildNextLine(node); if (line && (node.tocItem || node.id)) { @@ -724,7 +726,7 @@ class LayoutBuilder { let inline = textNode._inlines.shift(); isForceContinue = false; - if (!inline.noWrap && inline.text.length > 1 && inline.width > line.getAvailableWidth()) { + if (!inline.noWrap && inline.text && inline.text.length > 1 && inline.width > line.getAvailableWidth()) { let widthPerChar = inline.width / inline.text.length; let maxChars = Math.floor(line.getAvailableWidth() / widthPerChar); if (maxChars < 1) { @@ -774,6 +776,13 @@ class LayoutBuilder { let position = this.writer.addQr(node); node.positions.push(position); } + + processAcroForm (node) { + let availableWidth = this.writer.context().availableWidth; + let position = this.writer.addAcroForm(node); + node.positions.push(position); + node.availableWidth = availableWidth; + } } function decorateNode(node) { diff --git a/src/Line.js b/src/Line.js index 7ff24511f..293dc28b1 100644 --- a/src/Line.js +++ b/src/Line.js @@ -33,13 +33,7 @@ class Line { * @returns {number} */ getHeight() { - let max = 0; - - this.inlines.forEach(item => { - max = Math.max(max, item.height || 0); - }); - - return max; + return Math.max(...this.inlines.map(item => item.height || 0)); } /** @@ -49,7 +43,7 @@ class Line { let y = 0; this.inlines.forEach(inline => { - y = Math.max(y, inline.font.ascender / 1000 * inline.fontSize); + y = Math.max(y, inline.acroform ? inline.height : inline.font.ascender / 1000 * inline.fontSize); }); return y; diff --git a/src/PDFDocument.js b/src/PDFDocument.js index a9683863e..8bcb2bd2d 100644 --- a/src/PDFDocument.js +++ b/src/PDFDocument.js @@ -1,4 +1,7 @@ +import ExtendedAcroFormMixin from './pdf-kit-extensions/ExtendedAcroFormMixin'; +import PDFEmbeddedFont from './pdf-kit-extensions/PDFEmbeddedFont'; import PDFKit from '@foliojs-fork/pdfkit'; +import { isStandardFont } from './pdf-kit-extensions/StandardFonts'; const typeName = (bold, italics) => { let type = 'normal'; @@ -11,9 +14,9 @@ const typeName = (bold, italics) => { } return type; }; - + class PDFDocument extends PDFKit { - constructor(fonts = {}, images = {}, patterns = {}, options = {}, virtualfs = null) { + constructor(fonts = {}, images = {}, patterns = {}, options = {}, virtualfs = null, subsetFonts = true) { super(options); this.fonts = {}; @@ -39,9 +42,9 @@ class PDFDocument extends PDFKit { } } - this.images = images; this.virtualfs = virtualfs; + this.subsetFonts = subsetFonts; //TODO maybe automatically set this flag } getFontType(bold, italics) { @@ -75,7 +78,20 @@ class PDFDocument extends PDFKit { def[0] = this.virtualfs.readFileSync(def[0]); } - this.fontCache[familyName][type] = this.font(...def)._font; + if (this.subsetFonts == false && !isStandardFont(def[0])) { + this._font = new PDFEmbeddedFont( + this, + def[0], + `F${++this._fontCount}`, + ); + + this._fontFamilies[def[0]] = this._font; + this._fontFamilies[this._font.name] = this._font; + + this.fontCache[familyName][type] = this._font; + } else { + this.fontCache[familyName][type] = this.font(...def)._font; + } } return this.fontCache[familyName][type]; @@ -145,4 +161,10 @@ class PDFDocument extends PDFKit { } } +export function mixin(methods) { + Object.assign(PDFDocument.prototype, methods); +} + +mixin(ExtendedAcroFormMixin); + export default PDFDocument; diff --git a/src/PageElementWriter.js b/src/PageElementWriter.js index a9b2ff27b..756a50598 100644 --- a/src/PageElementWriter.js +++ b/src/PageElementWriter.js @@ -35,6 +35,10 @@ class PageElementWriter extends ElementWriter { return this._fitOnPage(() => super.addQr(qr, index)); } + addAcroForm(node, index) { + return this._fitOnPage(() => super.addAcroForm(node, index)); + } + addVector(vector, ignoreContextX, ignoreContextY, index) { return super.addVector(vector, ignoreContextX, ignoreContextY, index); } diff --git a/src/Printer.js b/src/Printer.js index 6aeca159d..ef3b35bc4 100644 --- a/src/Printer.js +++ b/src/Printer.js @@ -68,8 +68,8 @@ class PdfPrinter { font: null }; - this.pdfKitDoc = new PDFDocument(this.fontDescriptors, docDefinition.images, docDefinition.patterns, pdfOptions, this.virtualfs); - + this.pdfKitDoc = new PDFDocument(this.fontDescriptors, docDefinition.images, docDefinition.patterns, pdfOptions, this.virtualfs, docDefinition.subsetFonts); + const builder = new LayoutBuilder(pageSize, normalizePageMargin(docDefinition.pageMargins), new SVGMeasure()); builder.registerTableLayouts(tableLayouts); diff --git a/src/Renderer.js b/src/Renderer.js index 19bf464a0..7713a9855 100644 --- a/src/Renderer.js +++ b/src/Renderer.js @@ -41,6 +41,7 @@ class Renderer { constructor(pdfDocument, progressCallback) { this.pdfDocument = pdfDocument; this.progressCallback = progressCallback; + this.hasFormInit = false; } renderPages(pages) { @@ -65,6 +66,7 @@ class Renderer { let page = pages[i]; for (let ii = 0, il = page.items.length; ii < il; ii++) { let item = page.items[ii]; + switch (item.type) { case 'vector': this.renderVector(item.item); @@ -78,6 +80,9 @@ class Renderer { case 'svg': this.renderSVG(item.item); break; + case 'acroform': + this.renderAcroForm(item.item); + break; case 'beginClip': this.beginClip(item.item); break; @@ -144,48 +149,59 @@ class Renderer { let inline = line.inlines[i]; let shiftToBaseline = lineHeight - ((inline.font.ascender / 1000) * inline.fontSize) - descent; - if (inline._pageNodeRef) { - preparePageNodeRefLine(inline._pageNodeRef, inline); - } - - let options = { - lineBreak: false, - textWidth: inline.width, - characterSpacing: inline.characterSpacing, - wordCount: 1, - link: inline.link - }; - - if (inline.linkToDestination) { - options.goTo = inline.linkToDestination; - } - - if (line.id && i === 0) { - options.destination = line.id; - } - - if (inline.fontFeatures) { - options.features = inline.fontFeatures; - } - - let opacity = isNumber(inline.opacity) ? inline.opacity : 1; - this.pdfDocument.opacity(opacity); - this.pdfDocument.fill(inline.color || 'black'); - - this.pdfDocument._font = inline.font; - this.pdfDocument.fontSize(inline.fontSize); - - let shiftedY = offsetText(y + shiftToBaseline, inline); - this.pdfDocument.text(inline.text, x + inline.x, shiftedY, options); + if (inline.acroform) { + //TODO positioning issue + let shiftedY = y + (lineHeight - ((inline.font.ascender / 1000) * inline.height) - descent); + inline.y = shiftedY; + inline.x = x + inline.x; - if (inline.linkToPage) { - this.pdfDocument.ref({ Type: 'Action', S: 'GoTo', D: [inline.linkToPage, 0, 0] }).end(); - this.pdfDocument.annotate(x + inline.x, shiftedY, inline.width, inline.height, { Subtype: 'Link', Dest: [inline.linkToPage - 1, 'XYZ', null, null, null] }); + this.renderAcroForm(inline); + } else { + if (inline._pageNodeRef) { + preparePageNodeRefLine(inline._pageNodeRef, inline); + } + + let options = { + lineBreak: false, + textWidth: inline.width, + characterSpacing: inline.characterSpacing, + wordCount: 1, + link: inline.link + }; + + if (inline.linkToDestination) { + options.goTo = inline.linkToDestination; + } + + if (line.id && i === 0) { + options.destination = line.id; + } + + if (inline.fontFeatures) { + options.features = inline.fontFeatures; + } + + let opacity = isNumber(inline.opacity) ? inline.opacity : 1; + this.pdfDocument.opacity(opacity); + this.pdfDocument.fill(inline.color || 'black'); + + this.pdfDocument._font = inline.font; + this.pdfDocument.fontSize(inline.fontSize); + + let shiftedY = offsetText(y + shiftToBaseline, inline); + this.pdfDocument.text(inline.text, x + inline.x, shiftedY, options); + + if (inline.linkToPage) { + this.pdfDocument.ref({ Type: 'Action', S: 'GoTo', D: [inline.linkToPage, 0, 0] }).end(); + this.pdfDocument.annotate(x + inline.x, shiftedY, inline.width, inline.height, { Subtype: 'Link', Dest: [inline.linkToPage - 1, 'XYZ', null, null, null] }); + } } + } // Decorations won't draw correctly for superscript textDecorator.drawDecorations(line, x, y); + } renderVector(vector) { @@ -326,6 +342,84 @@ class Renderer { SVGtoPDF(this.pdfDocument, svg.svg, svg.x, svg.y, options); } + + renderAcroForm(node) { + const { font, bold, italics } = node; + let { type, options } = node.acroform; + + if (options == null) { + options = {}; + } + + const setFont = () => { + if (typeof font === "string") { + this.pdfDocument._font = this.pdfDocument.provideFont(font, bold, italics); + } else { + this.pdfDocument._font = font; + } + if (this.hasFormInit) { + this.pdfDocument.addFontToAcroFormDict(); + } + }; + + if (this.hasFormInit == false) { + setFont(); + this.pdfDocument.initForm(); + this.hasFormInit = true; + } + + const id = node.acroform.id; + + if (id == null) { + throw new Error(`Acroform field ${id} requires an ID`); + } + + let width = node.width || node.availableWidth || (!isNaN(node._calcWidth) && node._calcWidth) || node._minWidth; + if (node.width == '*') { + width = node.availableWidth; + } + + if (width == null) { + throw new Error(`Form ${type} width is undefined`); + } + + let resolvedType; + + setFont(); + + switch (type) { + case "text": + case "formText": + resolvedType = "formText"; + break; + case "button": + case "formPushButton": + resolvedType = "formPushButton"; + break; + case "list": + case "formList": + resolvedType = "formList"; + break; + case "combo": + case "formCombo": + resolvedType = "formCombo"; + break; + case "checkbox": + case "formCheckbox": + resolvedType = "formCheckbox"; + break; + case "radio": + case "formRadio": + case "formRadioButton": + resolvedType = "formRadioButton"; + break; + default: + throw new Error(`Unrecognized acroform type: ${type}`); + } + + this.pdfDocument[resolvedType](id, node.x, node.y, width, node.height, options); + } + beginClip(rect) { this.pdfDocument.save(); this.pdfDocument.addContent(`${rect.x} ${rect.y} ${rect.width} ${rect.height} re`); diff --git a/src/TextBreaker.js b/src/TextBreaker.js index e43ae07a0..5fa322834 100644 --- a/src/TextBreaker.js +++ b/src/TextBreaker.js @@ -104,16 +104,23 @@ class TextBreaker { let noWrap = StyleContextStack.getStyleProperty(item || {}, styleContextStack, 'noWrap', false); if (isObject(item)) { - if (item._textRef && item._textRef._textNodeRef.text) { - item.text = item._textRef._textNodeRef.text; + if (item.text) { + if (item._textRef && item._textRef._textNodeRef.text) { + item.text = item._textRef._textNodeRef.text; + } + words = splitWords(item.text, noWrap); + style = StyleContextStack.copyStyle(item); + } else if (item.acroform) { + words = [item]; + style = StyleContextStack.copyStyle(item); } - words = splitWords(item.text, noWrap); - style = StyleContextStack.copyStyle(item); + } else { words = splitWords(item, noWrap); } - if (lastWord && words.length) { + //TODO: handle if last or first is not text + if (lastWord && words.length && !lastWord.acroform) { let firstWord = getFirstWord(words, noWrap); let wrapWords = splitWords(lastWord + firstWord, false); @@ -123,10 +130,16 @@ class TextBreaker { } for (let i2 = 0, l2 = words.length; i2 < l2; i2++) { - let result = { - text: words[i2].text - }; + let result = {}; + if (words[0].acroform) { + result = words[0]; + } else { + result = { + text: words[i2].text + }; + } + if (words[i2].lineEnd) { result.lineEnd = true; } @@ -138,7 +151,11 @@ class TextBreaker { lastWord = null; if (i + 1 < l) { - lastWord = getLastWord(words, noWrap); + if (words[0].acroform) { + lastWord = words[0]; + } else { + lastWord = getLastWord(words, noWrap); + } } } diff --git a/src/TextInlines.js b/src/TextInlines.js index a3aa2fecf..660a43916 100644 --- a/src/TextInlines.js +++ b/src/TextInlines.js @@ -121,7 +121,7 @@ class TextInlines { item.link = StyleContextStack.getStyleProperty(item, styleContextStack, 'link', null); item.linkToPage = StyleContextStack.getStyleProperty(item, styleContextStack, 'linkToPage', null); item.linkToDestination = StyleContextStack.getStyleProperty(item, styleContextStack, 'linkToDestination', null); - item.noWrap = StyleContextStack.getStyleProperty(item, styleContextStack, 'noWrap', null); + item.noWrap = item.acroform ? true: StyleContextStack.getStyleProperty(item, styleContextStack, 'noWrap', null); item.opacity = StyleContextStack.getStyleProperty(item, styleContextStack, 'opacity', 1); item.sup = StyleContextStack.getStyleProperty(item, styleContextStack, 'sup', false); item.sub = StyleContextStack.getStyleProperty(item, styleContextStack, 'sub', false); @@ -133,30 +133,38 @@ class TextInlines { let lineHeight = StyleContextStack.getStyleProperty(item, styleContextStack, 'lineHeight', 1); - item.width = this.widthOfText(item.text, item); - item.height = item.font.lineHeight(item.fontSize) * lineHeight; + if (item.acroform) { + item.width = item.width || 25; + item.height = item.height || 15; + } else { + item.width = this.widthOfText(item.text, item); + item.height = item.font.lineHeight(item.fontSize) * lineHeight; - if (!item.leadingCut) { - item.leadingCut = 0; - } - - let preserveLeadingSpaces = StyleContextStack.getStyleProperty(item, styleContextStack, 'preserveLeadingSpaces', false); - if (!preserveLeadingSpaces) { - let leadingSpaces = item.text.match(LEADING); - if (leadingSpaces) { - item.leadingCut += this.widthOfText(leadingSpaces[0], item); + if (!item.leadingCut) { + item.leadingCut = 0; } - } - - item.trailingCut = 0; - - let preserveTrailingSpaces = StyleContextStack.getStyleProperty(item, styleContextStack, 'preserveTrailingSpaces', false); - if (!preserveTrailingSpaces) { - let trailingSpaces = item.text.match(TRAILING); - if (trailingSpaces) { - item.trailingCut = this.widthOfText(trailingSpaces[0], item); + + let preserveLeadingSpaces = StyleContextStack.getStyleProperty(item, styleContextStack, 'preserveLeadingSpaces', false); + if (!preserveLeadingSpaces) { + let leadingSpaces = item.text.match(LEADING); + if (leadingSpaces) { + item.leadingCut += this.widthOfText(leadingSpaces[0], item); + } + } + + item.trailingCut = 0; + + let preserveTrailingSpaces = StyleContextStack.getStyleProperty(item, styleContextStack, 'preserveTrailingSpaces', false); + if (!preserveTrailingSpaces) { + let trailingSpaces = item.text.match(TRAILING); + if (trailingSpaces) { + item.trailingCut = this.widthOfText(trailingSpaces[0], item); + } } } + + + }, this); return array; diff --git a/src/pdf-kit-extensions/ExtendedAcroFormMixin.js b/src/pdf-kit-extensions/ExtendedAcroFormMixin.js new file mode 100644 index 000000000..11c47e10d --- /dev/null +++ b/src/pdf-kit-extensions/ExtendedAcroFormMixin.js @@ -0,0 +1,224 @@ +/** + * Includes checkboxes and radio group forms + */ +const ExtendedAcroFormMixin = { + + extendedFormAnnotation(name, type, x, y, w, h, options = {}, appearanceId) { + let resolvedType = type == 'radioChild' ? null : type; + let fieldDict = this._fieldDict(name, resolvedType, options); + + //TODO adobe acrobat doesn't like 0 font size + Object.assign(fieldDict, { + DR: this.page.resources, + DA: new String(`/${this._font.id} ${options.fontSize || this._fontSize} Tf 0 g`), + }); + + if (fieldDict.fontSize) { + delete fieldDict.fontSize; + } + + fieldDict.Subtype = 'Widget'; + + if (fieldDict.F === undefined) { + fieldDict.F = 4; + } + + if ((type == 'checkbox' || type == 'radioChild') && appearanceId) { + this.createAppearances(x, y, w, h, fieldDict, type, appearanceId); + } + + this.annotate(x, y, w, h, fieldDict); + + let annotRef = this.page.annotations[this.page.annotations.length - 1]; + return this._addToParent(annotRef); + + }, + + createAppearances(x, y, w, h, options = {}, type, appearanceId) { + const dimentions = [x, y, w, h]; + + if (type == 'radioChild') { //radio field + delete options.T; + } + + if (options.selected) { + options.AS = appearanceId; + options.V = appearanceId; + + } else { + options.AS = "Off"; + options.V = "Off"; + } + + delete options.selected; + delete options.fontSize; + + let resolvedType; + switch (type) { + case 'radioChild': + resolvedType = 'RadioButton'; + break; + case 'checkbox': + resolvedType = 'Checkbox'; + break; + default: + break; + } + + const CA = Appearance[resolvedType].getCA(); + const AP = Appearance[resolvedType].createAppearance(this, appearanceId, dimentions); + + Object.assign(options, {MK: {CA}, AP}); + + return options; + }, + + _createAppearanceField(dimentions, stream) { + const appr = this.ref({ + Type: 'XObject', + Subtype: 'Form', + FormType: 1, + BBox: dimentions, + Resources: this.page.resources + }); + + appr.end(stream); + + return appr; + }, + + _getParentRadioField(parentName) { + const ref = this._getParent(parentName); + let groupRef; + + if (ref == null) { + groupRef = this.formField(parentName, { + FT: 'Btn', + Ff: 32768, //TODO + F: 4, + T: new String(parentName), + Kids: [], + }); + } else { + groupRef = ref; + } + + if (groupRef == null) { + throw new Error(`Unable to create parent: ${parentName}`); + } + + return groupRef; + }, + + _getParent(parentName) { + return this._root.data.AcroForm.data.Fields.filter(ref => ref.data.T != null && ref.data.T == parentName)[0]; + }, + + formRadioButton(name, x, y, w, h, options = {}) { + if (options.parentId == null) { + throw new Error(`Unable to find key 'parentId' for radio form field: ${name}`); + } + + const parentName = options.parentId; + + options.Parent = this._getParentRadioField(parentName); + + if (options.selected) { + this._getParent(parentName).data.V = name; + } + + delete options.parentId; + + return this.extendedFormAnnotation(name, 'radioChild', x, y, w, h, options, name); + }, + + formCheckbox(name, x, y, w, h, options = {}) { + return this.extendedFormAnnotation(name, 'checkbox', x, y, w, h, options, "On"); + }, + + formText(name, x, y, w, h, options = {}) { + return this.extendedFormAnnotation(name, 'text', x, y, w, h, options); + }, + + formPushButton(name, x, y, w, h, options = {}) { + return this.extendedFormAnnotation(name, 'pushButton', x, y, w, h, options); + }, + + formCombo(name, x, y, w, h, options = {}) { + return this.extendedFormAnnotation(name, 'combo', x, y, w, h, options); + }, + + formList(name, x, y, w, h, options = {}) { + return this.extendedFormAnnotation(name, 'list', x, y, w, h, options); + }, + + addFontToAcroFormDict() { + this._acroform.fonts[this._font.id] = this._font.ref(); + } +}; + +//TODO doc definition to customise this +const Appearance = { + Checkbox: { + createAppearance(pdfDocument, appearanceId, dimentions) { + const APStream = this.getTrueAPStream(pdfDocument); + return { + N: { + [appearanceId]: pdfDocument._createAppearanceField(dimentions, APStream), + "Off": pdfDocument._createAppearanceField(dimentions, APStream) + }, + //R + D: { + [appearanceId]: pdfDocument._createAppearanceField(dimentions, APStream), + "Off": pdfDocument._createAppearanceField(dimentions, APStream) + } + }; + }, + getTrueAPStream(pdfDocument) { + return ` +q +0 0 1 rg +BT + /${pdfDocument._font.id} ${pdfDocument._fontSize} Tf + 0 0 Td + (${this.getCA()}) Tj +ET +Q`; + }, + getCA() { + return new String(3); + } + }, + RadioButton: { + createAppearance(pdfDocument, appearanceId, dimentions) { + const APStream = this.getTrueAPStream(pdfDocument); + return { + N: { + [appearanceId]: pdfDocument._createAppearanceField(dimentions, APStream), + "Off": pdfDocument._createAppearanceField(dimentions, APStream) + }, + //R + D: { + [appearanceId]: pdfDocument._createAppearanceField(dimentions, APStream), + "Off": pdfDocument._createAppearanceField(dimentions, APStream) + } + }; + }, + getTrueAPStream(pdfDocument) { + return ` +q +0 0 1 rg +BT + /${pdfDocument._font.id} ${pdfDocument._fontSize} Tf + 0 0 Td + (${this.getCA()}) Tj +ET +Q`; + }, + getCA() { + return new String(8); + } + } +}; + +export default ExtendedAcroFormMixin; diff --git a/src/pdf-kit-extensions/PDFEmbeddedFont.js b/src/pdf-kit-extensions/PDFEmbeddedFont.js new file mode 100644 index 000000000..ed5bfa5a7 --- /dev/null +++ b/src/pdf-kit-extensions/PDFEmbeddedFont.js @@ -0,0 +1,321 @@ +var fs = require('fs'); +var fontkit = require('@foliojs-fork/fontkit'); + +class PDFEmbeddedFont { + constructor(document, src, id) { + this.document = document; + this.src = src; + this.dictionary = document.dictionary; + + if (typeof src === 'string') { + this.font = fontkit.create(fs.readFileSync(src)); + } else if (Buffer.isBuffer(src)) { + this.font = fontkit.create(src); + } else { + this.font = src; + } + + this.id = id; + this.name = this.font.postscriptName; + this.scale = 1000 / this.font.unitsPerEm; + + this.unicode = {}; + this.widths = [this.font.getGlyph(0).advanceWidth]; + + this.font.characterSet.forEach(codePoint => { + if (this.font.hasGlyphForCodePoint(codePoint)) { + const glyph = this.font.glyphForCodePoint(codePoint); + this.unicode[glyph.id] = ([codePoint]); + this.widths[glyph.id] = (glyph.advanceWidth * this.scale); + } + }); + + this.ascender = this.font.ascent * this.scale; + this.descender = this.font.descent * this.scale; + this.xHeight = this.font.xHeight * this.scale; + this.capHeight = this.font.capHeight * this.scale; + this.lineGap = this.font.lineGap * this.scale; + this.bbox = this.font.bbox; + + if (document.options.fontLayoutCache !== false) { + this.layoutCache = Object.create(null); + } + } + + layoutRun(text, features) { + /** + * TODO: + * font layout returns a single unicode point, instead of its substituions, line 25 + * liga false but can be overriden + */ + const run = this.font.layout(text, {liga: false,...features}); // Normalize position values + + for (let i = 0; i < run.positions.length; i++) { + const position = run.positions[i]; + + for (let key in position) { + position[key] *= this.scale; + } + + position.advanceWidth = run.glyphs[i].advanceWidth * this.scale; + } + + return run; + } + + layoutCached(text) { + if (!this.layoutCache) { + return this.layoutRun(text); + } + + let cached = this.layoutCache[text]; + + if (cached) { + return cached; + } + + const run = this.layoutRun(text); + this.layoutCache[text] = run; + return run; + } + + layout(text, features, onlyWidth) { + // Skip the cache if any user defined features are applied + if (features) { + return this.layoutRun(text, features); + } + + let glyphs = onlyWidth ? null : []; + let positions = onlyWidth ? null : []; + let advanceWidth = 0; // Split the string by words to increase cache efficiency. + // For this purpose, spaces and tabs are a good enough delimeter. + + let last = 0; + let index = 0; + + while (index <= text.length) { + var needle; + + if (index === text.length && last < index || (needle = text.charAt(index), [' ', '\t'].includes(needle))) { + const run = this.layoutCached(text.slice(last, ++index)); + + if (!onlyWidth) { + glyphs = glyphs.concat(run.glyphs); + positions = positions.concat(run.positions); + } + + advanceWidth += run.advanceWidth; + last = index; + } else { + index++; + } + } + + return { + glyphs, + positions, + advanceWidth + }; + } + + ref() { + return this.dictionary != null ? this.dictionary : this.dictionary = this.document.ref(); + } + + finalize() { + if (this.embedded || this.dictionary == null) { + return; + } + + this.embed(); + return this.embedded = true; + } + + encode(text, features) { + const { + glyphs, + positions + } = this.layout(text, features); + + const res = []; + + for (let i = 0; i < glyphs.length; i++) { + const glyph = glyphs[i]; + const gid = glyph.id; + + res.push(`0000${gid.toString(16)}`.slice(-4)); + } + + return [res, positions]; + } + + widthOfString(string, size, features) { + const width = this.layout(string, features, true).advanceWidth; + const scale = size / 1000; + return width * scale; + } + + embed() { + const isCFF = this.font.cff != null; + const fontFile = this.document.ref(); + + if (isCFF) { + fontFile.data.Subtype = 'CIDFontType0C'; + } + + fs.createReadStream(this.src) + .on("data", function (data) { + fontFile.write(data); + }) + .on("end", function () { + fontFile.end(); + }); + + const familyClass = ((this.font['OS/2'] != null ? this.font['OS/2'].sFamilyClass : undefined) || 0) >> 8; + let flags = 0; + + if (this.font.post.isFixedPitch) { + flags |= 1 << 0; + } + + if (1 <= familyClass && familyClass <= 7) { + flags |= 1 << 1; + } + + flags |= 1 << 2; // assume the font uses non-latin characters + + if (familyClass === 10) { + flags |= 1 << 3; + } + + if (this.font.head.macStyle.italic) { + flags |= 1 << 6; + } // generate a tag (6 uppercase letters. 17 is the char code offset from '0' to 'A'. 73 will map to 'Z') + + const name = this.font.postscriptName; + + const { + bbox + } = this.font; + const descriptor = this.document.ref({ + Type: 'FontDescriptor', + FontName: name, + Flags: flags, + FontBBox: [bbox.minX * this.scale, bbox.minY * this.scale, bbox.maxX * this.scale, bbox.maxY * this.scale], + ItalicAngle: this.font.italicAngle, + Ascent: this.ascender, + Descent: this.descender, + CapHeight: (this.font.capHeight || this.font.ascent) * this.scale, + XHeight: (this.font.xHeight || 0) * this.scale, + StemV: 0 + }); // not sure how to calculate this + + if (isCFF) { + descriptor.data.FontFile3 = fontFile; + } else { + descriptor.data.FontFile2 = fontFile; + } + + descriptor.end(); + const descendantFontData = { + Type: 'Font', + Subtype: 'CIDFontType0', + BaseFont: name, + CIDSystemInfo: { + Registry: new String('Adobe'), + Ordering: new String('Identity'), + Supplement: 0 + }, + FontDescriptor: descriptor, + W: [0, this.widths] + }; + + if (!isCFF) { + descendantFontData.Subtype = 'CIDFontType2'; + descendantFontData.CIDToGIDMap = 'Identity'; + } + + const descendantFont = this.document.ref(descendantFontData); + descendantFont.end(); + this.dictionary.data = { + Type: 'Font', + Subtype: 'Type0', + BaseFont: name, + Encoding: 'Identity-H', + DescendantFonts: [descendantFont], + ToUnicode: this.toUnicodeCmap() + }; + + return this.dictionary.end(); + } + + lineHeight(size, includeGap) { + if (includeGap == null) { + includeGap = false; + } + + const gap = includeGap ? this.lineGap : 0; + return (this.ascender + gap - this.descender) / 1000 * size; + } + + + toUnicodeCmap() { + const cmap = this.document.ref(); + const characters = () => { + let entries = ""; + + const toHex = function (num) { + return `0000${num.toString(16)}`.slice(-4); + }; + + for (let key of Object.keys(this.unicode)) { + const codePoints = this.unicode[key]; + const encoded = []; // encode codePoints to utf16 + + for (let value of codePoints) { + if (value > 0xffff) { + value -= 0x10000; + encoded.push(toHex(value >>> 10 & 0x3ff | 0xd800)); + value = 0xdc00 | value & 0x3ff; + } + + encoded.push(toHex(value)); + } + const srcCode = toHex(parseInt(key)); + const dstString = encoded.join(' '); + + //TODO: not good + entries += `<${srcCode}> <${dstString}>\n`; + } + + return entries; + }; + + cmap.end(`\ +/CIDInit /ProcSet findresource begin +12 dict begin +begincmap +/CIDSystemInfo << + /Registry (Adobe) + /Ordering (UCS) + /Supplement 0 +>> def +/CMapName /Adobe-Identity-UCS def +/CMapType 2 def +1 begincodespacerange +<0000> +endcodespacerange +1 beginbfchar +${characters()} +endbfchar +endcmap +CMapName currentdict /CMap defineresource pop +end +end\ +`); + + return cmap; + } +} + +export default PDFEmbeddedFont; \ No newline at end of file diff --git a/src/pdf-kit-extensions/StandardFonts.js b/src/pdf-kit-extensions/StandardFonts.js new file mode 100644 index 000000000..8d8a77785 --- /dev/null +++ b/src/pdf-kit-extensions/StandardFonts.js @@ -0,0 +1,64 @@ +var fs = require('fs'); + +export const isStandardFont = (name) => { + return name in STANDARD_FONTS; +}; + +export const STANDARD_FONTS = { + Courier() { + return fs.readFileSync(__dirname + '/data/Courier.afm', 'utf8'); + }, + + 'Courier-Bold'() { + return fs.readFileSync(__dirname + '/data/Courier-Bold.afm', 'utf8'); + }, + + 'Courier-Oblique'() { + return fs.readFileSync(__dirname + '/data/Courier-Oblique.afm', 'utf8'); + }, + + 'Courier-BoldOblique'() { + return fs.readFileSync(__dirname + '/data/Courier-BoldOblique.afm', 'utf8'); + }, + + Helvetica() { + return fs.readFileSync(__dirname + '/data/Helvetica.afm', 'utf8'); + }, + + 'Helvetica-Bold'() { + return fs.readFileSync(__dirname + '/data/Helvetica-Bold.afm', 'utf8'); + }, + + 'Helvetica-Oblique'() { + return fs.readFileSync(__dirname + '/data/Helvetica-Oblique.afm', 'utf8'); + }, + + 'Helvetica-BoldOblique'() { + return fs.readFileSync(__dirname + '/data/Helvetica-BoldOblique.afm', 'utf8'); + }, + + 'Times-Roman'() { + return fs.readFileSync(__dirname + '/data/Times-Roman.afm', 'utf8'); + }, + + 'Times-Bold'() { + return fs.readFileSync(__dirname + '/data/Times-Bold.afm', 'utf8'); + }, + + 'Times-Italic'() { + return fs.readFileSync(__dirname + '/data/Times-Italic.afm', 'utf8'); + }, + + 'Times-BoldItalic'() { + return fs.readFileSync(__dirname + '/data/Times-BoldItalic.afm', 'utf8'); + }, + + Symbol() { + return fs.readFileSync(__dirname + '/data/Symbol.afm', 'utf8'); + }, + + ZapfDingbats() { + return fs.readFileSync(__dirname + '/data/ZapfDingbats.afm', 'utf8'); + } + +}; \ No newline at end of file