diff --git a/.gitignore b/.gitignore index e86268469..aa6c5ffec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.vscode .idea .DS_Store node_modules/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/src/libs/fontFace.js b/src/libs/fontFace.js new file mode 100644 index 000000000..f39712c35 --- /dev/null +++ b/src/libs/fontFace.js @@ -0,0 +1,391 @@ +function toLookup(arr) { + return arr.reduce(function(lookup, name, index) { + lookup[name] = index; + + return lookup; + }, {}); +} + +var fontStyleOrder = { + italic: ["italic", "oblique", "normal"], + oblique: ["oblique", "italic", "normal"], + normal: ["normal", "oblique", "italic"] +}; + +var fontStretchOrder = [ + "ultra-condensed", + "extra-condensed", + "condensed", + "semi-condensed", + "normal", + "semi-expanded", + "expanded", + "extra-expanded", + "ultra-expanded" +]; + +// For a given font-stretch value, we need to know where to start our search +// from in the fontStretchOrder list. +var fontStretchLookup = toLookup(fontStretchOrder); + +var fontWeights = [100, 200, 300, 400, 500, 600, 700, 800, 900]; +var fontWeightsLookup = toLookup(fontWeights); + +function normalizeFontStretch(stretch) { + stretch = stretch || "normal"; + + return typeof fontStretchLookup[stretch] === "number" ? stretch : "normal"; +} + +function normalizeFontStyle(style) { + style = style || "normal"; + + return fontStyleOrder[style] ? style : "normal"; +} + +function normalizeFontWeight(weight) { + if (!weight) { + return 400; + } + + if (typeof weight === "number") { + // Ignore values which aren't valid font-weights. + return weight >= 100 && weight <= 900 && weight % 100 === 0 ? weight : 400; + } + + if (/^\d00$/.test(weight)) { + return parseInt(weight); + } + + switch (weight) { + case "bold": + return 700; + + case "normal": + default: + return 400; + } +} + +export function normalizeFontFace(fontFace) { + var family = fontFace.family.replace(/"|'/g, "").toLowerCase(); + + var style = normalizeFontStyle(fontFace.style); + var weight = normalizeFontWeight(fontFace.weight); + var stretch = normalizeFontStretch(fontFace.stretch); + + return { + family: family, + style: style, + weight: weight, + stretch: stretch, + src: fontFace.src || [], + + // The ref property maps this font-face to the font + // added by the .addFont() method. + ref: fontFace.ref || { + name: family, + style: [stretch, style, weight].join(" ") + } + }; +} + +/** + * Turns a list of font-faces into a map, for easier lookup when resolving + * fonts. + * */ +export function buildFontFaceMap(fontFaces) { + var map = {}; + + for (var i = 0; i < fontFaces.length; ++i) { + var normalized = normalizeFontFace(fontFaces[i]); + + var name = normalized.family; + var stretch = normalized.stretch; + var style = normalized.style; + var weight = normalized.weight; + + map[name] = map[name] || {}; + + map[name][stretch] = map[name][stretch] || {}; + map[name][stretch][style] = map[name][stretch][style] || {}; + map[name][stretch][style][weight] = normalized; + } + + return map; +} + +/** + * Searches a map of stretches, weights, etc. in the given direction and + * then, if no match has been found, in the opposite directions. + * + * @param {Object.} matchingSet A map of the various font variations. + * @param {any[]} order The order of the different variations + * @param {number} pivot The starting point of the search in the order list. + * @param {-1 | 1} dir The initial direction of the search (desc = -1, asc = 1) + */ + +function searchFromPivot(matchingSet, order, pivot, dir) { + var i; + + for (i = pivot; i >= 0 && i < order.length; i += dir) { + if (matchingSet[order[i]]) { + return matchingSet[order[i]]; + } + } + + for (i = pivot; i >= 0 && i < order.length; i -= dir) { + if (matchingSet[order[i]]) { + return matchingSet[order[i]]; + } + } +} + +function resolveFontStretch(stretch, matchingSet) { + if (matchingSet[stretch]) { + return matchingSet[stretch]; + } + + var pivot = fontStretchLookup[stretch]; + + // If the font-stretch value is normal or more condensed, we want to + // start with a descending search, otherwise we should do ascending. + var dir = pivot <= fontStretchLookup["normal"] ? -1 : 1; + var match = searchFromPivot(matchingSet, fontStretchOrder, pivot, dir); + + if (!match) { + // Since a font-family cannot exist without having at least one stretch value + // we should never reach this point. + throw new Error( + "Could not find a matching font-stretch value for " + stretch + ); + } + + return match; +} + +function resolveFontStyle(fontStyle, matchingSet) { + if (matchingSet[fontStyle]) { + return matchingSet[fontStyle]; + } + + var ordering = fontStyleOrder[fontStyle]; + + for (var i = 0; i < ordering.length; ++i) { + if (matchingSet[ordering[i]]) { + return matchingSet[ordering[i]]; + } + } + + // Since a font-family cannot exist without having at least one style value + // we should never reach this point. + throw new Error("Could not find a matching font-style for " + fontStyle); +} + +function resolveFontWeight(weight, matchingSet) { + if (matchingSet[weight]) { + return matchingSet[weight]; + } + + if (weight === 400 && matchingSet[500]) { + return matchingSet[500]; + } + + if (weight === 500 && matchingSet[400]) { + return matchingSet[400]; + } + + var pivot = fontWeightsLookup[weight]; + + // If the font-stretch value is normal or more condensed, we want to + // start with a descending search, otherwise we should do ascending. + var dir = weight < 400 ? -1 : 1; + var match = searchFromPivot(matchingSet, fontWeights, pivot, dir); + + if (!match) { + // Since a font-family cannot exist without having at least one stretch value + // we should never reach this point. + throw new Error( + "Could not find a matching font-weight for value " + weight + ); + } + + return match; +} + +var defaultGenericFontFamilies = { + "sans-serif": "helvetica", + fixed: "courier", + monospace: "courier", + terminal: "courier", + cursive: "times", + fantasy: "times", + serif: "times" +}; + +var systemFonts = { + caption: "times", + icon: "times", + menu: "times", + "message-box": "times", + "small-caption": "times", + "status-bar": "times" +}; + +function ruleToString(rule) { + return [rule.stretch, rule.style, rule.weight, rule.family].join(" "); +} + +export function resolveFontFace(fontFaceMap, rules, opts) { + opts = opts || {}; + + var defaultFontFamily = opts.defaultFontFamily || "times"; + var genericFontFamilies = Object.assign( + {}, + defaultGenericFontFamilies, + opts.genericFontFamilies || {} + ); + + var rule = null; + var matches = null; + + for (var i = 0; i < rules.length; ++i) { + rule = normalizeFontFace(rules[i]); + + if (genericFontFamilies[rule.family]) { + rule.family = genericFontFamilies[rule.family]; + } + + if (fontFaceMap.hasOwnProperty(rule.family)) { + matches = fontFaceMap[rule.family]; + + break; + } + } + + // Always fallback to a known font family. + matches = matches || fontFaceMap[defaultFontFamily]; + + if (!matches) { + // At this point we should definitiely have a font family, but if we + // don't there is something wrong with our configuration + throw new Error( + "Could not find a font-family for the rule '" + + ruleToString(rule) + + "' and default family '" + + defaultFontFamily + + "'." + ); + } + + matches = resolveFontStretch(rule.stretch, matches); + matches = resolveFontStyle(rule.style, matches); + matches = resolveFontWeight(rule.weight, matches); + + if (!matches) { + // We should've fount + throw new Error( + "Failed to resolve a font for the rule '" + ruleToString(rule) + "'." + ); + } + + return matches; +} + +/** + * Builds a style id for use with the addFont() method. + * @param {FontFace} font + */ +export function toStyleName(font) { + return [font.weight, font.style, font.stretch].join(" "); +} + +function eatWhiteSpace(input) { + return input.trimLeft(); +} + +function parseQuotedFontFamily(input, quote) { + var index = 0; + + while (index < input.length) { + var current = input.charAt(index); + + if (current === quote) { + return [input.substring(0, index), input.substring(index + 1)]; + } + + index += 1; + } + + // Unexpected end of input + return null; +} + +function parseNonQuotedFontFamily(input) { + // It implements part of the identifier parser here: https://www.w3.org/TR/CSS21/syndata.html#value-def-identifier + // + // NOTE: This parser pretty much ignores escaped identifiers and that there is a thing called unicode. + // + // Breakdown of regexp: + // -[a-z_] - when identifier starts with a hyphen, you're not allowed to have another hyphen or a digit + // [a-z_] - allow a-z and underscore at beginning of input + // [a-z0-9_-]* - after that, anything goes + var match = input.match(/^(-[a-z_]|[a-z_])[a-z0-9_-]*/i); + + // non quoted value contains illegal characters + if (match === null) { + return null; + } + + return [match[0], input.substring(match[0].length)]; +} + +var defaultFont = ["times"]; + +export function parseFontFamily(input) { + var result = []; + var ch, parsed; + var remaining = input.trim(); + + if (remaining === "") { + return defaultFont; + } + + if (remaining in systemFonts) { + return [systemFonts[remaining]]; + } + + while (remaining !== "") { + parsed = null; + remaining = eatWhiteSpace(remaining); + ch = remaining.charAt(0); + + switch (ch) { + case '"': + case "'": + parsed = parseQuotedFontFamily(remaining.substring(1), ch); + break; + + default: + parsed = parseNonQuotedFontFamily(remaining); + break; + } + + if (parsed === null) { + return defaultFont; + } + + result.push(parsed[0]); + + remaining = eatWhiteSpace(parsed[1]); + + // We expect end of input or a comma separator here + if (remaining !== "" && remaining.charAt(0) !== ",") { + return defaultFont; + } + + remaining = remaining.replace(/^,/, ""); + } + + return result; +} diff --git a/src/modules/context2d.js b/src/modules/context2d.js index 6bd5094e9..495992797 100644 --- a/src/modules/context2d.js +++ b/src/modules/context2d.js @@ -10,6 +10,11 @@ import { jsPDF } from "../jspdf.js"; import { RGBColor } from "../libs/rgbcolor.js"; import { console } from "../libs/console.js"; +import { + buildFontFaceMap, + parseFontFamily, + resolveFontFace +} from "../libs/fontFace.js"; /** * This plugin mimics the HTML5 CanvasRenderingContext2D. @@ -397,6 +402,94 @@ import { console } from "../libs/console.js"; } }); + var _fontFaceMap = null; + + function getFontFaceMap(pdf, fontFaces) { + if (_fontFaceMap === null) { + var fontMap = pdf.getFontList(); + + var convertedFontFaces = convertToFontFaces(fontMap); + + _fontFaceMap = buildFontFaceMap(convertedFontFaces.concat(fontFaces)); + } + + return _fontFaceMap; + } + + function convertToFontFaces(fontMap) { + var fontFaces = []; + + Object.keys(fontMap).forEach(function(family) { + var styles = fontMap[family]; + + styles.forEach(function(style) { + var fontFace = null; + + switch (style) { + case "bold": + fontFace = { + family: family, + weight: "bold" + }; + break; + + case "italic": + fontFace = { + family: family, + style: "italic" + }; + break; + + case "bolditalic": + fontFace = { + family: family, + weight: "bold", + style: "italic" + }; + break; + + case "": + case "normal": + fontFace = { + family: family + }; + break; + } + + // If font-face is still null here, it is a font with some styling we don't recognize and + // cannot map or it is a font added via the fontFaces option of .html(). + if (fontFace !== null) { + fontFace.ref = { + name: family, + style: style + }; + + fontFaces.push(fontFace); + } + }); + }); + + return fontFaces; + } + + var _fontFaces = null; + /** + * A map of available font-faces, as passed in the options of + * .html(). If set a limited implementation of the font style matching + * algorithm defined by https://www.w3.org/TR/css-fonts-3/#font-matching-algorithm + * will be used. If not set it will fallback to previous behavior. + */ + + Object.defineProperty(this, "fontFaces", { + get: function() { + return _fontFaces; + }, + set: function(value) { + _fontFaceMap = null; + _fontFaces = value; + } + }); + Object.defineProperty(this, "font", { get: function() { return this.ctx.font; @@ -435,6 +528,24 @@ import { console } from "../libs/console.js"; } this.pdf.setFontSize(fontSize); + var parts = parseFontFamily(fontFamily); + + if (this.fontFaces) { + var fontFaceMap = getFontFaceMap(this.pdf, this.fontFaces); + + var rules = parts.map(function(ff) { + return { + family: ff, + stretch: "normal", // TODO: Extract font-stretch from font rule (perhaps write proper parser for it?) + weight: fontWeight, + style: fontStyle + }; + }); + + var font = resolveFontFace(fontFaceMap, rules); + this.pdf.setFont(font.ref.name, font.ref.style); + return; + } var style = ""; if ( @@ -452,9 +563,7 @@ import { console } from "../libs/console.js"; if (style.length === 0) { style = "normal"; } - var jsPdfFontName = ""; - var parts = fontFamily.replace(/"|'/g, "").split(/\s*,\s*/); var fallbackFonts = { arial: "Helvetica", diff --git a/src/modules/html.js b/src/modules/html.js index 1cbae5bee..0c68e5ba1 100644 --- a/src/modules/html.js +++ b/src/modules/html.js @@ -8,6 +8,7 @@ */ import { jsPDF } from "../jspdf.js"; +import { normalizeFontFace } from "../libs/fontFace.js"; import { globalObject } from "../libs/globalObject.js"; /** @@ -246,7 +247,7 @@ import { globalObject } from "../libs/globalObject.js"; case "string": return "string"; case "element": - return src.nodeName.toLowerCase === "canvas" ? "canvas" : "element"; + return src.nodeName.toLowerCase() === "canvas" ? "canvas" : "element"; default: return "unknown"; } @@ -427,6 +428,7 @@ import { globalObject } from "../libs/globalObject.js"; // Handle old-fashioned 'onrendered' argument. var pdf = this.opt.jsPDF; + var fontFaces = this.opt.fontFaces; var options = Object.assign( { async: true, @@ -449,6 +451,20 @@ import { globalObject } from "../libs/globalObject.js"; pdf.context2d.autoPaging = true; pdf.context2d.posX = this.opt.x; pdf.context2d.posY = this.opt.y; + pdf.context2d.fontFaces = fontFaces; + + if (fontFaces) { + for (var i = 0; i < fontFaces.length; ++i) { + var font = fontFaces[i]; + var src = font.src.find(function(src) { + return src.format === "truetype"; + }); + + if (src) { + pdf.addFont(src.url, font.ref.name, font.ref.style); + } + } + } options.windowHeight = options.windowHeight || 0; options.windowHeight = @@ -961,6 +977,26 @@ import { globalObject } from "../libs/globalObject.js"; return info; }; + /** + * @typedef FontFace + * + * The font-face type implements an interface similar to that of the font-face CSS rule, + * and is used by jsPDF to match fonts when the font property of CanvasRenderingContext2D + * is updated. + * + * All properties expect values similar to those in the font-face CSS rule. A difference + * is the font-family, which do not need to be enclosed in double-quotes when containing + * spaces like in CSS. + * + * @property {string} family The name of the font-family. + * @property {string|undefined} style The style that this font-face defines, e.g. 'italic'. + * @property {string|number|undefined} weight The weight of the font, either as a string or a number (400, 500, 600, e.g.) + * @property {string|undefined} stretch The stretch of the font, e.g. condensed, normal, expanded. + * @property {Object[]} src A list of URLs from where fonts of various formats can be fetched. + * @property {string} [src] url A URL to a font of a specific format. + * @property {string} [src] format Format of the font referenced by the URL. + */ + /** * Generate a PDF from an HTML element or string using. * @@ -973,6 +1009,7 @@ import { globalObject } from "../libs/globalObject.js"; * @param {string} [options.filename] name of the file * @param {HTMLOptionImage} [options.image] image settings when converting HTML to image * @param {Html2CanvasOptions} [options.html2canvas] html2canvas options + * @param {FontFace[]} [options.fontFaces] A list of font-faces to match when resolving fonts. Fonts will be added to the PDF based on the specified URL. If omitted, the font match algorithm falls back to old algorithm. * @param {jsPDF} [options.jsPDF] jsPDF instance * @param {number} [options.x] x position on the PDF document * @param {number} [options.y] y position on the PDF document @@ -996,6 +1033,10 @@ import { globalObject } from "../libs/globalObject.js"; options.html2canvas = options.html2canvas || {}; options.html2canvas.canvas = options.html2canvas.canvas || this.canvas; options.jsPDF = options.jsPDF || this; + options.fontFaces = options.fontFaces + ? options.fontFaces.map(normalizeFontFace) + : null; + // Create a new worker with the given options. var worker = new Worker(options); diff --git a/test/deployment/globals/karma.conf.js b/test/deployment/globals/karma.conf.js index 9debe6785..3ead00751 100644 --- a/test/deployment/globals/karma.conf.js +++ b/test/deployment/globals/karma.conf.js @@ -12,7 +12,7 @@ module.exports = config => { "dist/polyfills.umd.js", "node_modules/regenerator-runtime/runtime.js", - "dist/jspdf.umd*.js", + "dist/jspdf.umd.js", "node_modules/canvg/lib/umd.js", "node_modules/html2canvas/dist/html2canvas.js", "node_modules/dompurify/dist/purify.js", diff --git a/test/reference/fonts/Roboto/LICENSE.txt b/test/reference/fonts/Roboto/LICENSE.txt new file mode 100644 index 000000000..75b52484e --- /dev/null +++ b/test/reference/fonts/Roboto/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/test/reference/fonts/Roboto/Roboto-Black.ttf b/test/reference/fonts/Roboto/Roboto-Black.ttf new file mode 100644 index 000000000..2d4523836 Binary files /dev/null and b/test/reference/fonts/Roboto/Roboto-Black.ttf differ diff --git a/test/reference/fonts/Roboto/Roboto-BlackItalic.ttf b/test/reference/fonts/Roboto/Roboto-BlackItalic.ttf new file mode 100644 index 000000000..29a4359ed Binary files /dev/null and b/test/reference/fonts/Roboto/Roboto-BlackItalic.ttf differ diff --git a/test/reference/fonts/Roboto/Roboto-Bold.ttf b/test/reference/fonts/Roboto/Roboto-Bold.ttf new file mode 100644 index 000000000..d998cf5b4 Binary files /dev/null and b/test/reference/fonts/Roboto/Roboto-Bold.ttf differ diff --git a/test/reference/fonts/Roboto/Roboto-BoldItalic.ttf b/test/reference/fonts/Roboto/Roboto-BoldItalic.ttf new file mode 100644 index 000000000..b4e221039 Binary files /dev/null and b/test/reference/fonts/Roboto/Roboto-BoldItalic.ttf differ diff --git a/test/reference/fonts/Roboto/Roboto-Italic.ttf b/test/reference/fonts/Roboto/Roboto-Italic.ttf new file mode 100644 index 000000000..5b390ff95 Binary files /dev/null and b/test/reference/fonts/Roboto/Roboto-Italic.ttf differ diff --git a/test/reference/fonts/Roboto/Roboto-Light.ttf b/test/reference/fonts/Roboto/Roboto-Light.ttf new file mode 100644 index 000000000..35267989d Binary files /dev/null and b/test/reference/fonts/Roboto/Roboto-Light.ttf differ diff --git a/test/reference/fonts/Roboto/Roboto-LightItalic.ttf b/test/reference/fonts/Roboto/Roboto-LightItalic.ttf new file mode 100644 index 000000000..46e9bf7c9 Binary files /dev/null and b/test/reference/fonts/Roboto/Roboto-LightItalic.ttf differ diff --git a/test/reference/fonts/Roboto/Roboto-Medium.ttf b/test/reference/fonts/Roboto/Roboto-Medium.ttf new file mode 100644 index 000000000..f714a514d Binary files /dev/null and b/test/reference/fonts/Roboto/Roboto-Medium.ttf differ diff --git a/test/reference/fonts/Roboto/Roboto-MediumItalic.ttf b/test/reference/fonts/Roboto/Roboto-MediumItalic.ttf new file mode 100644 index 000000000..5dc6a2dc6 Binary files /dev/null and b/test/reference/fonts/Roboto/Roboto-MediumItalic.ttf differ diff --git a/test/reference/fonts/Roboto/Roboto-Regular.ttf b/test/reference/fonts/Roboto/Roboto-Regular.ttf new file mode 100644 index 000000000..2b6392ffe Binary files /dev/null and b/test/reference/fonts/Roboto/Roboto-Regular.ttf differ diff --git a/test/reference/fonts/Roboto/Roboto-Thin.ttf b/test/reference/fonts/Roboto/Roboto-Thin.ttf new file mode 100644 index 000000000..4e797cf7e Binary files /dev/null and b/test/reference/fonts/Roboto/Roboto-Thin.ttf differ diff --git a/test/reference/fonts/Roboto/Roboto-ThinItalic.ttf b/test/reference/fonts/Roboto/Roboto-ThinItalic.ttf new file mode 100644 index 000000000..eea836f4a Binary files /dev/null and b/test/reference/fonts/Roboto/Roboto-ThinItalic.ttf differ diff --git a/test/reference/html-font-faces.pdf b/test/reference/html-font-faces.pdf new file mode 100644 index 000000000..dc68c7933 Binary files /dev/null and b/test/reference/html-font-faces.pdf differ diff --git a/test/specs/fontfaces.spec.mjs b/test/specs/fontfaces.spec.mjs new file mode 100644 index 000000000..960b112ed --- /dev/null +++ b/test/specs/fontfaces.spec.mjs @@ -0,0 +1,451 @@ +import { + resolveFontFace, + buildFontFaceMap, + normalizeFontFace, + parseFontFamily +} from "../../src/libs/fontFace.js"; + +function fontFace(opts) { + return { family: "TestFont", src: undefined, ...opts }; +} + +function merge(...args) { + return Object.assign({}, ...args); +} + +const ultraCondensed = fontFace({ stretch: "ultra-condensed" }); +const semiCondensed = fontFace({ stretch: "semi-condensed" }); +const condensed = fontFace({ stretch: "condensed" }); +const expanded = fontFace({ stretch: "ultra-expanded" }); +const semiExpanded = fontFace({ stretch: "semi-expanded" }); +const ultraExpanded = fontFace({ stretch: "ultra-expanded" }); + +const italic = fontFace({ style: "italic" }); +const oblique = fontFace({ style: "oblique" }); +const normal = fontFace({ style: "normal" }); + +const w100 = fontFace({ weight: 100 }); +const w200 = fontFace({ weight: 200 }); +const w300 = fontFace({ weight: 300 }); +const w400 = fontFace({ weight: 400 }); +const w500 = fontFace({ weight: 500 }); +const w600 = fontFace({ weight: 600 }); +const w700 = fontFace({ weight: 700 }); +const w800 = fontFace({ weight: 800 }); +const w900 = fontFace({ weight: 900 }); +const wnormal = fontFace({ weight: "normal" }); +const wbold = fontFace({ weight: "bold" }); + +describe("font-face", () => { + describe("stretch", () => { + describe("condensed", () => { + it("should pick exact match", () => { + const fontFaces = buildFontFaceMap([ + condensed, + expanded, + ultraCondensed + ]); + + const result = resolveFontFace(fontFaces, [condensed]); + + expect(result).toEqual(normalizeFontFace(condensed)); + }); + + it("should pick more condensed font if exact match not available", () => { + const fontFaces = buildFontFaceMap([ultraCondensed, semiExpanded]); + + const result = resolveFontFace(fontFaces, [semiCondensed]); + + expect(result).toEqual(normalizeFontFace(ultraCondensed)); + }); + + it("should pick expanded font if more condensed font does not exist", () => { + const fontFaces = buildFontFaceMap([ultraExpanded]); + + const result = resolveFontFace(fontFaces, [semiCondensed]); + + expect(result).toEqual(normalizeFontFace(ultraExpanded)); + }); + }); + + describe("expanded", () => { + it("should pick exact match", () => { + const fontFaces = buildFontFaceMap([ + expanded, + ultraExpanded, + ultraCondensed + ]); + + const result = resolveFontFace(fontFaces, [expanded]); + + expect(result).toEqual(normalizeFontFace(expanded)); + }); + + it("should pick more expanded font if exact match not available", () => { + const fontFaces = buildFontFaceMap([ultraExpanded]); + + const result = resolveFontFace(fontFaces, [semiExpanded]); + + expect(result).toEqual(normalizeFontFace(ultraExpanded)); + }); + + it("should pick condensed font if more condensed does not exist", () => { + const fontFaces = buildFontFaceMap([ultraCondensed]); + + const result = resolveFontFace(fontFaces, [semiExpanded]); + + expect(result).toEqual(normalizeFontFace(ultraCondensed)); + }); + }); + + describe("precedence", () => { + it("should prefer matching stretch over matching style and weight", () => { + const fontFaces = buildFontFaceMap([condensed, italic, wbold]); + + const result = resolveFontFace(fontFaces, [ + merge(condensed, italic, wbold) + ]); + + expect(result).toEqual(normalizeFontFace(condensed)); + }); + }); + }); + + describe("style", () => { + describe("italic", () => { + it("should pick italic when exact match is available", () => { + const fontFaces = buildFontFaceMap([oblique, italic, normal]); + + const result = resolveFontFace(fontFaces, [italic]); + + expect(result).toEqual(normalizeFontFace(italic)); + }); + + it("should prefer oblique over normal", () => { + const fontFaces = buildFontFaceMap([oblique, normal]); + + const result = resolveFontFace(fontFaces, [italic]); + + expect(result).toEqual(normalizeFontFace(oblique)); + }); + + it("should use normal when neither italic or oblique is available", () => { + const fontFaces = buildFontFaceMap([normal]); + + const result = resolveFontFace(fontFaces, [italic]); + + expect(result).toEqual(normalizeFontFace(normal)); + }); + }); + + describe("oblique", () => { + it("should pick oblique when exact match is available", () => { + const fontFaces = buildFontFaceMap([italic, oblique, normal]); + + const result = resolveFontFace(fontFaces, [oblique]); + + expect(result).toEqual(normalizeFontFace(oblique)); + }); + + it("should prefer italic over normal", () => { + const fontFaces = buildFontFaceMap([italic, normal]); + + const result = resolveFontFace(fontFaces, [oblique]); + + expect(result).toEqual(normalizeFontFace(italic)); + }); + + it("should use normal when neither italic or oblique is available", () => { + const fontFaces = buildFontFaceMap([normal]); + + const result = resolveFontFace(fontFaces, [oblique]); + + expect(result).toEqual(normalizeFontFace(normal)); + }); + }); + + describe("normal", () => { + it("should pick normal when exact match is available", () => { + const fontFaces = buildFontFaceMap([italic, oblique, normal]); + + const result = resolveFontFace(fontFaces, [normal]); + + expect(result).toEqual(normalizeFontFace(normal)); + }); + + it("should prefer oblique over italic", () => { + const fontFaces = buildFontFaceMap([italic, oblique]); + + const result = resolveFontFace(fontFaces, [normal]); + + expect(result).toEqual(normalizeFontFace(oblique)); + }); + + it("should use italic when neither normal or oblique is available", () => { + const fontFaces = buildFontFaceMap([italic]); + + const result = resolveFontFace(fontFaces, [normal]); + + expect(result).toEqual(normalizeFontFace(italic)); + }); + }); + }); + + describe("font weight", () => { + describe("400 and 500", () => { + it("should match exact weight when 400", () => { + const fontFaces = buildFontFaceMap([w300, w400, w500]); + + const result = resolveFontFace(fontFaces, [w400]); + + expect(result).toEqual(normalizeFontFace(w400)); + }); + + it("should match exact weight when 500", () => { + const fontFaces = buildFontFaceMap([w300, w400, w500]); + + const result = resolveFontFace(fontFaces, [w500]); + + expect(result).toEqual(normalizeFontFace(w500)); + }); + + it("should try font-weight 500 first when desired weight 400 is not available", () => { + const fontFaces = buildFontFaceMap([w300, w500]); + + const result = resolveFontFace(fontFaces, [w400]); + + expect(result).toEqual(normalizeFontFace(w500)); + }); + + it("should pick smaller font-weight when desired weight is 400 and 500 is not available", () => { + const fontFaces = buildFontFaceMap([w100]); + + const result = resolveFontFace(fontFaces, [w400]); + + expect(result).toEqual(normalizeFontFace(w100)); + }); + + it("should pick larger font-weight when no smaller is available", () => { + const fontFaces = buildFontFaceMap([w900]); + + const result = resolveFontFace(fontFaces, [w400]); + + expect(result).toEqual(normalizeFontFace(w900)); + }); + + it("should try font-weight 400 first when desired weight 500 is not available", () => { + const fontFaces = buildFontFaceMap([w600, w400]); + + const result = resolveFontFace(fontFaces, [w500]); + + expect(result).toEqual(normalizeFontFace(w400)); + }); + + it("should pick larger font-weight when desired weight is 500 and 400 is not available", () => { + const fontFaces = buildFontFaceMap([w900]); + + const result = resolveFontFace(fontFaces, [w500]); + + expect(result).toEqual(normalizeFontFace(w900)); + }); + + it("should pick smaller font-weight when no larger is available", () => { + const fontFaces = buildFontFaceMap([w100]); + + const result = resolveFontFace(fontFaces, [w500]); + + expect(result).toEqual(normalizeFontFace(w100)); + }); + }); + + describe("bold and normal", () => { + it("should resolve normal to font-weight 400", () => { + const fontFaces = buildFontFaceMap([w300, w400, w700]); + + const result = resolveFontFace(fontFaces, [wnormal]); + + expect(result).toEqual(normalizeFontFace(w400)); + }); + + it("should resolve bold to font-weight 700", () => { + const fontFaces = buildFontFaceMap([w300, w400, w700]); + + const result = resolveFontFace(fontFaces, [wbold]); + + expect(result).toEqual(normalizeFontFace(w700)); + }); + }); + + describe("weights below 400", () => { + it("should resolve to exact font weight", () => { + const fontFaces = buildFontFaceMap([w100, w300]); + + const result = resolveFontFace(fontFaces, [w300]); + + expect(result).toEqual(normalizeFontFace(w300)); + }); + + it("should pick a smaller font-weight when exact match is not available", () => { + const fontFaces = buildFontFaceMap([w700, w200]); + + const result = resolveFontFace(fontFaces, [w300]); + + expect(result).toEqual(normalizeFontFace(w200)); + }); + + it("should pick a larger font-weight when no smaller value is available", () => { + const fontFaces = buildFontFaceMap([w400]); + + const result = resolveFontFace(fontFaces, [w300]); + + expect(result).toEqual(normalizeFontFace(w400)); + }); + }); + + describe("weights above 400", () => { + it("should resolve to exact font weight", () => { + const fontFaces = buildFontFaceMap([w600, w800]); + + const result = resolveFontFace(fontFaces, [w600]); + + expect(result).toEqual(normalizeFontFace(w600)); + }); + + it("should pick a larger font-weight when exact match is not available", () => { + const fontFaces = buildFontFaceMap([w700, w200]); + + const result = resolveFontFace(fontFaces, [w600]); + + expect(result).toEqual(normalizeFontFace(w700)); + }); + + it("should pick a smaller font-weight when no larger value is available", () => { + const fontFaces = buildFontFaceMap([w500]); + + const result = resolveFontFace(fontFaces, [w600]); + + expect(result).toEqual(normalizeFontFace(w500)); + }); + }); + }); + + describe("generic font-faces", () => { + it("should use a default font when there is no match", () => { + const fontFaces = buildFontFaceMap([ + fontFace({ family: "first" }), + fontFace({ family: "second" }), + fontFace({ family: "third" }), + fontFace({ family: "times" }) + ]); + + const result = resolveFontFace( + fontFaces, + [fontFace({ family: "nope" })], + { defaultFontFamily: "third" } + ); + + expect(result).toEqual(normalizeFontFace(fontFace({ family: "third" }))); + }); + + const genericFontFamilies = { + "sans-serif": "actual-sans-serif", + fixed: "actual-fixed", + monospace: "actual-monospace", + terminal: "actual-terminal", + cursive: "actual-cursive", + fantasy: "actual-fantasy", + serif: "actual-serif" + }; + + Object.keys(genericFontFamilies).forEach(family => { + it("should match generic '" + family + "' font family", () => { + const fontFaces = buildFontFaceMap([ + fontFace({ family: "decoy" }), + fontFace({ family: "actual-" + family }) + ]); + + const result = resolveFontFace(fontFaces, [fontFace({ family })], { + genericFontFamilies + }); + + expect(result).toEqual( + normalizeFontFace(fontFace({ family: "actual-" + family })) + ); + }); + }); + }); + + describe("font family parser", () => { + const defaultFont = ["times"]; + + it("should return default font family on empty input", () => { + const result = parseFontFamily(" "); + + expect(result).toEqual(defaultFont); + }); + + it("should return default font family on non-sensical input", () => { + const result = parseFontFamily("@£$∞$§∞|$§∞©£@•$"); + + expect(result).toEqual(defaultFont); + }); + + it("should return default font family when font family contains illegal characters", () => { + const invalidStrs = [ + "--no-double-hyphen", + "-3no-digit-after-hypen", + "0digits", + "#£no-illegal-characters" + ]; + + const result = invalidStrs.map(parseFontFamily); + + expect(result).toEqual(invalidStrs.map(() => defaultFont)); + }); + + // If the user has specified a system font, then it's up to the user-agent to pick one. + it("should return default font if it is a system font", () => { + const systemFonts = [ + "caption", + "icon", + "menu", + "message-box", + "small-caption", + "status-bar" + ]; + + const result = systemFonts.map(parseFontFamily); + + expect(result).toEqual(systemFonts.map(() => defaultFont)); + }); + + it("should return all font families", () => { + var result = parseFontFamily( + " 'roboto sans' , \"SourceCode Pro\", Co-mP_l3x , arial, sans-serif " + ); + + expect(result).toEqual([ + "roboto sans", + "SourceCode Pro", + "Co-mP_l3x", + "arial", + "sans-serif" + ]); + }); + + it("should allow commas in font name", () => { + var result = parseFontFamily("before, 'name,with,commas', after"); + + expect(result).toEqual(["before", "name,with,commas", "after"]); + }); + + it("should return default on mismatching quotes", () => { + var result = [ + parseFontFamily("'I am not closed"), + parseFontFamily('"I am not closed either') + ]; + + expect(result).toEqual([defaultFont, defaultFont]); + }); + }); +}); diff --git a/test/specs/html.spec.js b/test/specs/html.spec.js index 579ddf6cf..9ed99e8de 100644 --- a/test/specs/html.spec.js +++ b/test/specs/html.spec.js @@ -1,4 +1,32 @@ /* global describe, it, jsPDF, comparePdf */ + +const render = (markup, opts = {}) => + new Promise(resolve => { + const doc = jsPDF({ floatPrecision: 2 }); + + doc.html(markup, { ...opts, callback: resolve }); + }); + +function toFontFaceRule(fontFace) { + const srcs = fontFace.src.map( + src => `url('${src.url}') format('${src.format}')` + ); + + const cssProps = [ + `font-family: ${fontFace.family}`, + fontFace.stretch && `font-stretch: ${fontFace.stretch}`, + fontFace.style && `font-style: ${fontFace.style}`, + fontFace.weight && `font-weight: ${fontFace.weight}`, + `src: ${srcs.join("\n")}` + ]; + + return ` + @font-face { + ${cssProps.filter(a => a).join(";\n")} + } + `; +} + describe("Module: html", function() { if ( (typeof isNode != "undefined" && isNode) || @@ -8,10 +36,122 @@ describe("Module: html", function() { } beforeAll(loadGlobals); it("html loads html2canvas asynchronously", async () => { - const doc = jsPDF({ floatPrecision: 2 }); - await new Promise(resolve => - doc.html("

