improved support of 'content' for pseudo elements (multiple components, counters, attr, quotes)

This commit is contained in:
Matthias Christen 2017-12-15 12:40:04 +01:00
parent 54c4002df7
commit 78c3c7fc71
6 changed files with 615 additions and 25 deletions

View File

@ -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

View File

@ -77,6 +77,7 @@
"homepage": "https://html2canvas.hertzen.com",
"license": "MIT",
"dependencies": {
"punycode": "2.1.0"
"punycode": "2.1.0",
"liststyletype-formatter": "latest"
}
}

View File

@ -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<PseudoContentItem>,
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';

340
src/PseudoNodeContent.js Normal file
View File

@ -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<number>},
quoteDepth: number
};
export type PseudoContentItem = {
type: $Values<typeof PSEUDO_CONTENT_ITEM_TYPE>,
value: string
};
export type Token = {
type: $Values<typeof TOKEN_TYPE>,
value: ?string,
format: ?string,
glue: ?string
};
export const parseCounterReset = (
style: CSSStyleDeclaration,
data: PseudoContentData
): Array<string> => {
if (!style || !style.counterReset || style.counterReset === 'none') {
return [];
}
const counterNames: Array<string> = [];
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<string>, 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<PseudoContentItem> => {
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<PseudoContentItem> = [];
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<Token> => {
if (cache && cache[content]) {
return cache[content];
}
const tokens: Array<Token> = [];
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<Token>, 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;
};

View File

@ -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: '!'}
]);
});
});

View File

@ -0,0 +1,104 @@
<!doctype html>
<html>
<head>
<script type="text/javascript" src="../test.js"></script>
<style>
body {
quotes: "<{" "}>" "->" "<-" "(" ")" "-:" ":-";
}
.counter1,
.counter2,
.quotes1,
.attr-url {
border: 1px solid deepskyblue;
padding: 5px;
margin-bottom: 10px;
}
.counter1 {
counter-reset: c1 3;
}
.counter1 > div::before {
content: "«\"" counter(c1) "\»";
counter-increment: c1 -1;
}
.counter2 {
counter-reset: c2;
}
.counter2 > div::before {
content: "["counters(c2, " < ", upper-roman) ']';
counter-increment: c2 2;
}
.quotes1::before {
content: open-quote "!" open-quote close-quote open-quote;
}
.quotes1::after {
content: "!" close-quote close-quote;
}
.quotes2 {
quotes: "«" "»" "“" "”";
}
.quotes2::before {
content: open-quote;
}
.quotes2::after {
content: close-quote;
}
.attr-url > div::after {
content: url(../assets/image.jpg) "///" attr(data-text);
}
</style>
</head>
<body>
<div class="counter1">
<div>A</div>
<div>B</div>
<div>C</div>
<div>D</div>
</div>
<div class="counter2">
<div>A</div>
<div>B</div>
<div>
C
<div class="counter2">
<div>a</div>
<div>b</div>
<div>
c
<div class="counter2">
<div>Aa</div>
<div>Bb</div>
<div>Cc</div>
</div>
</div>
</div>
</div>
<div>D</div>
</div>
<div class="quotes1">
Hello
<div class="quotes2">
Quoted
<div class="quotes2">World</div>
</div>
</div>
<div class="attr-url">
<div data-text="Hello World"></div>
</div>
</body>
</html>