Compare commits

..

7 Commits

10 changed files with 270 additions and 70 deletions

View File

@ -12,9 +12,9 @@ Below is a list of all the supported CSS properties and values.
- url()
- linear-gradient()
- radial-gradient()
- background-origin
- background-origin
- background-position
- background-size
- background-size
- border
- border-color
- border-radius
@ -50,6 +50,7 @@ Below is a list of all the supported CSS properties and values.
- overflow
- overflow-wrap
- padding
- paint-order
- position
- right
- text-align
@ -58,17 +59,18 @@ Below is a list of all the supported CSS properties and values.
- text-decoration-line
- text-decoration-style (**Only supports `solid`**)
- text-shadow
- text-transform
- text-transform
- top
- transform (**Limited support**)
- visibility
- white-space
- width
- webkit-text-stroke
- word-break
- word-spacing
- word-wrap
- z-index
## Unsupported CSS properties
These CSS properties are **NOT** currently supported
- [background-blend-mode](https://github.com/niklasvh/html2canvas/issues/966)

View File

@ -73,6 +73,9 @@ import {counterIncrement} from './property-descriptors/counter-increment';
import {counterReset} from './property-descriptors/counter-reset';
import {quotes} from './property-descriptors/quotes';
import {boxShadow} from './property-descriptors/box-shadow';
import {paintOrder} from './property-descriptors/paint-order';
import {webkitTextStrokeColor} from './property-descriptors/webkit-text-stroke-color';
import {webkitTextStrokeWidth} from './property-descriptors/webkit-text-stroke-width';
export class CSSParsedDeclaration {
backgroundClip: ReturnType<typeof backgroundClip.parse>;
@ -125,6 +128,7 @@ export class CSSParsedDeclaration {
paddingRight: LengthPercentage;
paddingBottom: LengthPercentage;
paddingLeft: LengthPercentage;
paintOrder: ReturnType<typeof paintOrder.parse>;
position: ReturnType<typeof position.parse>;
textAlign: ReturnType<typeof textAlign.parse>;
textDecorationColor: Color;
@ -134,6 +138,8 @@ export class CSSParsedDeclaration {
transform: ReturnType<typeof transform.parse>;
transformOrigin: ReturnType<typeof transformOrigin.parse>;
visibility: ReturnType<typeof visibility.parse>;
webkitTextStrokeColor: Color;
webkitTextStrokeWidth: ReturnType<typeof webkitTextStrokeWidth.parse>;
wordBreak: ReturnType<typeof wordBreak.parse>;
zIndex: ReturnType<typeof zIndex.parse>;
@ -189,6 +195,7 @@ export class CSSParsedDeclaration {
this.paddingRight = parse(paddingRight, declaration.paddingRight);
this.paddingBottom = parse(paddingBottom, declaration.paddingBottom);
this.paddingLeft = parse(paddingLeft, declaration.paddingLeft);
this.paintOrder = parse(paintOrder, declaration.paintOrder);
this.position = parse(position, declaration.position);
this.textAlign = parse(textAlign, declaration.textAlign);
this.textDecorationColor = parse(textDecorationColor, declaration.textDecorationColor ?? declaration.color);
@ -201,6 +208,8 @@ export class CSSParsedDeclaration {
this.transform = parse(transform, declaration.transform);
this.transformOrigin = parse(transformOrigin, declaration.transformOrigin);
this.visibility = parse(visibility, declaration.visibility);
this.webkitTextStrokeColor = parse(webkitTextStrokeColor, declaration.webkitTextStrokeColor);
this.webkitTextStrokeWidth = parse(webkitTextStrokeWidth, declaration.webkitTextStrokeWidth);
this.wordBreak = parse(wordBreak, declaration.wordBreak);
this.zIndex = parse(zIndex, declaration.zIndex);
}

View File

@ -0,0 +1,86 @@
import {deepStrictEqual} from 'assert';
import {Parser} from '../../syntax/parser';
import {paintOrder, PAINT_ORDER_LAYER} from '../paint-order';
const paintOrderParse = (value: string) => paintOrder.parse(Parser.parseValues(value));
describe('property-descriptors', () => {
describe('paint-order', () => {
it('none', () =>
deepStrictEqual(paintOrderParse('none'), [
PAINT_ORDER_LAYER.FILL,
PAINT_ORDER_LAYER.STROKE,
PAINT_ORDER_LAYER.MARKERS
]));
it('EMPTY', () =>
deepStrictEqual(paintOrderParse(''), [
PAINT_ORDER_LAYER.FILL,
PAINT_ORDER_LAYER.STROKE,
PAINT_ORDER_LAYER.MARKERS
]));
it('other values', () =>
deepStrictEqual(paintOrderParse('other values'), [
PAINT_ORDER_LAYER.FILL,
PAINT_ORDER_LAYER.STROKE,
PAINT_ORDER_LAYER.MARKERS
]));
it('normal', () =>
deepStrictEqual(paintOrderParse('normal'), [
PAINT_ORDER_LAYER.FILL,
PAINT_ORDER_LAYER.STROKE,
PAINT_ORDER_LAYER.MARKERS
]));
it('stroke', () =>
deepStrictEqual(paintOrderParse('stroke'), [
PAINT_ORDER_LAYER.STROKE,
PAINT_ORDER_LAYER.FILL,
PAINT_ORDER_LAYER.MARKERS
]));
it('fill', () =>
deepStrictEqual(paintOrderParse('fill'), [
PAINT_ORDER_LAYER.FILL,
PAINT_ORDER_LAYER.STROKE,
PAINT_ORDER_LAYER.MARKERS
]));
it('markers', () =>
deepStrictEqual(paintOrderParse('markers'), [
PAINT_ORDER_LAYER.MARKERS,
PAINT_ORDER_LAYER.FILL,
PAINT_ORDER_LAYER.STROKE
]));
it('stroke fill', () =>
deepStrictEqual(paintOrderParse('stroke fill'), [
PAINT_ORDER_LAYER.STROKE,
PAINT_ORDER_LAYER.FILL,
PAINT_ORDER_LAYER.MARKERS
]));
it('markers stroke', () =>
deepStrictEqual(paintOrderParse('markers stroke'), [
PAINT_ORDER_LAYER.MARKERS,
PAINT_ORDER_LAYER.STROKE,
PAINT_ORDER_LAYER.FILL
]));
it('markers stroke fill', () =>
deepStrictEqual(paintOrderParse('markers stroke fill'), [
PAINT_ORDER_LAYER.MARKERS,
PAINT_ORDER_LAYER.STROKE,
PAINT_ORDER_LAYER.FILL
]));
it('stroke fill markers', () =>
deepStrictEqual(paintOrderParse('stroke fill markers'), [
PAINT_ORDER_LAYER.STROKE,
PAINT_ORDER_LAYER.FILL,
PAINT_ORDER_LAYER.MARKERS
]));
});
});

View File

@ -0,0 +1,41 @@
import {IPropertyListDescriptor, PropertyDescriptorParsingType} from '../IPropertyDescriptor';
import {CSSValue, isIdentToken} from '../syntax/parser';
export enum PAINT_ORDER_LAYER {
FILL,
STROKE,
MARKERS
}
export type PaintOrder = PAINT_ORDER_LAYER[];
export const paintOrder: IPropertyListDescriptor<PaintOrder> = {
name: 'paint-order',
initialValue: 'normal',
prefix: false,
type: PropertyDescriptorParsingType.LIST,
parse: (tokens: CSSValue[]): PaintOrder => {
const DEFAULT_VALUE = [PAINT_ORDER_LAYER.FILL, PAINT_ORDER_LAYER.STROKE, PAINT_ORDER_LAYER.MARKERS];
let layers: PaintOrder = [];
tokens.filter(isIdentToken).forEach((token) => {
switch (token.value) {
case 'stroke':
layers.push(PAINT_ORDER_LAYER.STROKE);
break;
case 'fill':
layers.push(PAINT_ORDER_LAYER.FILL);
break;
case 'markers':
layers.push(PAINT_ORDER_LAYER.MARKERS);
break;
}
});
DEFAULT_VALUE.forEach((value) => {
if (layers.indexOf(value) === -1) {
layers.push(value);
}
});
return layers;
}
};

View File

@ -0,0 +1,8 @@
import {IPropertyTypeValueDescriptor, PropertyDescriptorParsingType} from '../IPropertyDescriptor';
export const webkitTextStrokeColor: IPropertyTypeValueDescriptor = {
name: `-webkit-text-stroke-color`,
initialValue: 'currentcolor',
prefix: false,
type: PropertyDescriptorParsingType.TYPE_VALUE,
format: 'color'
};

View File

@ -0,0 +1,14 @@
import {CSSValue, isDimensionToken} from '../syntax/parser';
import {IPropertyValueDescriptor, PropertyDescriptorParsingType} from '../IPropertyDescriptor';
export const webkitTextStrokeWidth: IPropertyValueDescriptor<number> = {
name: `-webkit-text-stroke-width`,
initialValue: '0',
type: PropertyDescriptorParsingType.VALUE,
prefix: false,
parse: (token: CSSValue): number => {
if (isDimensionToken(token)) {
return token.number;
}
return 0;
}
};

View File

@ -443,12 +443,17 @@ const iframeLoader = (iframe: HTMLIFrameElement): Promise<HTMLIFrameElement> =>
});
};
const ignoredStyleProperties = [
'all', // #2476
'd', // #2483
'content' // Safari shows pseudoelements if content is set
];
export const copyCSSStyles = <T extends HTMLElement | SVGElement>(style: CSSStyleDeclaration, target: T): T => {
// Edge does not provide value for cssText
for (let i = style.length - 1; i >= 0; i--) {
const property = style.item(i);
// Safari shows pseudoelements if content is set
if (property !== 'content') {
if (ignoredStyleProperties.indexOf(property) === -1) {
target.style.setProperty(property, style.getPropertyValue(property));
}
}

View File

@ -43,6 +43,7 @@ import {TextareaElementContainer} from '../../dom/elements/textarea-element-cont
import {SelectElementContainer} from '../../dom/elements/select-element-container';
import {IFrameElementContainer} from '../../dom/replaced-elements/iframe-element-container';
import {TextShadow} from '../../css/property-descriptors/text-shadow';
import {PAINT_ORDER_LAYER} from '../../css/property-descriptors/paint-order';
export type RenderConfigurations = RenderOptions & {
backgroundColor: Color | null;
@ -179,65 +180,88 @@ export class CanvasRenderer {
const [font, fontFamily, fontSize] = this.createFontStyle(styles);
this.ctx.font = font;
this.ctx.textBaseline = 'alphabetic';
this.ctx.textBaseline = 'alphabetic';
const {baseline, middle} = this.fontMetrics.getMetrics(fontFamily, fontSize);
const paintOrder = styles.paintOrder;
text.textBounds.forEach((text) => {
this.ctx.fillStyle = asString(styles.color);
this.renderTextWithLetterSpacing(text, styles.letterSpacing, baseline);
const textShadows: TextShadow = styles.textShadow;
if (textShadows.length && text.text.trim().length) {
textShadows
.slice(0)
.reverse()
.forEach((textShadow) => {
this.ctx.shadowColor = asString(textShadow.color);
this.ctx.shadowOffsetX = textShadow.offsetX.number * this.options.scale;
this.ctx.shadowOffsetY = textShadow.offsetY.number * this.options.scale;
this.ctx.shadowBlur = textShadow.blur.number;
paintOrder.forEach((paintOrderLayer) => {
switch (paintOrderLayer) {
case PAINT_ORDER_LAYER.FILL:
this.ctx.fillStyle = asString(styles.color);
this.renderTextWithLetterSpacing(text, styles.letterSpacing, baseline);
});
const textShadows: TextShadow = styles.textShadow;
this.ctx.shadowColor = '';
this.ctx.shadowOffsetX = 0;
this.ctx.shadowOffsetY = 0;
this.ctx.shadowBlur = 0;
}
if (textShadows.length && text.text.trim().length) {
textShadows
.slice(0)
.reverse()
.forEach((textShadow) => {
this.ctx.shadowColor = asString(textShadow.color);
this.ctx.shadowOffsetX = textShadow.offsetX.number * this.options.scale;
this.ctx.shadowOffsetY = textShadow.offsetY.number * this.options.scale;
this.ctx.shadowBlur = textShadow.blur.number;
if (styles.textDecorationLine.length) {
this.ctx.fillStyle = asString(styles.textDecorationColor || styles.color);
styles.textDecorationLine.forEach((textDecorationLine) => {
switch (textDecorationLine) {
case TEXT_DECORATION_LINE.UNDERLINE:
// Draws a line at the baseline of the font
// TODO As some browsers display the line as more than 1px if the font-size is big,
// need to take that into account both in position and size
this.ctx.fillRect(
text.bounds.left,
Math.round(text.bounds.top + baseline),
text.bounds.width,
1
);
this.renderTextWithLetterSpacing(text, styles.letterSpacing, baseline);
});
break;
case TEXT_DECORATION_LINE.OVERLINE:
this.ctx.fillRect(text.bounds.left, Math.round(text.bounds.top), text.bounds.width, 1);
break;
case TEXT_DECORATION_LINE.LINE_THROUGH:
// TODO try and find exact position for line-through
this.ctx.fillRect(
text.bounds.left,
Math.ceil(text.bounds.top + middle),
text.bounds.width,
1
);
break;
}
});
}
this.ctx.shadowColor = '';
this.ctx.shadowOffsetX = 0;
this.ctx.shadowOffsetY = 0;
this.ctx.shadowBlur = 0;
}
if (styles.textDecorationLine.length) {
this.ctx.fillStyle = asString(styles.textDecorationColor || styles.color);
styles.textDecorationLine.forEach((textDecorationLine) => {
switch (textDecorationLine) {
case TEXT_DECORATION_LINE.UNDERLINE:
// Draws a line at the baseline of the font
// TODO As some browsers display the line as more than 1px if the font-size is big,
// need to take that into account both in position and size
this.ctx.fillRect(
text.bounds.left,
Math.round(text.bounds.top + baseline),
text.bounds.width,
1
);
break;
case TEXT_DECORATION_LINE.OVERLINE:
this.ctx.fillRect(
text.bounds.left,
Math.round(text.bounds.top),
text.bounds.width,
1
);
break;
case TEXT_DECORATION_LINE.LINE_THROUGH:
// TODO try and find exact position for line-through
this.ctx.fillRect(
text.bounds.left,
Math.ceil(text.bounds.top + middle),
text.bounds.width,
1
);
break;
}
});
}
break;
case PAINT_ORDER_LAYER.STROKE:
if (styles.webkitTextStrokeWidth && text.text.trim().length) {
this.ctx.strokeStyle = asString(styles.webkitTextStrokeColor);
this.ctx.lineWidth = styles.webkitTextStrokeWidth;
this.ctx.lineJoin = !!(window as any).chrome ? 'miter' : 'round';
this.ctx.strokeText(text.text, text.bounds.left, text.bounds.top + baseline);
}
this.ctx.strokeStyle = '';
this.ctx.lineWidth = 0;
this.ctx.lineJoin = 'miter';
break;
}
});
});
}
@ -376,7 +400,7 @@ export class CanvasRenderer {
this.ctx.font = fontFamily;
this.ctx.fillStyle = asString(styles.color);
this.ctx.textBaseline = 'middle';
this.ctx.textBaseline = 'alphabetic';
this.ctx.textAlign = canvasTextAlign(container.styles.textAlign);
const bounds = contentBox(container);
@ -409,7 +433,7 @@ export class CanvasRenderer {
baseline
);
this.ctx.restore();
this.ctx.textBaseline = 'bottom';
this.ctx.textBaseline = 'alphabetic';
this.ctx.textAlign = 'left';
}
@ -427,7 +451,7 @@ export class CanvasRenderer {
}
}
} else if (paint.listValue && container.styles.listStyleType !== LIST_STYLE_TYPE.NONE) {
const [fontFamily, fontSize] = this.createFontStyle(styles);
const [fontFamily] = this.createFontStyle(styles);
this.ctx.font = fontFamily;
this.ctx.fillStyle = asString(styles.color);
@ -441,12 +465,10 @@ export class CanvasRenderer {
computeLineHeight(styles.lineHeight, styles.fontSize.number) / 2 + 1
);
const {baseline} = this.fontMetrics.getMetrics(fontFamily, fontSize);
this.renderTextWithLetterSpacing(
new TextBounds(paint.listValue, bounds),
styles.letterSpacing,
baseline
computeLineHeight(styles.lineHeight, styles.fontSize.number) / 2 + 2
);
this.ctx.textBaseline = 'bottom';
this.ctx.textAlign = 'left';
@ -504,11 +526,14 @@ export class CanvasRenderer {
mask(paths: Path[]): void {
this.ctx.beginPath();
this.ctx.save();
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.ctx.moveTo(0, 0);
this.ctx.lineTo(this.canvas.width, 0);
this.ctx.lineTo(this.canvas.width, this.canvas.height);
this.ctx.lineTo(0, this.canvas.height);
this.ctx.lineTo(0, 0);
this.ctx.restore();
this.formatPath(paths.slice(0).reverse());
this.ctx.closePath();
}
@ -554,7 +579,8 @@ export class CanvasRenderer {
return image;
}
const canvas = (this.canvas.ownerDocument as Document).createElement('canvas');
const ownerDocument = this.canvas.ownerDocument ?? document;
const canvas = ownerDocument.createElement('canvas');
canvas.width = Math.max(1, width);
canvas.height = Math.max(1, height);
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
@ -700,18 +726,19 @@ export class CanvasRenderer {
await this.renderBackgroundImage(paint.container);
this.ctx.restore();
const borderBoxArea = calculateBorderBoxPath(paint.curves);
styles.boxShadow
.slice(0)
.reverse()
.forEach((shadow) => {
this.ctx.save();
const borderBoxArea = calculateBorderBoxPath(paint.curves);
const maskOffset = shadow.inset ? 0 : MASK_OFFSET;
const shadowDirection = shadow.inset ? 1 : -1;
const shadowPaintingArea = transformPath(
borderBoxArea,
-maskOffset + (shadow.inset ? 1 : -1) * shadow.spread.number,
(shadow.inset ? 1 : -1) * shadow.spread.number,
shadow.offsetX.number - maskOffset + shadowDirection * shadow.spread.number,
shadow.offsetY.number + shadowDirection * shadow.spread.number,
shadow.spread.number * (shadow.inset ? -2 : 2),
shadow.spread.number * (shadow.inset ? -2 : 2)
);
@ -726,8 +753,8 @@ export class CanvasRenderer {
this.path(shadowPaintingArea);
}
this.ctx.shadowOffsetX = shadow.offsetX.number + maskOffset;
this.ctx.shadowOffsetY = shadow.offsetY.number;
this.ctx.shadowOffsetX = maskOffset;
this.ctx.shadowOffsetY = 0;
this.ctx.shadowColor = asString(shadow.color);
this.ctx.shadowBlur = shadow.blur.number;
this.ctx.fillStyle = shadow.inset ? asString(shadow.color) : 'rgba(0,0,0,1)';

View File

@ -9,7 +9,7 @@
margin: 10px;
display: inline-block;
min-height: 50px;
// border-radius: 10px;
border-radius: 10px;
}
body {

View File

@ -40,5 +40,13 @@
<text x="65" y="35" class="canvas">canvas</text>
</svg>
<img width="200" height="200" src="data:image/svg+xml;base64,PHN2ZyB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgd2lkdGg9IjMwNiIgaGVpZ2h0PSIyOTYiPjxkZWZzIGlkPSJkZWZzNCIgLz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTYyLjQ2OTk1LC00NzcuMjg2MykiIGlkPSJsYXllcjEiPjxwYXRoIGQ9Im0gMzE0LjE1NzQ1LDQ4MS42OTU1OCBjIC01OS4yMDA4OSwwLjUzNzc0IC0xMTQuODA5NzksMzYuNzIyMTkgLTEzNy4zMTI1LDk1LjM0Mzc1IC0yOS4zOTEyOSw3Ni41NjY5MyA4LjgzOTMyLDE2Mi40NTI0NiA4NS40MDYyNSwxOTEuODQzNzUgbCAzNC4wMzEyNSwtODguNjg3NSBjIC0yMC4wNjc4LC03LjcxMzU4IC0zNC4zMTI1LC0yNy4xNTMyNCAtMzQuMzEyNSwtNDkuOTM3NSAwLC0yOS41NDcyMyAyMy45NTI3NywtNTMuNSA1My41LC01My41IDI5LjU0NzIzLDAgNTMuNSwyMy45NTI3NyA1My41LDUzLjUgMCwyMi43ODQyNiAtMTQuMjQ0Nyw0Mi4yMjM5MiAtMzQuMzEyNSw0OS45Mzc1IGwgMzQuMDMxMjUsODguNjg3NSBjIDM5LjI5MDg1LC0xNS4wODIzNCA3MC4zMjM5LC00Ni4xMTU0IDg1LjQwNjI1LC04NS40MDYyNSAyOS4zOTEyOSwtNzYuNTY2OTMgLTguODM5MzIsLTE2Mi40ODM3MSAtODUuNDA2MjUsLTE5MS44NzUgLTE3Ljk0NTM3LC02Ljg4ODU5IC0zNi40MDg1MywtMTAuMDcwODcgLTU0LjUzMTI1LC05LjkwNjI1IHoiIGlkPSJwYXRoMjgzMCIgc3R5bGU9ImZpbGw6IzQwYWE1NDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6IzIwNTUyYTtzdHJva2Utd2lkdGg6Ny45OTk5OTk1MjtzdHJva2UtbWl0ZXJsaW1pdDo0O3N0cm9rZS1vcGFjaXR5OjE7c3Ryb2tlLWRhc2hhcnJheTpub25lIiAvPjwvZz48L3N2Zz4=" /></div>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width=50 height=20>
<g data-z-index="0.1" opacity="1" transform="translate(-974,-30) scale(1 1)">
<path fill="#7cb5ec" d="M 990 37.734399999999994 A 4 4 0 1 1 990.0039999993332 37.73439800000016 Z" opacity="1" stroke-width="0.00015790535835003006"></path>
<path fill="#7cb5ec" d="M 999 37.734399999999994 A 4 4 0 1 1 999.0039999993332 37.73439800000016 Z" opacity="1" stroke-width="0.00015790535835003006"></path>
<path fill="#7cb5ec" d="M 1009 37.734399999999994 A 4 4 0 1 1 1009.0039999993332 37.73439800000016 Z" opacity="1" stroke-width="0.00015790535835003006"></path>
<path fill="#7cb5ec" d="M 1019 37.734399999999994 A 4 4 0 1 1 1019.0039999993332 37.73439800000016 Z" opacity="1" stroke-width="0.00015790535835003006"></path>
</g>
</svg>
</body>
</html>