Basic HTML

", { callback: resolve }) - ); + const doc = await render("

Basic HTML

"); + comparePdf(doc.output(), "html-basic.pdf", "html"); }); + + it("renders font-faces", async () => { + const opts = { + fontFaces: [ + { + family: "Roboto", + weight: 400, + src: [ + { + url: "base/test/reference/fonts/Roboto/Roboto-Regular.ttf", + format: "truetype" + } + ] + }, + { + family: "Roboto", + weight: 700, + src: [ + { + url: "base/test/reference/fonts/Roboto/Roboto-Bold.ttf", + format: "truetype" + } + ] + }, + { + family: "Roboto", + weight: "bold", + style: "italic", + src: [ + { + url: "base/test/reference/fonts/Roboto/Roboto-BoldItalic.ttf", + format: "truetype" + } + ] + }, + { + family: "Roboto", + style: "italic", + src: [ + { + url: "base/test/reference/fonts/Roboto/Roboto-Italic.ttf", + format: "truetype" + } + ] + } + ] + }; + + const doc = await render( + ` +
+ + +

+ The quick brown fox jumps over the lazy dog (default) +

+

+ The quick brown fox jumps over the lazy dog (generic) +

+

+ The quick brown fox jumps over the lazy dog (sans-serif) +

+ +

