diff --git a/docs/features.md b/docs/features.md index 72ea435..834631f 100644 --- a/docs/features.md +++ b/docs/features.md @@ -22,7 +22,7 @@ Below is a list of all the supported CSS properties and values. - border-width - bottom - box-sizing - - content (**Does not support `attr()`**) + - content - color - display - flex diff --git a/package.json b/package.json index d6a464e..1f7e9d7 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "homepage": "https://html2canvas.hertzen.com", "license": "MIT", "dependencies": { - "punycode": "2.1.0" + "punycode": "2.1.0", + "liststyletype-formatter": "latest" } } diff --git a/src/Clone.js b/src/Clone.js index c60ca9d..58f8aae 100644 --- a/src/Clone.js +++ b/src/Clone.js @@ -2,6 +2,7 @@ 'use strict'; import type {Bounds} from './Bounds'; import type {Options} from './index'; +import type {PseudoContentData, PseudoContentItem} from './PseudoNodeContent'; import type Logger from './Logger'; import {parseBounds} from './Bounds'; @@ -10,6 +11,12 @@ import ResourceLoader from './ResourceLoader'; import {copyCSSStyles} from './Util'; import {parseBackgroundImage} from './parsing/background'; import CanvasRenderer from './renderer/CanvasRenderer'; +import { + parseCounterReset, + popCounters, + resolvePseudoContent, + PSEUDO_CONTENT_ITEM_TYPE +} from './PseudoNodeContent'; const IGNORE_ATTRIBUTE = 'data-html2canvas-ignore'; @@ -24,6 +31,7 @@ export class DocumentCloner { inlineImages: boolean; copyStyles: boolean; renderer: (element: HTMLElement, options: Options, logger: Logger) => Promise<*>; + pseudoContentData: PseudoContentData; constructor( element: HTMLElement, @@ -40,6 +48,10 @@ export class DocumentCloner { this.options = options; this.renderer = renderer; this.resourceLoader = new ResourceLoader(options, logger, window); + this.pseudoContentData = { + counters: {}, + quoteDepth: 0 + }; // $FlowFixMe this.documentElement = this.cloneNode(element.ownerDocument.documentElement); } @@ -226,6 +238,11 @@ export class DocumentCloner { : this.createElementClone(node); const window = node.ownerDocument.defaultView; + const style = node instanceof window.HTMLElement ? window.getComputedStyle(node) : null; + const styleBefore = + node instanceof window.HTMLElement ? window.getComputedStyle(node, ':before') : null; + const styleAfter = + node instanceof window.HTMLElement ? window.getComputedStyle(node, ':after') : null; if (this.referenceElement === node && clone instanceof window.HTMLElement) { this.clonedReferenceElement = clone; @@ -235,6 +252,9 @@ export class DocumentCloner { createPseudoHideStyles(clone); } + const counters = parseCounterReset(style, this.pseudoContentData); + const contentBefore = resolvePseudoContent(node, styleBefore, this.pseudoContentData); + for (let child = node.firstChild; child; child = child.nextSibling) { if ( child.nodeType !== Node.ELEMENT_NODE || @@ -246,11 +266,23 @@ export class DocumentCloner { } } } + + const contentAfter = resolvePseudoContent(node, styleAfter, this.pseudoContentData); + popCounters(counters, this.pseudoContentData); + if (node instanceof window.HTMLElement && clone instanceof window.HTMLElement) { - this.inlineAllImages(inlinePseudoElement(node, clone, PSEUDO_BEFORE)); - this.inlineAllImages(inlinePseudoElement(node, clone, PSEUDO_AFTER)); - if (this.copyStyles && !(node instanceof HTMLIFrameElement)) { - copyCSSStyles(node.ownerDocument.defaultView.getComputedStyle(node), clone); + if (styleBefore) { + this.inlineAllImages( + inlinePseudoElement(node, clone, styleBefore, contentBefore, PSEUDO_BEFORE) + ); + } + if (styleAfter) { + this.inlineAllImages( + inlinePseudoElement(node, clone, styleAfter, contentAfter, PSEUDO_AFTER) + ); + } + if (style && this.copyStyles && !(node instanceof HTMLIFrameElement)) { + copyCSSStyles(style, clone); } this.inlineAllImages(clone); if (node.scrollTop !== 0 || node.scrollLeft !== 0) { @@ -361,9 +393,10 @@ const cloneCanvasContents = (canvas: HTMLCanvasElement, clonedCanvas: HTMLCanvas const inlinePseudoElement = ( node: HTMLElement, clone: HTMLElement, + style: CSSStyleDeclaration, + contentItems: Array, pseudoElt: ':before' | ':after' ): ?HTMLElement => { - const style = node.ownerDocument.defaultView.getComputedStyle(node, pseudoElt); if ( !style || !style.content || @@ -374,20 +407,28 @@ const inlinePseudoElement = ( return; } - const content = stripQuotes(style.content); - const image = content.match(URL_REGEXP); - const anonymousReplacedElement = clone.ownerDocument.createElement( - image ? 'img' : 'html2canvaspseudoelement' - ); - if (image) { - // $FlowFixMe - anonymousReplacedElement.src = stripQuotes(image[1]); - } else { - anonymousReplacedElement.textContent = content; - } + const anonymousReplacedElement = clone.ownerDocument.createElement('html2canvaspseudoelement'); + const len = contentItems.length; copyCSSStyles(style, anonymousReplacedElement); + for (var i = 0; i < len; i++) { + const item = contentItems[i]; + switch (item.type) { + case PSEUDO_CONTENT_ITEM_TYPE.IMAGE: + const img = clone.ownerDocument.createElement('img'); + img.src = parseBackgroundImage(`url(${item.value})`)[0].args[0]; + img.style.opacity = '1'; + anonymousReplacedElement.appendChild(img); + break; + case PSEUDO_CONTENT_ITEM_TYPE.TEXT: + anonymousReplacedElement.appendChild( + clone.ownerDocument.createTextNode(item.value) + ); + break; + } + } + anonymousReplacedElement.className = `${PSEUDO_HIDE_ELEMENT_CLASS_BEFORE} ${PSEUDO_HIDE_ELEMENT_CLASS_AFTER}`; clone.className += pseudoElt === PSEUDO_BEFORE @@ -402,13 +443,6 @@ const inlinePseudoElement = ( return anonymousReplacedElement; }; -const stripQuotes = (content: string): string => { - const first = content.substr(0, 1); - return first === content.substr(content.length - 1) && first.match(/['"]/) - ? content.substr(1, content.length - 2) - : content; -}; - const URL_REGEXP = /^url\((.+)\)$/i; const PSEUDO_BEFORE = ':before'; const PSEUDO_AFTER = ':after'; diff --git a/src/PseudoNodeContent.js b/src/PseudoNodeContent.js new file mode 100644 index 0000000..dcd6b03 --- /dev/null +++ b/src/PseudoNodeContent.js @@ -0,0 +1,340 @@ +import ListStyleTypeFormatter from 'liststyletype-formatter'; + +export const PSEUDO_CONTENT_ITEM_TYPE = { + TEXT: 0, + IMAGE: 1 +}; + +export const TOKEN_TYPE = { + STRING: 0, + ATTRIBUTE: 1, + URL: 2, + COUNTER: 3, + COUNTERS: 4, + OPENQUOTE: 5, + CLOSEQUOTE: 6 +}; + +export type PseudoContentData = { + counters: {[string]: Array}, + quoteDepth: number +}; + +export type PseudoContentItem = { + type: $Values, + value: string +}; + +export type Token = { + type: $Values, + value: ?string, + format: ?string, + glue: ?string +}; + +export const parseCounterReset = ( + style: CSSStyleDeclaration, + data: PseudoContentData +): Array => { + if (!style || !style.counterReset || style.counterReset === 'none') { + return []; + } + + const counterNames: Array = []; + const counterResets = style.counterReset.split(/\s*,\s*/); + const lenCounterResets = counterResets.length; + + for (let i = 0; i < lenCounterResets; i++) { + const [counterName, initialValue] = counterResets[i].split(/\s+/); + counterNames.push(counterName); + let counter = data.counters[counterName]; + if (!counter) { + counter = data.counters[counterName] = []; + } + counter.push(parseInt(initialValue || 0, 10)); + } + + return counterNames; +}; + +export const popCounters = (counterNames: Array, data: PseudoContentData): void => { + const lenCounters = counterNames.length; + for (let i = 0; i < lenCounters; i++) { + data.counters[counterNames[i]].pop(); + } +}; + +export const resolvePseudoContent = ( + node: Node, + style: CSSStyleDeclaration, + data: PseudoContentData +): Array => { + if ( + !style || + !style.content || + style.content === 'none' || + style.content === '-moz-alt-content' || + style.display === 'none' + ) { + return null; + } + + const tokens = parseContent(style.content); + const len = tokens.length; + const contentItems: Array = []; + let s = ''; + + // increment the counter (if there is a "counter-increment" declaration) + const counterIncrement = style.counterIncrement; + if (counterIncrement && counterIncrement !== 'none') { + const [counterName, incrementValue] = counterIncrement.split(/\s+/); + const counter = data.counters[counterName]; + if (counter) { + counter[counter.length - 1] += + incrementValue === undefined ? 1 : parseInt(incrementValue, 10); + } + } + + // build the content string + for (let i = 0; i < len; i++) { + const token = tokens[i]; + switch (token.type) { + case TOKEN_TYPE.STRING: + s += token.value; + break; + + case TOKEN_TYPE.ATTRIBUTE: + if (node instanceof HTMLElement) { + s += node.getAttribute(token.value); + } + break; + + case TOKEN_TYPE.COUNTER: + const counter = data.counters[token.name]; + if (counter) { + s += formatCounterValue([counter[counter.length - 1]], '', token.format); + } + break; + + case TOKEN_TYPE.COUNTERS: + const counters = data.counters[token.name]; + if (counters) { + s += formatCounterValue(counters, token.glue, token.format); + } + break; + + case TOKEN_TYPE.OPENQUOTE: + s += getQuote(style, true, data.quoteDepth); + data.quoteDepth++; + break; + + case TOKEN_TYPE.CLOSEQUOTE: + data.quoteDepth--; + s += getQuote(style, false, data.quoteDepth); + break; + + case TOKEN_TYPE.URL: + if (s) { + contentItems.push({type: PSEUDO_CONTENT_ITEM_TYPE.TEXT, value: s}); + s = ''; + } + contentItems.push({type: PSEUDO_CONTENT_ITEM_TYPE.IMAGE, value: token.value}); + break; + } + } + + if (s) { + contentItems.push({type: PSEUDO_CONTENT_ITEM_TYPE.TEXT, value: s}); + } + + return contentItems; +}; + +type Token = { + type: string, + value: ?string +}; + +export const parseContent = (content: string, cache: ?{[string]: string}): Array => { + if (cache && cache[content]) { + return cache[content]; + } + + const tokens: Array = []; + const len = content.length; + + let isString = false; + let isEscaped = false; + let isFunction = false; + let str = ''; + let functionName = ''; + let args = []; + + for (let i = 0; i < len; i++) { + const c = content.charAt(i); + + switch (c) { + case "'": + case '"': + if (isEscaped) { + str += c; + } else { + isString = !isString; + if (!isFunction && !isString) { + tokens.push({type: TOKEN_TYPE.STRING, value: str}); + str = ''; + } + } + break; + + case '\\': + if (isEscaped) { + str += c; + isEscaped = false; + } else { + isEscaped = true; + } + break; + + case '(': + if (isString) { + str += c; + } else { + isFunction = true; + functionName = str; + str = ''; + args = []; + } + break; + + case ')': + if (isString) { + str += c; + } else if (isFunction) { + if (str) { + args.push(str); + } + + switch (functionName) { + case 'attr': + if (args.length > 0) { + tokens.push({type: TOKEN_TYPE.ATTRIBUTE, value: args[0]}); + } + break; + + case 'counter': + if (args.length > 0) { + const counter: Token = { + type: TOKEN_TYPE.COUNTER, + name: args[0] + }; + if (args.length > 1) { + counter.format = args[1]; + } + tokens.push(counter); + } + break; + + case 'counters': + if (args.length > 0) { + const counters: Token = { + type: TOKEN_TYPE.COUNTERS, + name: args[0] + }; + if (args.length > 1) { + counters.glue = args[1]; + } + if (args.length > 2) { + counters.format = args[2]; + } + tokens.push(counters); + } + break; + + case 'url': + if (args.length > 0) { + tokens.push({type: TOKEN_TYPE.URL, value: args[0]}); + } + break; + } + + isFunction = false; + str = ''; + } + break; + + case ',': + if (isString) { + str += c; + } else if (isFunction) { + args.push(str); + str = ''; + } + break; + + case ' ': + case '\t': + if (isString) { + str += c; + } else if (str) { + addOtherToken(tokens, str); + str = ''; + } + break; + + default: + str += c; + } + + if (c !== '\\') { + isEscaped = false; + } + } + + if (str) { + addOtherToken(tokens, str); + } + + if (cache) { + cache[content] = tokens; + } + + return tokens; +}; + +const addOtherToken = (tokens: Array, identifier: string): Token => { + switch (identifier) { + case 'open-quote': + tokens.push({type: TOKEN_TYPE.OPENQUOTE}); + break; + case 'close-quote': + tokens.push({type: TOKEN_TYPE.CLOSEQUOTE}); + break; + } +}; + +const getQuote = (style: CSSStyleDeclaration, isOpening: boolean, quoteDepth: number): string => { + const quotes = style.quotes ? style.quotes.split(/\s+/) : ["'\"'", "'\"'"]; + let idx = quoteDepth * 2; + if (idx >= quotes.length) { + idx = quotes.length - 2; + } + if (!isOpening) { + ++idx; + } + return quotes[idx].replace(/^["']|["']$/g, ''); +}; + +const formatCounterValue = (counter, glue: string, format: string): string => { + const len = counter.length; + let result = ''; + + for (let i = 0; i < len; i++) { + if (i > 0) { + result += glue; + } + result += ListStyleTypeFormatter.format(counter[i], format, false); + } + + return result; +}; diff --git a/tests/node/pseudonodecontent.js b/tests/node/pseudonodecontent.js new file mode 100644 index 0000000..b481c33 --- /dev/null +++ b/tests/node/pseudonodecontent.js @@ -0,0 +1,111 @@ +const PseudoNodeContent = require('../../dist/npm/PseudoNodeContent'); +const assert = require('assert'); + +describe('PseudoNodeContent', function() { + it('should parse string', function() { + assert.deepEqual(PseudoNodeContent.parseContent('"hello"'), [ + {type: PseudoNodeContent.TOKEN_TYPE.STRING, value: 'hello'} + ]); + }); + + it('should parse string with (,)', function() { + assert.deepEqual(PseudoNodeContent.parseContent('"a,b (c) d"'), [ + {type: PseudoNodeContent.TOKEN_TYPE.STRING, value: 'a,b (c) d'} + ]); + }); + + it('should parse string with escaped quotes', function() { + assert.deepEqual(PseudoNodeContent.parseContent('"3.14\\""'), [ + {type: PseudoNodeContent.TOKEN_TYPE.STRING, value: '3.14"'} + ]); + }); + + it('should parse string with escape', function() { + assert.deepEqual(PseudoNodeContent.parseContent('"a\\) \\\\ b"'), [ + {type: PseudoNodeContent.TOKEN_TYPE.STRING, value: 'a) \\ b'} + ]); + }); + + it('should parse two strings', function() { + assert.deepEqual(PseudoNodeContent.parseContent('"hello" \'world\''), [ + {type: PseudoNodeContent.TOKEN_TYPE.STRING, value: 'hello'}, + {type: PseudoNodeContent.TOKEN_TYPE.STRING, value: 'world'} + ]); + }); + + it('should parse counter', function() { + assert.deepEqual(PseudoNodeContent.parseContent('counter(x)'), [ + {type: PseudoNodeContent.TOKEN_TYPE.COUNTER, name: 'x'} + ]); + }); + + it('should parse counters', function() { + assert.deepEqual(PseudoNodeContent.parseContent('counters(x, "-")'), [ + {type: PseudoNodeContent.TOKEN_TYPE.COUNTERS, name: 'x', glue: '-'} + ]); + }); + + it('should parse strings and counters', function() { + assert.deepEqual(PseudoNodeContent.parseContent('"["counters(c2, " < ") \']\''), [ + {type: PseudoNodeContent.TOKEN_TYPE.STRING, value: '['}, + {type: PseudoNodeContent.TOKEN_TYPE.COUNTERS, name: 'c2', glue: ' < '}, + {type: PseudoNodeContent.TOKEN_TYPE.STRING, value: ']'} + ]); + }); + + it('should parse counter with format', function() { + assert.deepEqual(PseudoNodeContent.parseContent('counter(x, lower-greek)'), [ + {type: PseudoNodeContent.TOKEN_TYPE.COUNTER, name: 'x', format: 'lower-greek'} + ]); + }); + + it('should parse counters with format', function() { + assert.deepEqual(PseudoNodeContent.parseContent('counters(x, "-", upper-roman)'), [ + { + type: PseudoNodeContent.TOKEN_TYPE.COUNTERS, + name: 'x', + glue: '-', + format: 'upper-roman' + } + ]); + }); + + it('should parse strings and counters with format', function() { + assert.deepEqual(PseudoNodeContent.parseContent("\"[\"counters(c2, ' < ', disc) ']'"), [ + {type: PseudoNodeContent.TOKEN_TYPE.STRING, value: '['}, + {type: PseudoNodeContent.TOKEN_TYPE.COUNTERS, name: 'c2', glue: ' < ', format: 'disc'}, + {type: PseudoNodeContent.TOKEN_TYPE.STRING, value: ']'} + ]); + }); + + it('should parse attr', function() { + assert.deepEqual(PseudoNodeContent.parseContent('attr(id)'), [ + {type: PseudoNodeContent.TOKEN_TYPE.ATTRIBUTE, value: 'id'} + ]); + }); + + it('should parse url', function() { + assert.deepEqual(PseudoNodeContent.parseContent('url(http://www.abc.de/f/g.png)'), [ + {type: PseudoNodeContent.TOKEN_TYPE.URL, value: 'http://www.abc.de/f/g.png'} + ]); + }); + + it('should parse open-quote', function() { + assert.deepEqual(PseudoNodeContent.parseContent('open-quote'), [ + {type: PseudoNodeContent.TOKEN_TYPE.OPENQUOTE} + ]); + }); + + it('should parse close-quote', function() { + assert.deepEqual(PseudoNodeContent.parseContent('close-quote'), [ + {type: PseudoNodeContent.TOKEN_TYPE.CLOSEQUOTE} + ]); + }); + + it('should parse open-quote and string', function() { + assert.deepEqual(PseudoNodeContent.parseContent('open-quote "!"'), [ + {type: PseudoNodeContent.TOKEN_TYPE.OPENQUOTE}, + {type: PseudoNodeContent.TOKEN_TYPE.STRING, value: '!'} + ]); + }); +}); diff --git a/tests/reftests/pseudo-content.html b/tests/reftests/pseudo-content.html new file mode 100644 index 0000000..bac5796 --- /dev/null +++ b/tests/reftests/pseudo-content.html @@ -0,0 +1,104 @@ + + + + + + + + +
+
A
+
B
+
C
+
D
+
+ +
+
A
+
B
+
+ C +
+
a
+
b
+
+ c +
+
Aa
+
Bb
+
Cc
+
+
+
+
+
D
+
+ +
+ Hello +
+ Quoted +
World
+
+
+ +
+
+
+ + \ No newline at end of file