From 8ef3861a5cba4c9806e4aeaa522aba53ab6f7819 Mon Sep 17 00:00:00 2001 From: Matthias Christen Date: Mon, 11 Dec 2017 22:02:51 +0100 Subject: [PATCH 1/2] added support for radial gradients --- src/Gradient.js | 418 ++++++++++++++++-- src/Length.js | 32 ++ src/Renderer.js | 21 +- src/Util.js | 2 + src/renderer/CanvasRenderer.js | 52 ++- src/renderer/RefTestRenderer.js | 17 +- tests/node/gradient.js | 151 +++++++ .../reftests/background/radial-gradient2.html | 51 +++ 8 files changed, 689 insertions(+), 55 deletions(-) create mode 100644 tests/node/gradient.js create mode 100644 tests/reftests/background/radial-gradient2.html diff --git a/src/Gradient.js b/src/Gradient.js index d542b84..78aa405 100644 --- a/src/Gradient.js +++ b/src/Gradient.js @@ -3,14 +3,22 @@ import type {BackgroundSource} from './parsing/background'; import type {Bounds} from './Bounds'; +import NodeContainer from './NodeContainer'; import {parseAngle} from './Angle'; import Color from './Color'; -import Length, {LENGTH_TYPE} from './Length'; +import Length, {LENGTH_TYPE, calculateLengthFromValueWithUnit} from './Length'; +import {distance} from './Util'; const SIDE_OR_CORNER = /^(to )?(left|top|right|bottom)( (left|top|right|bottom))?$/i; const PERCENTAGE_ANGLES = /^([+-]?\d*\.?\d+)% ([+-]?\d*\.?\d+)%$/i; const ENDS_WITH_LENGTH = /(px)|%|( 0)$/i; -const FROM_TO = /^(from|to)\((.+)\)$/i; +const FROM_TO_COLORSTOP = /^(from|to|color-stop)\((?:([\d.]+)(%)?,\s*)?(.+?)\)$/i; +const RADIAL_SHAPE_DEFINITION = /^\s*(circle|ellipse)?\s*((?:([\d.]+)(px|r?em|%)\s*(?:([\d.]+)(px|r?em|%))?)|closest-side|closest-corner|farthest-side|farthest-corner)?\s*(?:at\s*(?:(left|center|right)|([\d.]+)(px|r?em|%))\s+(?:(top|center|bottom)|([\d.]+)(px|r?em|%)))?(?:\s|$)/i; + +export type Point = { + x: number, + y: number +}; export type Direction = { x0: number, @@ -24,51 +32,72 @@ export type ColorStop = { stop: number }; -export type Gradient = { +export type LinearGradient = { + type: GradientType, direction: Direction, colorStops: Array }; +export type RadialGradient = { + type: GradientType, + shape: RadialGradientShapeType, + center: Point, + radius: Point, + colorStops: Array +}; + +export const GRADIENT_TYPE = { + LINEAR_GRADIENT: 0, + RADIAL_GRADIENT: 1 +}; + +export type GradientType = $Values; + +export const RADIAL_GRADIENT_SHAPE = { + CIRCLE: 0, + ELLIPSE: 1 +}; + +export type RadialGradientShapeType = $Values; + +const LENGTH_FOR_POSITION = { + left: new Length('0%'), + top: new Length('0%'), + center: new Length('50%'), + right: new Length('100%'), + bottom: new Length('100%') +}; + export const parseGradient = ( + container: NodeContainer, {args, method, prefix}: BackgroundSource, bounds: Bounds -): ?Gradient => { +): ?LinearGradient | RadialGradient => { if (method === 'linear-gradient') { return parseLinearGradient(args, bounds, !!prefix); } else if (method === 'gradient' && args[0] === 'linear') { // TODO handle correct angle return parseLinearGradient( - ['to bottom'].concat( - args - .slice(3) - .map(color => color.match(FROM_TO)) - .filter(v => v !== null) - // $FlowFixMe - .map(v => v[2]) - ), + ['to bottom'].concat(transformObsoleteColorStops(args.slice(3))), bounds, !!prefix ); + } else if (method === 'radial-gradient') { + if (prefix === '-webkit-') { + args = transformWebkitRadialGradientArgs(args); + } + return parseRadialGradient(container, args, bounds); + } else if (method === 'gradient' && args[0] === 'radial') { + return parseRadialGradient( + container, + transformObsoleteColorStops(transformWebkitRadialGradientArgs(args.slice(1))), + bounds + ); } }; -const parseLinearGradient = (args: Array, bounds: Bounds, hasPrefix: boolean): Gradient => { - const angle = parseAngle(args[0]); - const HAS_SIDE_OR_CORNER = SIDE_OR_CORNER.test(args[0]); - const HAS_DIRECTION = HAS_SIDE_OR_CORNER || angle !== null || PERCENTAGE_ANGLES.test(args[0]); - const direction = HAS_DIRECTION - ? angle !== null - ? calculateGradientDirection( - // if there is a prefix, the 0° angle points due East (instead of North per W3C) - hasPrefix ? angle - Math.PI * 0.5 : angle, - bounds - ) - : HAS_SIDE_OR_CORNER - ? parseSideOrCorner(args[0], bounds) - : parsePercentageAngle(args[0], bounds) - : calculateGradientDirection(Math.PI, bounds); +const parseColorStops = (args: Array, firstColorStopIndex: number, lineLength: number) => { const colorStops = []; - const firstColorStopIndex = HAS_DIRECTION ? 1 : 0; for (let i = firstColorStopIndex; i < args.length; i++) { const value = args[i]; @@ -83,16 +112,6 @@ const parseLinearGradient = (args: Array, bounds: Bounds, hasPrefix: boo colorStops.push({color, stop}); } - // TODO: Fix some inaccuracy with color stops with px values - const lineLength = Math.min( - Math.sqrt( - Math.pow(Math.abs(direction.x0) + Math.abs(direction.x1), 2) + - Math.pow(Math.abs(direction.y0) + Math.abs(direction.y1), 2) - ), - bounds.width * 2, - bounds.height * 2 - ); - const absoluteValuedColorStops = colorStops.map(({color, stop}) => { return { color, @@ -123,9 +142,108 @@ const parseLinearGradient = (args: Array, bounds: Bounds, hasPrefix: boo } } + return absoluteValuedColorStops; +}; + +const parseLinearGradient = ( + args: Array, + bounds: Bounds, + hasPrefix: boolean +): LinearGradient => { + const angle = parseAngle(args[0]); + const HAS_SIDE_OR_CORNER = SIDE_OR_CORNER.test(args[0]); + const HAS_DIRECTION = HAS_SIDE_OR_CORNER || angle !== null || PERCENTAGE_ANGLES.test(args[0]); + const direction = HAS_DIRECTION + ? angle !== null + ? calculateGradientDirection( + // if there is a prefix, the 0° angle points due East (instead of North per W3C) + hasPrefix ? angle - Math.PI * 0.5 : angle, + bounds + ) + : HAS_SIDE_OR_CORNER + ? parseSideOrCorner(args[0], bounds) + : parsePercentageAngle(args[0], bounds) + : calculateGradientDirection(Math.PI, bounds); + const firstColorStopIndex = HAS_DIRECTION ? 1 : 0; + + // TODO: Fix some inaccuracy with color stops with px values + const lineLength = Math.min( + distance( + Math.abs(direction.x0) + Math.abs(direction.x1), + Math.abs(direction.y0) + Math.abs(direction.y1) + ), + bounds.width * 2, + bounds.height * 2 + ); + return { + type: GRADIENT_TYPE.LINEAR_GRADIENT, direction, - colorStops: absoluteValuedColorStops + colorStops: parseColorStops(args, firstColorStopIndex, lineLength) + }; +}; + +const parseRadialGradient = ( + container: NodeContainer, + args: Array, + bounds: Bounds +): RadialGradient => { + const m = args[0].match(RADIAL_SHAPE_DEFINITION); + const shape = + m && + (m[1] === 'circle' || // explicit shape specification + (m[3] !== undefined && m[5] === undefined)) // only one radius coordinate + ? RADIAL_GRADIENT_SHAPE.CIRCLE + : RADIAL_GRADIENT_SHAPE.ELLIPSE; + const radius = {}; + const center = {}; + + if (m) { + // Radius + if (m[3] !== undefined) { + radius.x = calculateLengthFromValueWithUnit(container, m[3], m[4]).getAbsoluteValue( + bounds.width + ); + } + + if (m[5] !== undefined) { + radius.y = calculateLengthFromValueWithUnit(container, m[5], m[6]).getAbsoluteValue( + bounds.height + ); + } + + // Position + if (m[7]) { + center.x = LENGTH_FOR_POSITION[m[7].toLowerCase()]; + } else if (m[8] !== undefined) { + center.x = calculateLengthFromValueWithUnit(container, m[8], m[9]); + } + + if (m[10]) { + center.y = LENGTH_FOR_POSITION[m[10].toLowerCase()]; + } else if (m[11] !== undefined) { + center.y = calculateLengthFromValueWithUnit(container, m[11], m[12]); + } + } + + const gradientCenter = { + x: center.x === undefined ? bounds.width / 2 : center.x.getAbsoluteValue(bounds.width), + y: center.y === undefined ? bounds.height / 2 : center.y.getAbsoluteValue(bounds.height) + }; + const gradientRadius = calculateRadius( + (m && m[2]) || 'farthest-corner', + shape, + gradientCenter, + radius, + bounds + ); + + return { + type: GRADIENT_TYPE.RADIAL_GRADIENT, + shape, + center: gradientCenter, + radius: gradientRadius, + colorStops: parseColorStops(args, m ? 1 : 0, Math.min(gradientRadius.x, gradientRadius.y)) }; }; @@ -146,9 +264,7 @@ const calculateGradientDirection = (radian: number, bounds: Bounds): Direction = }; const parseTopRight = (bounds: Bounds) => - Math.acos( - bounds.width / 2 / (Math.sqrt(Math.pow(bounds.width, 2) + Math.pow(bounds.height, 2)) / 2) - ); + Math.acos(bounds.width / 2 / (distance(bounds.width, bounds.height) / 2)); const parseSideOrCorner = (side: string, bounds: Bounds): Direction => { switch (side) { @@ -194,3 +310,221 @@ const parsePercentageAngle = (angle: string, bounds: Bounds): Direction => { return calculateGradientDirection(Math.atan(isNaN(ratio) ? 1 : ratio) + Math.PI / 2, bounds); }; + +const findCorner = (bounds: Bounds, x: number, y: number, closest: boolean): Point => { + var corners = [ + {x: 0, y: 0}, + {x: 0, y: bounds.height}, + {x: bounds.width, y: 0}, + {x: bounds.width, y: bounds.height} + ]; + + let optimumDistance = closest ? Infinity : -Infinity; + let optimumCorner = null; + + for (let corner of corners) { + const d = distance(x - corner.x, y - corner.y); + if (closest ? d < optimumDistance : d > optimumDistance) { + optimumDistance = d; + optimumCorner = corner; + } + } + + // $FlowFixMe + return optimumCorner; +}; + +const calculateRadius = ( + extent: string, + shape: RadialGradientShapeType, + center: Point, + radius: Point, + bounds: Bounds +): Point => { + const x = center.x; + const y = center.y; + let rx = 0; + let ry = 0; + + switch (extent) { + case 'closest-side': + // The ending shape is sized so that that it exactly meets the side of the gradient box closest to the gradient’s center. + // If the shape is an ellipse, it exactly meets the closest side in each dimension. + if (shape === RADIAL_GRADIENT_SHAPE.CIRCLE) { + rx = ry = Math.min( + Math.abs(x), + Math.abs(x - bounds.width), + Math.abs(y), + Math.abs(y - bounds.height) + ); + } else if (shape === RADIAL_GRADIENT_SHAPE.ELLIPSE) { + rx = Math.min(Math.abs(x), Math.abs(x - bounds.width)); + ry = Math.min(Math.abs(y), Math.abs(y - bounds.height)); + } + break; + + case 'closest-corner': + // The ending shape is sized so that that it passes through the corner of the gradient box closest to the gradient’s center. + // If the shape is an ellipse, the ending shape is given the same aspect-ratio it would have if closest-side were specified. + if (shape === RADIAL_GRADIENT_SHAPE.CIRCLE) { + rx = ry = Math.min( + distance(x, y), + distance(x, y - bounds.height), + distance(x - bounds.width, y), + distance(x - bounds.width, y - bounds.height) + ); + } else if (shape === RADIAL_GRADIENT_SHAPE.ELLIPSE) { + // Compute the ratio ry/rx (which is to be the same as for "closest-side") + const c = + Math.min(Math.abs(y), Math.abs(y - bounds.height)) / + Math.min(Math.abs(x), Math.abs(x - bounds.width)); + const corner = findCorner(bounds, x, y, true); + rx = distance(corner.x - x, (corner.y - y) / c); + ry = c * rx; + } + break; + + case 'farthest-side': + // Same as closest-side, except the ending shape is sized based on the farthest side(s) + if (shape === RADIAL_GRADIENT_SHAPE.CIRCLE) { + rx = ry = Math.max( + Math.abs(x), + Math.abs(x - bounds.width), + Math.abs(y), + Math.abs(y - bounds.height) + ); + } else if (shape === RADIAL_GRADIENT_SHAPE.ELLIPSE) { + rx = Math.max(Math.abs(x), Math.abs(x - bounds.width)); + ry = Math.max(Math.abs(y), Math.abs(y - bounds.height)); + } + break; + + case 'farthest-corner': + // Same as closest-corner, except the ending shape is sized based on the farthest corner. + // If the shape is an ellipse, the ending shape is given the same aspect ratio it would have if farthest-side were specified. + if (shape === RADIAL_GRADIENT_SHAPE.CIRCLE) { + rx = ry = Math.max( + distance(x, y), + distance(x, y - bounds.height), + distance(x - bounds.width, y), + distance(x - bounds.width, y - bounds.height) + ); + } else if (shape === RADIAL_GRADIENT_SHAPE.ELLIPSE) { + // Compute the ratio ry/rx (which is to be the same as for "farthest-side") + const c = + Math.max(Math.abs(y), Math.abs(y - bounds.height)) / + Math.max(Math.abs(x), Math.abs(x - bounds.width)); + const corner = findCorner(bounds, x, y, false); + rx = distance(corner.x - x, (corner.y - y) / c); + ry = c * rx; + } + break; + + default: + // pixel or percentage values + rx = radius.x || 0; + ry = radius.y !== undefined ? radius.y : rx; + break; + } + + return { + x: rx, + y: ry + }; +}; + +export const transformWebkitRadialGradientArgs = (args: Array): Array => { + let shape = ''; + let radius = ''; + let extent = ''; + let position = ''; + let idx = 0; + + const POSITION = /^(left|center|right|\d+(?:px|r?em|%)?)(?:\s+(top|center|bottom|\d+(?:px|r?em|%)?))?$/i; + const SHAPE_AND_EXTENT = /^(circle|ellipse)?\s*(closest-side|closest-corner|farthest-side|farthest-corner|contain|cover)?$/i; + const RADIUS = /^\d+(px|r?em|%)?(?:\s+\d+(px|r?em|%)?)?$/i; + + const matchStartPosition = args[idx].match(POSITION); + if (matchStartPosition) { + idx++; + } + + const matchShapeExtent = args[idx].match(SHAPE_AND_EXTENT); + if (matchShapeExtent) { + shape = matchShapeExtent[1] || ''; + extent = matchShapeExtent[2] || ''; + if (extent === 'contain') { + extent = 'closest-side'; + } else if (extent === 'cover') { + extent = 'farthest-corner'; + } + idx++; + } + + const matchStartRadius = args[idx].match(RADIUS); + if (matchStartRadius) { + idx++; + } + + const matchEndPosition = args[idx].match(POSITION); + if (matchEndPosition) { + idx++; + } + + const matchEndRadius = args[idx].match(RADIUS); + if (matchEndRadius) { + idx++; + } + + const matchPosition = matchEndPosition || matchStartPosition; + if (matchPosition && matchPosition[1]) { + position = matchPosition[1] + (/^\d+$/.test(matchPosition[1]) ? 'px' : ''); + if (matchPosition[2]) { + position += ' ' + matchPosition[2] + (/^\d+$/.test(matchPosition[2]) ? 'px' : ''); + } + } + + const matchRadius = matchEndRadius || matchStartRadius; + if (matchRadius) { + radius = matchRadius[0]; + if (!matchRadius[1]) { + radius += 'px'; + } + } + + if (position && !shape && !radius && !extent) { + radius = position; + position = ''; + } + + if (position) { + position = `at ${position}`; + } + + return [[shape, extent, radius, position].filter(s => !!s).join(' ')].concat(args.slice(idx)); +}; + +const transformObsoleteColorStops = (args: Array): Array => { + return ( + args + .map(color => color.match(FROM_TO_COLORSTOP)) + // $FlowFixMe + .map((v: Array, index: number) => { + if (!v) { + return args[index]; + } + + switch (v[1]) { + case 'from': + return `${v[4]} 0%`; + case 'to': + return `${v[4]} 100%`; + case 'color-stop': + if (v[3] === '%') { + return `${v[4]} ${v[2]}`; + } + return `${v[4]} ${parseFloat(v[2]) * 100}%`; + } + }) + ); +}; diff --git a/src/Length.js b/src/Length.js index 6eb9e80..e2c1d78 100644 --- a/src/Length.js +++ b/src/Length.js @@ -1,6 +1,10 @@ /* @flow */ 'use strict'; +import NodeContainer from './NodeContainer'; + +const LENGTH_WITH_UNIT = /([\d.]+)(px|r?em|%)/i; + export const LENGTH_TYPE = { PX: 0, PERCENTAGE: 1 @@ -34,3 +38,31 @@ export default class Length { return new Length(v); } } + +const getRootFontSize = (container: NodeContainer): number => { + const parent = container.parent; + return parent ? getRootFontSize(parent) : parseFloat(container.style.font.fontSize); +}; + +export const calculateLengthFromValueWithUnit = ( + container: NodeContainer, + value: string, + unit: string +): Length => { + switch (unit) { + case 'px': + case '%': + return new Length(value + unit); + case 'em': + case 'rem': + const length = new Length(value); + length.value *= + unit === 'em' + ? parseFloat(container.style.font.fontSize) + : getRootFontSize(container); + return length; + default: + // TODO: handle correctly if unknown unit is used + return new Length('0'); + } +}; diff --git a/src/Renderer.js b/src/Renderer.js index 0cdfe7c..3e59702 100644 --- a/src/Renderer.js +++ b/src/Renderer.js @@ -14,7 +14,7 @@ import type {TextShadow} from './parsing/textShadow'; import type {Matrix} from './parsing/transform'; import type {BoundCurves} from './Bounds'; -import type {Gradient} from './Gradient'; +import type {LinearGradient, RadialGradient} from './Gradient'; import type {ResourceStore, ImageElement} from './ResourceLoader'; import type NodeContainer from './NodeContainer'; import type StackingContext from './StackingContext'; @@ -22,7 +22,7 @@ import type {TextBounds} from './TextBounds'; import {Bounds, parsePathForBorder, calculateContentBox, calculatePaddingBoxPath} from './Bounds'; import {FontMetrics} from './Font'; -import {parseGradient} from './Gradient'; +import {parseGradient, GRADIENT_TYPE} from './Gradient'; import TextContainer from './TextContainer'; import { @@ -62,7 +62,9 @@ export interface RenderTarget { render(options: RenderOptions): void, - renderLinearGradient(bounds: Bounds, gradient: Gradient): void, + renderLinearGradient(bounds: Bounds, gradient: LinearGradient): void, + + renderRadialGradient(bounds: Bounds, gradient: RadialGradient): void, renderRepeat( path: Path, @@ -265,9 +267,18 @@ export default class Renderer { backgroundImageSize.height ); - const gradient = parseGradient(background.source, gradientBounds); + const gradient = parseGradient(container, background.source, gradientBounds); if (gradient) { - this.target.renderLinearGradient(gradientBounds, gradient); + switch (gradient.type) { + case GRADIENT_TYPE.LINEAR_GRADIENT: + // $FlowFixMe + this.target.renderLinearGradient(gradientBounds, gradient); + break; + case GRADIENT_TYPE.RADIAL_GRADIENT: + // $FlowFixMe + this.target.renderRadialGradient(gradientBounds, gradient); + break; + } } } diff --git a/src/Util.js b/src/Util.js index fd414de..7d390e8 100644 --- a/src/Util.js +++ b/src/Util.js @@ -3,6 +3,8 @@ export const contains = (bit: number, value: number): boolean => (bit & value) !== 0; +export const distance = (a: number, b: number): number => Math.sqrt(a * a + b * b); + export const copyCSSStyles = (style: CSSStyleDeclaration, target: HTMLElement): HTMLElement => { // Edge does not provide value for cssText for (let i = style.length - 1; i >= 0; i--) { diff --git a/src/renderer/CanvasRenderer.js b/src/renderer/CanvasRenderer.js index ee5c3f4..781e60c 100644 --- a/src/renderer/CanvasRenderer.js +++ b/src/renderer/CanvasRenderer.js @@ -13,12 +13,23 @@ import type {Matrix} from '../parsing/transform'; import type {Bounds} from '../Bounds'; import type {ImageElement} from '../ResourceLoader'; -import type {Gradient} from '../Gradient'; +import type {LinearGradient, RadialGradient} from '../Gradient'; import type {TextBounds} from '../TextBounds'; import {PATH} from '../drawing/Path'; import {TEXT_DECORATION_LINE} from '../parsing/textDecoration'; +const addColorStops = ( + gradient: LinearGradient | RadialGradient, + canvasGradient: CanvasGradient +): void => { + const maxStop = Math.max.apply(null, gradient.colorStops.map(colorStop => colorStop.stop)); + const f = 1 / Math.max(1, maxStop); + gradient.colorStops.forEach(colorStop => { + canvasGradient.addColorStop(f * colorStop.stop, colorStop.color.toString()); + }); +}; + export default class CanvasRenderer implements RenderTarget { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; @@ -131,7 +142,7 @@ export default class CanvasRenderer implements RenderTarget { this.ctx.fillRect(x, y, width, height); } - renderLinearGradient(bounds: Bounds, gradient: Gradient) { + renderLinearGradient(bounds: Bounds, gradient: LinearGradient) { const linearGradient = this.ctx.createLinearGradient( bounds.left + gradient.direction.x1, bounds.top + gradient.direction.y1, @@ -139,14 +150,43 @@ export default class CanvasRenderer implements RenderTarget { bounds.top + gradient.direction.y0 ); - gradient.colorStops.forEach(colorStop => { - linearGradient.addColorStop(colorStop.stop, colorStop.color.toString()); - }); - + addColorStops(gradient, linearGradient); this.ctx.fillStyle = linearGradient; this.ctx.fillRect(bounds.left, bounds.top, bounds.width, bounds.height); } + renderRadialGradient(bounds: Bounds, gradient: RadialGradient) { + const x = bounds.left + gradient.center.x; + const y = bounds.top + gradient.center.y; + + const radialGradient = this.ctx.createRadialGradient(x, y, 0, x, y, gradient.radius.x); + if (!radialGradient) { + return; + } + + addColorStops(gradient, radialGradient); + this.ctx.fillStyle = radialGradient; + + if (gradient.radius.x !== gradient.radius.y) { + // transforms for elliptical radial gradient + const midX = bounds.left + 0.5 * bounds.width; + const midY = bounds.top + 0.5 * bounds.height; + const f = gradient.radius.y / gradient.radius.x; + const invF = 1 / f; + + this.transform(midX, midY, [1, 0, 0, f, 0, 0], () => + this.ctx.fillRect( + bounds.left, + invF * (bounds.top - midY) + midY, + bounds.width, + bounds.height * invF + ) + ); + } else { + this.ctx.fillRect(bounds.left, bounds.top, bounds.width, bounds.height); + } + } + renderRepeat( path: Path, image: ImageElement, diff --git a/src/renderer/RefTestRenderer.js b/src/renderer/RefTestRenderer.js index fa77189..0d5371e 100644 --- a/src/renderer/RefTestRenderer.js +++ b/src/renderer/RefTestRenderer.js @@ -18,7 +18,7 @@ import type {Matrix} from '../parsing/transform'; import type {Bounds} from '../Bounds'; import type {ImageElement} from '../ResourceLoader'; -import type {Gradient} from '../Gradient'; +import type {LinearGradient, RadialGradient} from '../Gradient'; import type {TextBounds} from '../TextBounds'; import {TEXT_DECORATION_STYLE, TEXT_DECORATION_LINE} from '../parsing/textDecoration'; @@ -110,7 +110,7 @@ class RefTestRenderer implements RenderTarget { return `Path (${string})`; } - renderLinearGradient(bounds: Bounds, gradient: Gradient) { + renderLinearGradient(bounds: Bounds, gradient: LinearGradient) { const direction = [ `x0: ${Math.round(gradient.direction.x0)}`, `x1: ${Math.round(gradient.direction.x1)}`, @@ -129,6 +129,19 @@ class RefTestRenderer implements RenderTarget { ); } + renderRadialGradient(bounds: Bounds, gradient: RadialGradient) { + const stops = gradient.colorStops.map( + stop => `${stop.color.toString()} ${Math.ceil(stop.stop * 100) / 100}` + ); + + this.writeLine( + `RadialGradient: ${this.formatBounds(bounds)} radial-gradient(${gradient.radius + .x} ${gradient.radius.y} at ${gradient.center.x} ${gradient.center.y}, ${stops.join( + ', ' + )})` + ); + } + renderRepeat( path: Path, image: ImageElement, diff --git a/tests/node/gradient.js b/tests/node/gradient.js new file mode 100644 index 0000000..3b2353b --- /dev/null +++ b/tests/node/gradient.js @@ -0,0 +1,151 @@ +const Gradient = require('../../dist/npm/Gradient'); +const assert = require('assert'); + +describe('Gradient', () => { + describe('transformWebkitRadialGradientArgs', () => { + it('white, black', () => { + assert.equal(Gradient.transformWebkitRadialGradientArgs(['white', 'black'])[0], ''); + }); + + it('circle, white, black', () => { + assert.equal( + Gradient.transformWebkitRadialGradientArgs(['circle', 'white', 'black'])[0], + 'circle' + ); + }); + + it('10% 30%, white, black', () => { + assert.equal( + Gradient.transformWebkitRadialGradientArgs(['10% 30%', 'white', 'black'])[0], + '10% 30%' + ); + }); + + it('30% 30%, closest-corner, white, black', () => { + assert.equal( + Gradient.transformWebkitRadialGradientArgs([ + '30% 30%', + 'closest-corner', + 'white', + 'black' + ])[0], + 'closest-corner at 30% 30%' + ); + }); + + it('30% 30%, circle closest-corner, white, black', () => { + assert.equal( + Gradient.transformWebkitRadialGradientArgs([ + '30% 30%', + 'circle closest-corner', + 'white', + 'black' + ])[0], + 'circle closest-corner at 30% 30%' + ); + }); + + it('center, 5em 40px, white, black', () => { + assert.equal( + Gradient.transformWebkitRadialGradientArgs([ + 'center', + '5em 40px', + 'white', + 'black' + ])[0], + '5em 40px at center' + ); + }); + + it('45 45, 10, 52 50, 30, from(#A7D30C), to(red)', () => { + assert.equal( + Gradient.transformWebkitRadialGradientArgs([ + '45 45', + '10', + '52 50', + '30', + 'from(#A7D30C)' + ])[0], + '30px at 52px 50px' + ); + }); + + it('75% 19%, ellipse closest-side, #ababab, #0000ff 33%,#991f1f 100%', () => { + assert.equal( + Gradient.transformWebkitRadialGradientArgs([ + '75% 19%', + 'ellipse closest-side', + '#ababab', + '#0000ff 33%', + '#991f1f 100%' + ])[0], + 'ellipse closest-side at 75% 19%' + ); + }); + + it('75% 19%, circle contain, #ababab, #0000ff 33%,#991f1f 100%', () => { + assert.equal( + Gradient.transformWebkitRadialGradientArgs([ + '75% 19%', + 'circle contain', + '#ababab', + '#0000ff 33%', + '#991f1f 100%' + ])[0], + 'circle closest-side at 75% 19%' + ); + }); + + it('75% 19%, circle cover, #ababab, #0000ff 33%,#991f1f 100%', () => { + assert.equal( + Gradient.transformWebkitRadialGradientArgs([ + '75% 19%', + 'circle cover', + '#ababab', + '#0000ff 33%', + '#991f1f 100%' + ])[0], + 'circle farthest-corner at 75% 19%' + ); + }); + + it('right 19%, ellipse cover, #ababab, #0000ff 33%,#991f1f 100%', () => { + assert.equal( + Gradient.transformWebkitRadialGradientArgs([ + 'right 19%', + 'ellipse cover', + '#ababab', + '#0000ff 33%', + '#991f1f 100%' + ])[0], + 'ellipse farthest-corner at right 19%' + ); + }); + + it('left 19%, ellipse cover, #ababab, #0000ff 33%,#991f1f 100%', () => { + assert.equal( + Gradient.transformWebkitRadialGradientArgs([ + 'left 19%', + 'ellipse cover', + '#ababab', + '#0000ff 33%', + '#991f1f 100%' + ])[0], + 'ellipse farthest-corner at left 19%' + ); + }); + + it('left top, circle cover, #ababab, #0000ff 33%,#991f1f 100%', () => { + assert.equal( + Gradient.transformWebkitRadialGradientArgs([ + 'left top', + 'circle cover', + '#ababab', + '#0000ff 33%', + '#991f1f 100%' + ])[0], + 'circle farthest-corner at left top' + ); + }); + }); +}); diff --git a/tests/reftests/background/radial-gradient2.html b/tests/reftests/background/radial-gradient2.html new file mode 100644 index 0000000..059f474 --- /dev/null +++ b/tests/reftests/background/radial-gradient2.html @@ -0,0 +1,51 @@ + + + + Background attribute tests + + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + From cacb9f64e44b0789e47433b7c97a90c146e7bee9 Mon Sep 17 00:00:00 2001 From: Niklas von Hertzen Date: Tue, 12 Dec 2017 20:57:48 +0800 Subject: [PATCH 2/2] Radial gradient support --- CHANGELOG.md | 1 + docs/features.md | 2 +- src/Gradient.js | 74 ++++++++++++++++++++++++++++++------------------ 3 files changed, 49 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aec615b..2c85496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ### Changelog ### #### v1.0.0-alpha4 - TBD #### + * Add support for radial-gradients * Fix logging option (#1302) * Add support for rendering webgl canvas content (#646) * Fix external SVG loading with proxies (#802) diff --git a/docs/features.md b/docs/features.md index b6af7e1..7c0cd74 100644 --- a/docs/features.md +++ b/docs/features.md @@ -11,6 +11,7 @@ Below is a list of all the supported CSS properties and values. - background-image - url() - linear-gradient() + - radial-gradient() - background-origin - background-position - background-size @@ -68,7 +69,6 @@ These CSS properties are **NOT** currently supported - [filter](https://github.com/niklasvh/html2canvas/issues/493) - [font-variant-ligatures](https://github.com/niklasvh/html2canvas/pull/1085) - [list-style](https://github.com/niklasvh/html2canvas/issues/177) - - radial-gradient() - [repeating-linear-gradient()](https://github.com/niklasvh/html2canvas/issues/1162) - word-break - [writing-mode](https://github.com/niklasvh/html2canvas/issues/1258) diff --git a/src/Gradient.js b/src/Gradient.js index 78aa405..a197dbd 100644 --- a/src/Gradient.js +++ b/src/Gradient.js @@ -32,19 +32,10 @@ export type ColorStop = { stop: number }; -export type LinearGradient = { +export interface Gradient { type: GradientType, - direction: Direction, colorStops: Array -}; - -export type RadialGradient = { - type: GradientType, - shape: RadialGradientShapeType, - center: Point, - radius: Point, - colorStops: Array -}; +} export const GRADIENT_TYPE = { LINEAR_GRADIENT: 0, @@ -68,11 +59,44 @@ const LENGTH_FOR_POSITION = { bottom: new Length('100%') }; +export class LinearGradient implements Gradient { + type: GradientType; + colorStops: Array; + direction: Direction; + + constructor(colorStops: Array, direction: Direction) { + this.type = GRADIENT_TYPE.LINEAR_GRADIENT; + this.colorStops = colorStops; + this.direction = direction; + } +} + +export class RadialGradient implements Gradient { + type: GradientType; + colorStops: Array; + shape: RadialGradientShapeType; + center: Point; + radius: Point; + + constructor( + colorStops: Array, + shape: RadialGradientShapeType, + center: Point, + radius: Point + ) { + this.type = GRADIENT_TYPE.RADIAL_GRADIENT; + this.colorStops = colorStops; + this.shape = shape; + this.center = center; + this.radius = radius; + } +} + export const parseGradient = ( container: NodeContainer, {args, method, prefix}: BackgroundSource, bounds: Bounds -): ?LinearGradient | RadialGradient => { +): ?Gradient => { if (method === 'linear-gradient') { return parseLinearGradient(args, bounds, !!prefix); } else if (method === 'gradient' && args[0] === 'linear') { @@ -83,10 +107,11 @@ export const parseGradient = ( !!prefix ); } else if (method === 'radial-gradient') { - if (prefix === '-webkit-') { - args = transformWebkitRadialGradientArgs(args); - } - return parseRadialGradient(container, args, bounds); + return parseRadialGradient( + container, + prefix === '-webkit-' ? transformWebkitRadialGradientArgs(args) : args, + bounds + ); } else if (method === 'gradient' && args[0] === 'radial') { return parseRadialGradient( container, @@ -176,11 +201,7 @@ const parseLinearGradient = ( bounds.height * 2 ); - return { - type: GRADIENT_TYPE.LINEAR_GRADIENT, - direction, - colorStops: parseColorStops(args, firstColorStopIndex, lineLength) - }; + return new LinearGradient(parseColorStops(args, firstColorStopIndex, lineLength), direction); }; const parseRadialGradient = ( @@ -238,13 +259,12 @@ const parseRadialGradient = ( bounds ); - return { - type: GRADIENT_TYPE.RADIAL_GRADIENT, + return new RadialGradient( + parseColorStops(args, m ? 1 : 0, Math.min(gradientRadius.x, gradientRadius.y)), shape, - center: gradientCenter, - radius: gradientRadius, - colorStops: parseColorStops(args, m ? 1 : 0, Math.min(gradientRadius.x, gradientRadius.y)) - }; + gradientCenter, + gradientRadius + ); }; const calculateGradientDirection = (radian: number, bounds: Bounds): Direction => {