+

+ The quick brown fox jumps over the lazy dog (roboto) +

+

+ The quick brown fox jumps over the lazy dog (roboto bold) +

+

+ The quick brown fox jumps over the lazy dog (roboto italic) +

+

+ The quick brown fox jumps over the lazy dog (roboto bold italic) +

+

+
+ `, + opts + ); + + comparePdf(doc.output(), "html-font-faces.pdf", "html"); + }); }); diff --git a/test/unit/karma.conf.js b/test/unit/karma.conf.js index 2a65408c2..1843a17e7 100644 --- a/test/unit/karma.conf.js +++ b/test/unit/karma.conf.js @@ -26,7 +26,12 @@ module.exports = config => { included: true }, { - pattern: "test/reference/*.*", + pattern: "test/specs/*.spec.mjs", + included: true, + type: "module" + }, + { + pattern: "test/reference/**/*.*", included: false, served: true } diff --git a/test/utils/compare.js b/test/utils/compare.js index 08de852b4..e68e17f0e 100644 --- a/test/utils/compare.js +++ b/test/utils/compare.js @@ -79,10 +79,7 @@ function resetFile(pdfFile) { /(\/ID \[ (<[0-9a-fA-F]+> ){2}\])/, "/ID [ <00000000000000000000000000000000> <00000000000000000000000000000000> ]" ); - pdfFile = pdfFile.replace( - /\/Producer \([^)]+\)/, - "/Producer (jsPDF 0.0.0)" - ); + pdfFile = pdfFile.replace(/\/Producer \([^)]+\)/, "/Producer (jsPDF 0.0.0)"); return pdfFile; } /** diff --git a/types/index.d.ts b/types/index.d.ts index 49e436b63..827593c09 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -182,6 +182,46 @@ declare module "jspdf" { quality: number; } + export interface HTMLFontFace { + family: string; + style?: "italic" | "oblique" | "normal"; + stretch?: + | "ultra-condensed" + | "extra-condensed" + | "condensed" + | "semi-condensed" + | "normal" + | "semi-expanded" + | "expanded" + | "extra-expanded" + | "ultra-expanded"; + weight?: + | "normal" + | "bold" + | 100 + | 200 + | 300 + | 400 + | 500 + | 600 + | 700 + | 800 + | 900 + | "100" + | "200" + | "300" + | "400" + | "500" + | "600" + | "700" + | "800" + | "900"; + src: Array<{ + url: string; + format: "truetype"; + }>; + } + export interface HTMLOptions { callback?: (doc: jsPDF) => void; margin?: number | number[]; @@ -191,6 +231,7 @@ declare module "jspdf" { jsPDF?: jsPDF; x?: number; y?: number; + fontFaces?: HTMLFontFace[]; } //jsPDF plugin: viewerPreferences