From a2895691baadc8301a2ee4f7ea64416a5cd0c2ce Mon Sep 17 00:00:00 2001 From: Niklas von Hertzen Date: Sun, 6 Aug 2017 20:21:35 +0800 Subject: [PATCH] Extract render target logic out of renderer to be target agnostic --- src/CanvasRenderer.js | 510 --------------------------------- src/Gradient.js | 5 +- src/Renderer.js | 364 +++++++++++++++++++++++ src/index.js | 18 +- src/parsing/textDecoration.js | 12 +- src/renderer/CanvasRenderer.js | 266 +++++++++++++++++ 6 files changed, 652 insertions(+), 523 deletions(-) delete mode 100644 src/CanvasRenderer.js create mode 100644 src/Renderer.js create mode 100644 src/renderer/CanvasRenderer.js diff --git a/src/CanvasRenderer.js b/src/CanvasRenderer.js deleted file mode 100644 index 029f3d0a2..000000000 --- a/src/CanvasRenderer.js +++ /dev/null @@ -1,510 +0,0 @@ -/* @flow */ -'use strict'; - -import type Color from './Color'; -import type Size from './drawing/Size'; - -import type {BackgroundImage} from './parsing/background'; -import type {Border, BorderSide} from './parsing/border'; -import type {TextShadow} from './parsing/textShadow'; - -import type {Path, BoundCurves} from './Bounds'; -import type {ImageStore, ImageElement} from './ImageLoader'; -import type NodeContainer from './NodeContainer'; -import type StackingContext from './StackingContext'; -import type {TextBounds} from './TextBounds'; - -import BezierCurve from './drawing/BezierCurve'; -import Circle from './drawing/Circle'; -import Vector from './drawing/Vector'; - -import { - parsePathForBorder, - calculateContentBox, - calculatePaddingBox, - calculatePaddingBoxPath -} from './Bounds'; -import {FontMetrics} from './Font'; -import {parseGradient} from './Gradient'; -import TextContainer from './TextContainer'; - -import { - BACKGROUND_ORIGIN, - calculateBackgroungPaintingArea, - calculateBackgroundPosition, - calculateBackgroundRepeatPath, - calculateBackgroundSize -} from './parsing/background'; -import {BORDER_STYLE} from './parsing/border'; -import {TEXT_DECORATION_LINE} from './parsing/textDecoration'; - -export type RenderOptions = { - scale: number, - backgroundColor: ?Color, - imageStore: ImageStore, - fontMetrics: FontMetrics -}; - -export default class CanvasRenderer { - canvas: HTMLCanvasElement; - ctx: CanvasRenderingContext2D; - options: RenderOptions; - - constructor(canvas: HTMLCanvasElement, options: RenderOptions) { - this.canvas = canvas; - this.ctx = canvas.getContext('2d'); - this.options = options; - } - - renderNode(container: NodeContainer) { - if (container.isVisible()) { - this.renderNodeBackgroundAndBorders(container); - this.renderNodeContent(container); - } - } - - renderNodeContent(container: NodeContainer) { - this.ctx.save(); - const clipPaths = container.getClipPaths(); - if (clipPaths.length) { - clipPaths.forEach(path => { - this.path(path); - this.ctx.clip(); - }); - } - - if (container.childNodes.length) { - this.ctx.fillStyle = container.style.color.toString(); - this.ctx.font = [ - container.style.font.fontStyle, - container.style.font.fontVariant, - container.style.font.fontWeight, - container.style.font.fontSize, - container.style.font.fontFamily - ] - .join(' ') - .split(',')[0]; - container.childNodes.forEach(child => { - if (child instanceof TextContainer) { - this.renderTextNode(child); - } else { - this.path(child); - this.ctx.fill(); - } - }); - } - - if (container.image) { - const image = this.options.imageStore.get(container.image); - if (image) { - const contentBox = calculateContentBox( - container.bounds, - container.style.padding, - container.style.border - ); - const width = typeof image.width === 'number' ? image.width : contentBox.width; - const height = typeof image.height === 'number' ? image.height : contentBox.height; - this.ctx.save(); - this.path(calculatePaddingBoxPath(container.curvedBounds)); - this.ctx.clip(); - this.ctx.drawImage( - image, - 0, - 0, - width, - height, - contentBox.left, - contentBox.top, - contentBox.width, - contentBox.height - ); - this.ctx.restore(); - } - } - - this.ctx.restore(); - } - - renderNodeBackgroundAndBorders(container: NodeContainer) { - this.ctx.save(); - if (container.parent) { - const clipPaths = container.parent.getClipPaths(); - if (clipPaths.length) { - clipPaths.forEach(path => { - this.path(path); - this.ctx.clip(); - }); - } - } - - const backgroungPaintingArea = calculateBackgroungPaintingArea( - container.curvedBounds, - container.style.background.backgroundClip - ); - this.path(backgroungPaintingArea); - if (!container.style.background.backgroundColor.isTransparent()) { - this.ctx.fillStyle = container.style.background.backgroundColor.toString(); - this.ctx.fill(); - } - - this.ctx.save(); - this.ctx.clip(); - this.renderBackgroundImage(container); - this.ctx.restore(); - container.style.border.forEach((border, side) => { - this.renderBorder(border, side, container.curvedBounds); - }); - this.ctx.restore(); - } - - renderTextNode(textContainer: TextContainer) { - textContainer.bounds.forEach(text => this.renderText(text, textContainer)); - } - - renderText(text: TextBounds, textContainer: TextContainer) { - const container = textContainer.parent; - this.ctx.fillStyle = container.style.color.toString(); - if (container.style.textShadow && text.text.trim().length) { - container.style.textShadow.slice(0).reverse().forEach(textShadow => { - this.ctx.shadowColor = textShadow.color.toString(); - this.ctx.shadowOffsetX = textShadow.offsetX * this.options.scale; - this.ctx.shadowOffsetY = textShadow.offsetY * this.options.scale; - this.ctx.shadowBlur = textShadow.blur; - - this.ctx.fillText( - text.text, - text.bounds.left, - text.bounds.top + text.bounds.height - ); - }); - } else { - this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + text.bounds.height); - } - - const textDecoration = container.style.textDecoration; - if (textDecoration) { - textDecoration.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 - const {baseline} = this.options.fontMetrics.getMetrics( - container.style.font - ); - this.rectangle( - text.bounds.left, - Math.round(text.bounds.top + baseline), - text.bounds.width, - 1, - textDecoration.textDecorationColor || container.style.color - ); - break; - case TEXT_DECORATION_LINE.OVERLINE: - this.rectangle( - text.bounds.left, - Math.round(text.bounds.top), - text.bounds.width, - 1, - textDecoration.textDecorationColor || container.style.color - ); - break; - case TEXT_DECORATION_LINE.LINE_THROUGH: - // TODO try and find exact position for line-through - const {middle} = this.options.fontMetrics.getMetrics(container.style.font); - this.rectangle( - text.bounds.left, - Math.ceil(text.bounds.top + middle), - text.bounds.width, - 1, - textDecoration.textDecorationColor || container.style.color - ); - break; - } - }); - } - } - - renderBackgroundImage(container: NodeContainer) { - container.style.background.backgroundImage.reverse().forEach(backgroundImage => { - if (backgroundImage.source.method === 'url' && backgroundImage.source.args.length) { - this.renderBackgroundRepeat(container, backgroundImage); - } else { - const gradient = parseGradient(backgroundImage.source, container.bounds); - if (gradient) { - const bounds = container.bounds; - const grad = this.ctx.createLinearGradient( - bounds.left + gradient.direction.x1, - bounds.top + gradient.direction.y1, - bounds.left + gradient.direction.x0, - bounds.top + gradient.direction.y0 - ); - - gradient.colorStops.forEach(colorStop => { - grad.addColorStop(colorStop.stop, colorStop.color.toString()); - }); - - this.ctx.fillStyle = grad; - this.ctx.fillRect(bounds.left, bounds.top, bounds.width, bounds.height); - } - } - }); - } - - renderBackgroundRepeat(container: NodeContainer, background: BackgroundImage) { - const image = this.options.imageStore.get(background.source.args[0]); - if (image) { - const bounds = container.bounds; - const paddingBox = calculatePaddingBox(bounds, container.style.border); - const backgroundImageSize = calculateBackgroundSize(background, image, bounds); - - // TODO support CONTENT_BOX - const backgroundPositioningArea = - container.style.background.backgroundOrigin === BACKGROUND_ORIGIN.BORDER_BOX - ? bounds - : paddingBox; - - const position = calculateBackgroundPosition( - background.position, - backgroundImageSize, - backgroundPositioningArea - ); - const path = calculateBackgroundRepeatPath( - background, - position, - backgroundImageSize, - backgroundPositioningArea, - bounds - ); - this.path(path); - const offsetX = Math.round(paddingBox.left + position.x); - const offsetY = Math.round(paddingBox.top + position.y); - this.ctx.fillStyle = this.ctx.createPattern( - this.resizeImage(image, backgroundImageSize), - 'repeat' - ); - this.ctx.translate(offsetX, offsetY); - this.ctx.fill(); - this.ctx.translate(-offsetX, -offsetY); - } - } - - resizeImage(image: ImageElement, size: Size) { - if (image.width === size.width && image.height === size.height) { - return image; - } - - const canvas = document.createElement('canvas'); - canvas.width = size.width; - canvas.height = size.height; - const ctx = canvas.getContext('2d'); - ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, size.width, size.height); - return canvas; - } - - renderBorder(border: Border, side: BorderSide, curvePoints: BoundCurves) { - if (border.borderStyle !== BORDER_STYLE.NONE && !border.borderColor.isTransparent()) { - const path = parsePathForBorder(curvePoints, side); - this.path(path); - this.ctx.fillStyle = border.borderColor.toString(); - this.ctx.fill(); - } - } - - path(path: Path) { - this.ctx.beginPath(); - if (path instanceof Circle) { - this.ctx.arc( - path.x + path.radius, - path.y + path.radius, - path.radius, - 0, - Math.PI * 2, - true - ); - } else { - path.forEach((point, index) => { - const start = point instanceof Vector ? point : point.start; - if (index === 0) { - this.ctx.moveTo(start.x, start.y); - } else { - this.ctx.lineTo(start.x, start.y); - } - - if (point instanceof BezierCurve) { - this.ctx.bezierCurveTo( - point.startControl.x, - point.startControl.y, - point.endControl.x, - point.endControl.y, - point.end.x, - point.end.y - ); - } - }); - } - - this.ctx.closePath(); - } - - rectangle(x: number, y: number, width: number, height: number, color: Color) { - this.ctx.fillStyle = color.toString(); - this.ctx.fillRect(x, y, width, height); - } - - renderStack(stack: StackingContext) { - if (stack.container.isVisible()) { - this.ctx.globalAlpha = stack.getOpacity(); - const transform = stack.container.style.transform; - if (transform !== null) { - this.ctx.save(); - this.ctx.translate( - stack.container.bounds.left + transform.transformOrigin[0].value, - stack.container.bounds.top + transform.transformOrigin[1].value - ); - this.ctx.transform( - transform.transform[0], - transform.transform[1], - transform.transform[2], - transform.transform[3], - transform.transform[4], - transform.transform[5] - ); - this.ctx.translate( - -(stack.container.bounds.left + transform.transformOrigin[0].value), - -(stack.container.bounds.top + transform.transformOrigin[1].value) - ); - } - const [ - negativeZIndex, - zeroOrAutoZIndexOrTransformedOrOpacity, - positiveZIndex, - nonPositionedFloats, - nonPositionedInlineLevel - ] = splitStackingContexts(stack); - const [inlineLevel, nonInlineLevel] = splitDescendants(stack); - - // https://www.w3.org/TR/css-position-3/#painting-order - // 1. the background and borders of the element forming the stacking context. - this.renderNodeBackgroundAndBorders(stack.container); - // 2. the child stacking contexts with negative stack levels (most negative first). - negativeZIndex.sort(sortByZIndex).forEach(this.renderStack, this); - // 3. For all its in-flow, non-positioned, block-level descendants in tree order: - this.renderNodeContent(stack.container); - nonInlineLevel.forEach(this.renderNode, this); - // 4. All non-positioned floating descendants, in tree order. For each one of these, - // treat the element as if it created a new stacking context, but any positioned descendants and descendants - // which actually create a new stacking context should be considered part of the parent stacking context, - // not this new one. - nonPositionedFloats.forEach(this.renderStack, this); - // 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks. - nonPositionedInlineLevel.forEach(this.renderStack, this); - inlineLevel.forEach(this.renderNode, this); - // 6. All positioned, opacity or transform descendants, in tree order that fall into the following categories: - // All positioned descendants with 'z-index: auto' or 'z-index: 0', in tree order. - // For those with 'z-index: auto', treat the element as if it created a new stacking context, - // but any positioned descendants and descendants which actually create a new stacking context should be - // considered part of the parent stacking context, not this new one. For those with 'z-index: 0', - // treat the stacking context generated atomically. - // - // All opacity descendants with opacity less than 1 - // - // All transform descendants with transform other than none - zeroOrAutoZIndexOrTransformedOrOpacity.forEach(this.renderStack, this); - // 7. Stacking contexts formed by positioned descendants with z-indices greater than or equal to 1 in z-index - // order (smallest first) then tree order. - positiveZIndex.sort(sortByZIndex).forEach(this.renderStack, this); - - if (transform !== null) { - this.ctx.restore(); - } - } - } - - render(stack: StackingContext): Promise { - this.ctx.scale(this.options.scale, this.options.scale); - this.ctx.textBaseline = 'bottom'; - if (this.options.backgroundColor) { - this.rectangle( - 0, - 0, - this.canvas.width, - this.canvas.height, - this.options.backgroundColor - ); - } - this.renderStack(stack); - return Promise.resolve(this.canvas); - } -} - -const splitDescendants = (stack: StackingContext): [Array, Array] => { - const inlineLevel = []; - const nonInlineLevel = []; - - const length = stack.children.length; - for (let i = 0; i < length; i++) { - let child = stack.children[i]; - if (child.isInlineLevel()) { - inlineLevel.push(child); - } else { - nonInlineLevel.push(child); - } - } - return [inlineLevel, nonInlineLevel]; -}; - -const splitStackingContexts = ( - stack: StackingContext -): [ - Array, - Array, - Array, - Array, - Array -] => { - const negativeZIndex = []; - const zeroOrAutoZIndexOrTransformedOrOpacity = []; - const positiveZIndex = []; - const nonPositionedFloats = []; - const nonPositionedInlineLevel = []; - const length = stack.contexts.length; - for (let i = 0; i < length; i++) { - let child = stack.contexts[i]; - if ( - child.container.isPositioned() || - child.container.style.opacity < 1 || - child.container.isTransformed() - ) { - if (child.container.style.zIndex.order < 0) { - negativeZIndex.push(child); - } else if (child.container.style.zIndex.order > 0) { - positiveZIndex.push(child); - } else { - zeroOrAutoZIndexOrTransformedOrOpacity.push(child); - } - } else { - if (child.container.isFloating()) { - nonPositionedFloats.push(child); - } else { - nonPositionedInlineLevel.push(child); - } - } - } - return [ - negativeZIndex, - zeroOrAutoZIndexOrTransformedOrOpacity, - positiveZIndex, - nonPositionedFloats, - nonPositionedInlineLevel - ]; -}; - -const sortByZIndex = (a: StackingContext, b: StackingContext): number => { - if (a.container.style.zIndex.order > b.container.style.zIndex.order) { - return 1; - } else if (a.container.style.zIndex.order < b.container.style.zIndex.order) { - return -1; - } - return 0; -}; diff --git a/src/Gradient.js b/src/Gradient.js index 3c01a7524..08b52a535 100644 --- a/src/Gradient.js +++ b/src/Gradient.js @@ -27,7 +27,10 @@ export type Gradient = { colorStops: Array }; -export const parseGradient = ({args, method, prefix}: BackgroundSource, bounds: Bounds) => { +export const parseGradient = ( + {args, method, prefix}: BackgroundSource, + bounds: Bounds +): ?Gradient => { if (method === 'linear-gradient') { return parseLinearGradient(args, bounds); } diff --git a/src/Renderer.js b/src/Renderer.js new file mode 100644 index 000000000..0c3759ddb --- /dev/null +++ b/src/Renderer.js @@ -0,0 +1,364 @@ +/* @flow */ +'use strict'; + +import type Color from './Color'; +import type Size from './drawing/Size'; +import type Logger from './Logger'; + +import type {BackgroundImage} from './parsing/background'; +import type {Border, BorderSide} from './parsing/border'; +import type {Font} from './parsing/font'; +import type {TextDecoration} from './parsing/textDecoration'; +import type {TextShadow} from './parsing/textShadow'; +import type {Matrix} from './parsing/transform'; + +import type {Path, BoundCurves} from './Bounds'; +import type {Gradient} from './Gradient'; +import type {ImageStore, ImageElement} from './ImageLoader'; +import type NodeContainer from './NodeContainer'; +import type StackingContext from './StackingContext'; +import type {TextBounds} from './TextBounds'; + +import { + Bounds, + parsePathForBorder, + calculateContentBox, + calculatePaddingBox, + calculatePaddingBoxPath +} from './Bounds'; +import {FontMetrics} from './Font'; +import {parseGradient} from './Gradient'; +import TextContainer from './TextContainer'; + +import { + BACKGROUND_ORIGIN, + calculateBackgroungPaintingArea, + calculateBackgroundPosition, + calculateBackgroundRepeatPath, + calculateBackgroundSize +} from './parsing/background'; +import {BORDER_STYLE} from './parsing/border'; + +export type RenderOptions = { + scale: number, + backgroundColor: ?Color, + imageStore: ImageStore, + fontMetrics: FontMetrics, + logger: Logger, + width: number, + height: number +}; + +export interface RenderTarget { + clip(clipPaths: Array, callback: () => void): void, + + drawImage(image: ImageElement, source: Bounds, destination: Bounds): void, + + drawShape(path: Path, color: Color): void, + + fill(color: Color): void, + + getTarget(): Promise, + + rectangle(x: number, y: number, width: number, height: number, color: Color): void, + + renderLinearGradient(bounds: Bounds, gradient: Gradient): void, + + renderRepeat( + path: Path, + image: ImageElement, + imageSize: Size, + offsetX: number, + offsetY: number + ): void, + + renderTextNode( + textBounds: Array, + color: Color, + font: Font, + textDecoration: TextDecoration, + textShadows: Array | null + ): void, + + setOpacity(opacity: number): void, + + transform(offsetX: number, offsetY: number, matrix: Matrix, callback: () => void): void +} + +export default class Renderer { + target: RenderTarget; + options: RenderOptions; + + constructor(target: RenderTarget, options: RenderOptions) { + this.target = target; + this.options = options; + } + + renderNode(container: NodeContainer) { + if (container.isVisible()) { + this.renderNodeBackgroundAndBorders(container); + this.renderNodeContent(container); + } + } + + renderNodeContent(container: NodeContainer) { + this.target.clip(container.getClipPaths(), () => { + if (container.childNodes.length) { + container.childNodes.forEach(child => { + if (child instanceof TextContainer) { + const style = child.parent.style; + this.target.renderTextNode( + child.bounds, + style.color, + style.font, + style.textDecoration, + style.textShadow + ); + } else { + this.target.drawShape(child, container.style.color); + } + }); + } + + if (container.image) { + const image = this.options.imageStore.get(container.image); + if (image) { + const contentBox = calculateContentBox( + container.bounds, + container.style.padding, + container.style.border + ); + const width = typeof image.width === 'number' ? image.width : contentBox.width; + const height = + typeof image.height === 'number' ? image.height : contentBox.height; + this.target.clip([calculatePaddingBoxPath(container.curvedBounds)], () => { + this.target.drawImage(image, new Bounds(0, 0, width, height), contentBox); + }); + } + } + }); + } + + renderNodeBackgroundAndBorders(container: NodeContainer) { + this.target.clip(container.parent ? container.parent.getClipPaths() : [], () => { + const backgroundPaintingArea = calculateBackgroungPaintingArea( + container.curvedBounds, + container.style.background.backgroundClip + ); + this.target.clip([backgroundPaintingArea], () => { + if (!container.style.background.backgroundColor.isTransparent()) { + this.target.fill(container.style.background.backgroundColor); + } + + this.renderBackgroundImage(container); + }); + + container.style.border.forEach((border, side) => { + this.renderBorder(border, side, container.curvedBounds); + }); + }); + } + + renderBackgroundImage(container: NodeContainer) { + container.style.background.backgroundImage.reverse().forEach(backgroundImage => { + if (backgroundImage.source.method === 'url' && backgroundImage.source.args.length) { + this.renderBackgroundRepeat(container, backgroundImage); + } else { + const gradient = parseGradient(backgroundImage.source, container.bounds); + if (gradient) { + const bounds = container.bounds; + this.target.renderLinearGradient(bounds, gradient); + } + } + }); + } + + renderBackgroundRepeat(container: NodeContainer, background: BackgroundImage) { + const image = this.options.imageStore.get(background.source.args[0]); + if (image) { + const bounds = container.bounds; + const paddingBox = calculatePaddingBox(bounds, container.style.border); + const backgroundImageSize = calculateBackgroundSize(background, image, bounds); + + // TODO support CONTENT_BOX + const backgroundPositioningArea = + container.style.background.backgroundOrigin === BACKGROUND_ORIGIN.BORDER_BOX + ? bounds + : paddingBox; + + const position = calculateBackgroundPosition( + background.position, + backgroundImageSize, + backgroundPositioningArea + ); + const path = calculateBackgroundRepeatPath( + background, + position, + backgroundImageSize, + backgroundPositioningArea, + bounds + ); + + const offsetX = Math.round(paddingBox.left + position.x); + const offsetY = Math.round(paddingBox.top + position.y); + this.target.renderRepeat(path, image, backgroundImageSize, offsetX, offsetY); + } + } + + renderBorder(border: Border, side: BorderSide, curvePoints: BoundCurves) { + if (border.borderStyle !== BORDER_STYLE.NONE && !border.borderColor.isTransparent()) { + this.target.drawShape(parsePathForBorder(curvePoints, side), border.borderColor); + } + } + + renderStack(stack: StackingContext) { + if (stack.container.isVisible()) { + this.target.setOpacity(stack.getOpacity()); + const transform = stack.container.style.transform; + if (transform !== null) { + this.target.transform( + stack.container.bounds.left + transform.transformOrigin[0].value, + stack.container.bounds.top + transform.transformOrigin[1].value, + transform.transform, + () => this.renderStackContent(stack) + ); + } else { + this.renderStackContent(stack); + } + } + } + + renderStackContent(stack: StackingContext) { + const [ + negativeZIndex, + zeroOrAutoZIndexOrTransformedOrOpacity, + positiveZIndex, + nonPositionedFloats, + nonPositionedInlineLevel + ] = splitStackingContexts(stack); + const [inlineLevel, nonInlineLevel] = splitDescendants(stack); + + // https://www.w3.org/TR/css-position-3/#painting-order + // 1. the background and borders of the element forming the stacking context. + this.renderNodeBackgroundAndBorders(stack.container); + // 2. the child stacking contexts with negative stack levels (most negative first). + negativeZIndex.sort(sortByZIndex).forEach(this.renderStack, this); + // 3. For all its in-flow, non-positioned, block-level descendants in tree order: + this.renderNodeContent(stack.container); + nonInlineLevel.forEach(this.renderNode, this); + // 4. All non-positioned floating descendants, in tree order. For each one of these, + // treat the element as if it created a new stacking context, but any positioned descendants and descendants + // which actually create a new stacking context should be considered part of the parent stacking context, + // not this new one. + nonPositionedFloats.forEach(this.renderStack, this); + // 5. the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks. + nonPositionedInlineLevel.forEach(this.renderStack, this); + inlineLevel.forEach(this.renderNode, this); + // 6. All positioned, opacity or transform descendants, in tree order that fall into the following categories: + // All positioned descendants with 'z-index: auto' or 'z-index: 0', in tree order. + // For those with 'z-index: auto', treat the element as if it created a new stacking context, + // but any positioned descendants and descendants which actually create a new stacking context should be + // considered part of the parent stacking context, not this new one. For those with 'z-index: 0', + // treat the stacking context generated atomically. + // + // All opacity descendants with opacity less than 1 + // + // All transform descendants with transform other than none + zeroOrAutoZIndexOrTransformedOrOpacity.forEach(this.renderStack, this); + // 7. Stacking contexts formed by positioned descendants with z-indices greater than or equal to 1 in z-index + // order (smallest first) then tree order. + positiveZIndex.sort(sortByZIndex).forEach(this.renderStack, this); + } + + render(stack: StackingContext): Promise { + if (this.options.backgroundColor) { + this.target.rectangle( + 0, + 0, + this.options.width, + this.options.height, + this.options.backgroundColor + ); + } + this.renderStack(stack); + const target = this.target.getTarget(); + if (__DEV__) { + return target.then(output => { + this.options.logger.log(`Render completed`); + return output; + }); + } + return target; + } +} + +const splitDescendants = (stack: StackingContext): [Array, Array] => { + const inlineLevel = []; + const nonInlineLevel = []; + + const length = stack.children.length; + for (let i = 0; i < length; i++) { + let child = stack.children[i]; + if (child.isInlineLevel()) { + inlineLevel.push(child); + } else { + nonInlineLevel.push(child); + } + } + return [inlineLevel, nonInlineLevel]; +}; + +const splitStackingContexts = ( + stack: StackingContext +): [ + Array, + Array, + Array, + Array, + Array +] => { + const negativeZIndex = []; + const zeroOrAutoZIndexOrTransformedOrOpacity = []; + const positiveZIndex = []; + const nonPositionedFloats = []; + const nonPositionedInlineLevel = []; + const length = stack.contexts.length; + for (let i = 0; i < length; i++) { + let child = stack.contexts[i]; + if ( + child.container.isPositioned() || + child.container.style.opacity < 1 || + child.container.isTransformed() + ) { + if (child.container.style.zIndex.order < 0) { + negativeZIndex.push(child); + } else if (child.container.style.zIndex.order > 0) { + positiveZIndex.push(child); + } else { + zeroOrAutoZIndexOrTransformedOrOpacity.push(child); + } + } else { + if (child.container.isFloating()) { + nonPositionedFloats.push(child); + } else { + nonPositionedInlineLevel.push(child); + } + } + } + return [ + negativeZIndex, + zeroOrAutoZIndexOrTransformedOrOpacity, + positiveZIndex, + nonPositionedFloats, + nonPositionedInlineLevel + ]; +}; + +const sortByZIndex = (a: StackingContext, b: StackingContext): number => { + if (a.container.style.zIndex.order > b.container.style.zIndex.order) { + return 1; + } else if (a.container.style.zIndex.order < b.container.style.zIndex.order) { + return -1; + } + return 0; +}; diff --git a/src/index.js b/src/index.js index c072e387f..1c1172a84 100644 --- a/src/index.js +++ b/src/index.js @@ -2,7 +2,8 @@ 'use strict'; import {NodeParser} from './NodeParser'; -import CanvasRenderer from './CanvasRenderer'; +import Renderer from './Renderer'; +import CanvasRenderer from './renderer/CanvasRenderer'; import Logger from './Logger'; import ImageLoader from './ImageLoader'; import {Bounds, parseDocumentSize} from './Bounds'; @@ -110,12 +111,19 @@ const html2canvas = (element: HTMLElement, config: Options): Promise; export type TextDecorationLine = $Values; type TextDecorationLineType = Array | null; -export type TextDecoration = - | { - textDecorationLine: Array, - textDecorationStyle: TextDecorationStyle, - textDecorationColor: Color | null - } - | $Values; +export type TextDecoration = { + textDecorationLine: Array, + textDecorationStyle: TextDecorationStyle, + textDecorationColor: Color | null +} | null; const parseLine = (line: string): TextDecorationLine => { switch (line) { diff --git a/src/renderer/CanvasRenderer.js b/src/renderer/CanvasRenderer.js new file mode 100644 index 000000000..601f91246 --- /dev/null +++ b/src/renderer/CanvasRenderer.js @@ -0,0 +1,266 @@ +/* @flow */ +'use strict'; + +import type {RenderTarget, RenderOptions} from '../Renderer'; + +import type Color from '../Color'; +import type Size from '../drawing/Size'; + +import type {Font} from '../parsing/font'; +import type {TextDecoration} from '../parsing/textDecoration'; +import type {TextShadow} from '../parsing/textShadow'; +import type {Matrix} from '../parsing/transform'; + +import type {Path, Bounds} from '../Bounds'; +import type {ImageElement} from '../ImageLoader'; +import type {Gradient} from '../Gradient'; +import type {TextBounds} from '../TextBounds'; + +import BezierCurve from '../drawing/BezierCurve'; +import Circle from '../drawing/Circle'; +import Vector from '../drawing/Vector'; + +import {TEXT_DECORATION_LINE} from '../parsing/textDecoration'; + +export default class CanvasRenderer implements RenderTarget { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + options: RenderOptions; + + constructor(canvas: HTMLCanvasElement, options: RenderOptions) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.options = options; + + this.ctx.scale(this.options.scale, this.options.scale); + this.ctx.textBaseline = 'bottom'; + options.logger.log(`Canvas renderer initialized with scale ${this.options.scale}`); + } + + clip(clipPaths: Array, callback: () => void) { + if (clipPaths.length) { + this.ctx.save(); + clipPaths.forEach(path => { + this.path(path); + this.ctx.clip(); + }); + } + + callback(); + + if (clipPaths.length) { + this.ctx.restore(); + } + } + + drawImage(image: ImageElement, source: Bounds, destination: Bounds) { + this.ctx.drawImage( + image, + source.left, + source.top, + source.width, + source.height, + destination.left, + destination.top, + destination.width, + destination.height + ); + } + + drawShape(path: Path, color: Color) { + this.path(path); + this.ctx.fillStyle = color.toString(); + this.ctx.fill(); + } + + fill(color: Color) { + this.ctx.fillStyle = color.toString(); + this.ctx.fill(); + } + + getTarget(): Promise { + return Promise.resolve(this.canvas); + } + + path(path: Path) { + this.ctx.beginPath(); + if (path instanceof Circle) { + this.ctx.arc( + path.x + path.radius, + path.y + path.radius, + path.radius, + 0, + Math.PI * 2, + true + ); + } else { + path.forEach((point, index) => { + const start = point instanceof Vector ? point : point.start; + if (index === 0) { + this.ctx.moveTo(start.x, start.y); + } else { + this.ctx.lineTo(start.x, start.y); + } + + if (point instanceof BezierCurve) { + this.ctx.bezierCurveTo( + point.startControl.x, + point.startControl.y, + point.endControl.x, + point.endControl.y, + point.end.x, + point.end.y + ); + } + }); + } + + this.ctx.closePath(); + } + + rectangle(x: number, y: number, width: number, height: number, color: Color) { + this.ctx.fillStyle = color.toString(); + this.ctx.fillRect(x, y, width, height); + } + + renderLinearGradient(bounds: Bounds, gradient: Gradient) { + const linearGradient = this.ctx.createLinearGradient( + bounds.left + gradient.direction.x1, + bounds.top + gradient.direction.y1, + bounds.left + gradient.direction.x0, + bounds.top + gradient.direction.y0 + ); + + gradient.colorStops.forEach(colorStop => { + linearGradient.addColorStop(colorStop.stop, colorStop.color.toString()); + }); + + this.ctx.fillStyle = linearGradient; + this.ctx.fillRect(bounds.left, bounds.top, bounds.width, bounds.height); + } + + renderRepeat( + path: Path, + image: ImageElement, + imageSize: Size, + offsetX: number, + offsetY: number + ) { + this.path(path); + this.ctx.fillStyle = this.ctx.createPattern(this.resizeImage(image, imageSize), 'repeat'); + this.ctx.translate(offsetX, offsetY); + this.ctx.fill(); + this.ctx.translate(-offsetX, -offsetY); + } + + renderTextNode( + textBounds: Array, + color: Color, + font: Font, + textDecoration: TextDecoration, + textShadows: Array | null + ) { + this.ctx.font = [ + font.fontStyle, + font.fontVariant, + font.fontWeight, + font.fontSize, + font.fontFamily + ] + .join(' ') + .split(',')[0]; + + textBounds.forEach(text => { + this.ctx.fillStyle = color.toString(); + if (textShadows && text.text.trim().length) { + textShadows.slice(0).reverse().forEach(textShadow => { + this.ctx.shadowColor = textShadow.color.toString(); + this.ctx.shadowOffsetX = textShadow.offsetX * this.options.scale; + this.ctx.shadowOffsetY = textShadow.offsetY * this.options.scale; + this.ctx.shadowBlur = textShadow.blur; + + this.ctx.fillText( + text.text, + text.bounds.left, + text.bounds.top + text.bounds.height + ); + }); + } else { + this.ctx.fillText( + text.text, + text.bounds.left, + text.bounds.top + text.bounds.height + ); + } + + if (textDecoration !== null) { + const textDecorationColor = textDecoration.textDecorationColor || color; + textDecoration.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 + const {baseline} = this.options.fontMetrics.getMetrics(font); + this.rectangle( + text.bounds.left, + Math.round(text.bounds.top + baseline), + text.bounds.width, + 1, + textDecorationColor + ); + break; + case TEXT_DECORATION_LINE.OVERLINE: + this.rectangle( + text.bounds.left, + Math.round(text.bounds.top), + text.bounds.width, + 1, + textDecorationColor + ); + break; + case TEXT_DECORATION_LINE.LINE_THROUGH: + // TODO try and find exact position for line-through + const {middle} = this.options.fontMetrics.getMetrics(font); + this.rectangle( + text.bounds.left, + Math.ceil(text.bounds.top + middle), + text.bounds.width, + 1, + textDecorationColor + ); + break; + } + }); + } + }); + } + + resizeImage(image: ImageElement, size: Size): ImageElement { + if (image.width === size.width && image.height === size.height) { + return image; + } + + const canvas = this.canvas.ownerDocument.createElement('canvas'); + canvas.width = size.width; + canvas.height = size.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, size.width, size.height); + return canvas; + } + + setOpacity(opacity: number) { + this.ctx.globalAlpha = opacity; + } + + transform(offsetX: number, offsetY: number, matrix: Matrix, callback: () => void) { + this.ctx.save(); + this.ctx.translate(offsetX, offsetY); + this.ctx.transform(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]); + this.ctx.translate(-offsetX, -offsetY); + + callback(); + + this.ctx.restore(); + } +}