From 8a6fb5f733206b15722af8f91a510b521fcf7325 Mon Sep 17 00:00:00 2001 From: Niklas von Hertzen Date: Sat, 29 Jul 2017 10:07:42 +0800 Subject: [PATCH] Library rewrite --- .babelrc | 3 + .eslintrc | 15 + .flowconfig | 6 + .gitignore | 1 + .gitmodules | 3 - .jshintrc | 19 - .travis.yml | 3 - Gruntfile.js | 216 -------- flow-typed/myLibDef.js | 1 + package.json | 49 +- src/BezierCurve.js | 36 ++ src/Bounds.js | 278 +++++++++++ src/CanvasRenderer.js | 364 ++++++++++++++ src/Color.js | 249 ++++++++++ src/Feature.js | 34 ++ src/ImageLoader.js | 100 ++++ src/Length.js | 36 ++ src/Logger.js | 19 + src/NodeContainer.js | 141 ++++++ src/NodeParser.js | 95 ++++ src/Size.js | 12 + src/StackingContext.js | 37 ++ src/TextBounds.js | 102 ++++ src/TextContainer.js | 43 ++ src/Util.js | 4 + src/Vector.js | 20 + src/clone.js | 104 ---- src/color.js | 272 ----------- src/core.js | 155 ------ src/dummyimagecontainer.js | 22 - src/fabric | 1 - src/font.js | 52 -- src/fontmetrics.js | 14 - src/framecontainer.js | 31 -- src/gradientcontainer.js | 21 - src/imagecontainer.js | 19 - src/imageloader.js | 157 ------ src/index.js | 49 ++ src/lineargradientcontainer.js | 102 ---- src/log.js | 8 - src/nodecontainer.js | 296 ----------- src/nodeparser.js | 869 --------------------------------- src/parsing/background.js | 366 ++++++++++++++ src/parsing/border.js | 49 ++ src/parsing/borderRadius.js | 15 + src/parsing/display.js | 111 +++++ src/parsing/float.js | 26 + src/parsing/font.js | 38 ++ src/parsing/letterSpacing.js | 10 + src/parsing/padding.js | 11 + src/parsing/position.js | 27 + src/parsing/textDecoration.js | 88 ++++ src/parsing/textTransform.js | 24 + src/parsing/transform.js | 49 ++ src/parsing/zIndex.js | 15 + src/proxy.js | 95 ---- src/proxyimagecontainer.js | 21 - src/pseudoelementcontainer.js | 38 -- src/renderer.js | 108 ---- src/renderers/canvas.js | 181 ------- src/stackingcontext.js | 18 - src/support.js | 51 -- src/svgcontainer.js | 52 -- src/svgnodecontainer.js | 25 - src/textcontainer.js | 33 -- src/utils.js | 169 ------- src/webkitgradientcontainer.js | 10 - src/xhr.js | 22 - tests/test.js | 9 +- webpack.config.js | 21 + 70 files changed, 2521 insertions(+), 3219 deletions(-) create mode 100644 .babelrc create mode 100644 .eslintrc create mode 100644 .flowconfig delete mode 100644 .gitmodules delete mode 100644 .jshintrc delete mode 100644 Gruntfile.js create mode 100644 flow-typed/myLibDef.js create mode 100644 src/BezierCurve.js create mode 100644 src/Bounds.js create mode 100644 src/CanvasRenderer.js create mode 100644 src/Color.js create mode 100644 src/Feature.js create mode 100644 src/ImageLoader.js create mode 100644 src/Length.js create mode 100644 src/Logger.js create mode 100644 src/NodeContainer.js create mode 100644 src/NodeParser.js create mode 100644 src/Size.js create mode 100644 src/StackingContext.js create mode 100644 src/TextBounds.js create mode 100644 src/TextContainer.js create mode 100644 src/Util.js create mode 100644 src/Vector.js delete mode 100644 src/clone.js delete mode 100644 src/color.js delete mode 100644 src/core.js delete mode 100644 src/dummyimagecontainer.js delete mode 160000 src/fabric delete mode 100644 src/font.js delete mode 100644 src/fontmetrics.js delete mode 100644 src/framecontainer.js delete mode 100644 src/gradientcontainer.js delete mode 100644 src/imagecontainer.js delete mode 100644 src/imageloader.js create mode 100644 src/index.js delete mode 100644 src/lineargradientcontainer.js delete mode 100644 src/log.js delete mode 100644 src/nodecontainer.js delete mode 100644 src/nodeparser.js create mode 100644 src/parsing/background.js create mode 100644 src/parsing/border.js create mode 100644 src/parsing/borderRadius.js create mode 100644 src/parsing/display.js create mode 100644 src/parsing/float.js create mode 100644 src/parsing/font.js create mode 100644 src/parsing/letterSpacing.js create mode 100644 src/parsing/padding.js create mode 100644 src/parsing/position.js create mode 100644 src/parsing/textDecoration.js create mode 100644 src/parsing/textTransform.js create mode 100644 src/parsing/transform.js create mode 100644 src/parsing/zIndex.js delete mode 100644 src/proxy.js delete mode 100644 src/proxyimagecontainer.js delete mode 100644 src/pseudoelementcontainer.js delete mode 100644 src/renderer.js delete mode 100644 src/renderers/canvas.js delete mode 100644 src/stackingcontext.js delete mode 100644 src/support.js delete mode 100644 src/svgcontainer.js delete mode 100644 src/svgnodecontainer.js delete mode 100644 src/textcontainer.js delete mode 100644 src/utils.js delete mode 100644 src/webkitgradientcontainer.js delete mode 100644 src/xhr.js create mode 100644 webpack.config.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 000000000..478397bf3 --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015", "flow"] +} diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 000000000..024de94a0 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,15 @@ +{ + "parser": "babel-eslint", + "plugins": [ + "prettier" + ], + "rules": { + "prettier/prettier": ["error", { + "singleQuote": true, + "bracketSpacing": false, + "parser": "flow", + "tabWidth": 4, + "printWidth": 100 + }] + } +} diff --git a/.flowconfig b/.flowconfig new file mode 100644 index 000000000..72d12a0bf --- /dev/null +++ b/.flowconfig @@ -0,0 +1,6 @@ +[ignore] +[include] +[libs] +./flow-typed +[options] +[lints] diff --git a/.gitignore b/.gitignore index d3905c481..a91bd1678 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/dist /nbproject/ image.jpg /.project diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index d630f8d01..000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "src/fabric"] - path = src/fabric - url = https://github.com/kangax/fabric.js.git diff --git a/.jshintrc b/.jshintrc deleted file mode 100644 index 77ec1aff5..000000000 --- a/.jshintrc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "curly": true, - "eqeqeq": true, - "immed": true, - "latedef": false, - "newcap": true, - "noarg": true, - "sub": true, - "undef": true, - "boss": true, - "eqnull": true, - "browser": true, - "node": true, - "indent": 4, - "globals": { - "jQuery": true - }, - "predef": ["Promise", "define"] -} diff --git a/.travis.yml b/.travis.yml index a61f79529..1f576e28a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,9 +8,6 @@ env: - secure: YI+YbTOGf2x4fPMKW+KhJiZWswoXT6xOKGwLfsQsVwmFX1LerJouil5D5iYOQuL4FE3pNaoJSNakIsokJQuGKJMmnPc8rdhMZuBJBk6MRghurE2Xe9qBHfuUBPlfD61nARESm4WDcyMwM0QVYaOKeY6aIpZ91qbUbyc60EEx3C4= addons: sauce_connect: true -before_script: -- npm install -g grunt-cli -- npm install -g uglify-js notifications: webhooks: urls: diff --git a/Gruntfile.js b/Gruntfile.js deleted file mode 100644 index af9004605..000000000 --- a/Gruntfile.js +++ /dev/null @@ -1,216 +0,0 @@ -/*global module:false*/ -var _ = require('lodash'), path = require('path'); -var proxy = require('html2canvas-proxy'); - -module.exports = function(grunt) { - - var meta = { - banner: '/*\n <%= pkg.title || pkg.name %> <%= pkg.version %>' + - '<%= pkg.homepage ? " <" + pkg.homepage + ">" : "" %>' + '\n' + - ' Copyright (c) <%= grunt.template.today("yyyy") %> <%= pkg.author.name %>' + - '\n\n Released under <%= _.pluck(pkg.licenses, "type").join(", ") %> License\n*/\n' - }; - - var browsers = { - chrome: { - browserName: "chrome", - platform: "Windows 7", - version: "39" - }, - firefox: { - browserName: "firefox", - version: "15", - platform: "Windows 7" - }, - ie9: { - browserName: "internet explorer", - version: "9", - platform: "Windows 7" - }, - ie10: { - browserName: "internet explorer", - version: "10", - platform: "Windows 8" - }, - ie11: { - browserName: "internet explorer", - version: "11", - platform: "Windows 8.1" - }, - chromeOSX:{ - browserName: "chrome", - platform: "OS X 10.8", - version: "39" - } - }; - grunt.initConfig({ - - pkg: grunt.file.readJSON('package.json'), - - browserify: { - dist: { - src: ['src/core.js'], - dest: 'dist/<%= pkg.name %>.js', - options: { - browserifyOptions: { - standalone: 'html2canvas' - }, - banner: meta.banner, - plugin: [ - [ "browserify-derequire" ] - ] - } - }, - svg: { - src: [ - 'src/fabric/dist/fabric.js' - ], - dest: 'dist/<%= pkg.name %>.svg.js', - options:{ - browserifyOptions: { - standalone: 'html2canvas.svg' - }, - banner: meta.banner, - plugin: [ - [ "browserify-derequire" ] - ] - } - } - }, - connect: { - server: { - options: { - port: 8080, - base: './', - keepalive: true - } - }, - altServer: { - options: { - port: 8083, - base: './' - } - }, - cors: { - options: { - port: 8081, - base: './', - middleware: function(connect, options) { - return [ - function(req, res, next) { - if (req.url !== '/tests/assets/image2.jpg') { - next(); - return; - } - res.setHeader("Access-Control-Allow-Origin", "*"); - res.end(require("fs").readFileSync('tests/assets/image2.jpg')); - } - ]; - } - } - }, - proxy: { - options: { - port: 8082, - middleware: function(connect, options) { - return [ - function(req, res, next) { - res.jsonp = function(content) { - res.end(req.query.callback + "(" + JSON.stringify(content) + ")"); - }; - next(); - }, - proxy() - ]; - } - } - }, - ci: { - options: { - port: 8080, - base: './' - } - } - }, - execute: { - fabric: { - options: { - args: ['modules=' + ['text','serialization', - 'parser', 'gradient', 'pattern', 'shadow', 'freedrawing', - 'image_filters', 'serialization'].join(","), 'no-es5-compat', 'dest=' + path.resolve(__dirname, 'src/fabric/dist/') + '/'] - }, - src: ['src/fabric/build.js'] - } - }, - uglify: { - dist: { - src: ['<%= browserify.dist.dest %>'], - dest: 'dist/<%= pkg.name %>.min.js' - }, - svg: { - src: ['<%= browserify.svg.dest %>'], - dest: 'dist/<%= pkg.name %>.svg.min.js' - }, - options: { - banner: meta.banner - } - }, - watch: { - files: ['src/**/*', '!src/fabric/**/*'], - tasks: ['jshint', 'build'] - }, - jshint: { - all: ['src/*.js', 'src/renderers/*.js'], - options: grunt.file.readJSON('./.jshintrc') - }, - mochacli: { - options: { - reporter: 'spec' - }, - all: ['tests/node/*.js'] - }, - mocha_phantomjs: { - all: ['tests/mocha/**/*.html'] - }, - mocha_webdriver: browsers, - webdriver: browsers - }); - - grunt.registerTask('webdriver', 'Browser render tests', function(browser, test) { - var selenium = require("./tests/selenium.js"); - var done = this.async(); - var browsers = (browser) ? [grunt.config.get(this.name + "." + browser)] : _.values(grunt.config.get(this.name)); - selenium.tests(browsers, test).catch(function() { - done(false); - }).finally(function() { - console.log("Done"); - done(); - }); - }); - - grunt.registerTask('mocha_webdriver', 'Browser mocha tests', function(browser, test) { - var selenium = require("./tests/mocha/selenium.js"); - var done = this.async(); - var browsers = (browser) ? [grunt.config.get(this.name + "." + browser)] : _.values(grunt.config.get(this.name)); - selenium.tests(browsers, test).catch(function() { - done(false); - }).finally(function() { - done(); - }); - }); - - grunt.loadNpmTasks('grunt-browserify'); - grunt.loadNpmTasks('grunt-mocha-phantomjs'); - grunt.loadNpmTasks('grunt-contrib-watch'); - grunt.loadNpmTasks('grunt-contrib-uglify'); - grunt.loadNpmTasks('grunt-contrib-jshint'); - grunt.loadNpmTasks('grunt-contrib-connect'); - grunt.loadNpmTasks('grunt-execute'); - grunt.loadNpmTasks('grunt-mocha-cli'); - - grunt.registerTask('server', ['connect:cors', 'connect:proxy', 'connect:altServer', 'connect:server']); - grunt.registerTask('build', ['execute', 'browserify', 'uglify']); - grunt.registerTask('default', ['jshint', 'build', 'mochacli', 'connect:altServer', 'mocha_phantomjs']); - grunt.registerTask('travis', ['jshint', 'build', 'connect:altServer', 'connect:ci', 'connect:proxy', 'connect:cors', 'mocha_phantomjs', 'webdriver']); - -}; diff --git a/flow-typed/myLibDef.js b/flow-typed/myLibDef.js new file mode 100644 index 000000000..7d9a15d49 --- /dev/null +++ b/flow-typed/myLibDef.js @@ -0,0 +1 @@ +declare var __DEV__: boolean; diff --git a/package.json b/package.json index abc62d4f6..4b1f543b3 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "name": "html2canvas", "description": "Screenshots with JavaScript", "main": "dist/html2canvas.js", - "version": "0.5.0-beta4", + "version": "1.0.0-alpha.1", "author": { "name": "Niklas von Hertzen", "email": "niklasvh@gmail.com", @@ -20,32 +20,31 @@ "url": "https://github.com/niklasvh/html2canvas/issues" }, "devDependencies": { - "base64-arraybuffer": "^0.1.5", - "bluebird": "^3.0.6", - "browserify-derequire": "^0.9.4", - "grunt": "^0.4.5", - "grunt-browserify": "^4.0.1", - "grunt-cli": "^0.1.13", - "grunt-contrib-connect": "^0.11.2", - "grunt-contrib-jshint": "^0.11.3", - "grunt-contrib-uglify": "^0.11.0", - "grunt-contrib-watch": "^0.6.1", - "grunt-execute": "^0.2.2", - "grunt-mocha-cli": "^1.12.0", - "grunt-mocha-phantomjs": "^2.0.0", - "html2canvas-proxy": "0.0.5", - "humanize-duration": "^2.0.1", - "lodash": "^3.10.1", - "pngjs": "^2.2.0", - "requirejs": "^2.1.20", - "sauce-connect-launcher": "^0.13.0", - "wd": "^0.4.0" + "babel-cli": "6.24.1", + "babel-core": "6.25.0", + "babel-eslint": "7.2.3", + "babel-loader": "7.1.1", + "babel-preset-es2015": "6.24.1", + "babel-preset-flow": "6.23.0", + "base64-arraybuffer": "0.1.5", + "eslint": "4.2.0", + "eslint-plugin-prettier": "^2.1.2", + "flow-bin": "0.50.0", + "prettier": "1.5.3", + "rimraf": "2.6.1", + "webpack": "3.4.1" }, "scripts": { - "test": "grunt travis --verbose", - "start": "grunt server", - "sauceconnect": "tests/sauceconnect.js" + "build": "rimraf dist/ && babel src/ -d dist/npm/", + "build:browser": "webpack", + "format": "prettier --single-quote --no-bracket-spacing --tab-width 4 --print-width 100 --write \"src/**/*.js\"", + "flow": "flow", + "lint": "eslint src/**", + "test": "npm run flow && npm run lint" }, "homepage": "http://html2canvas.hertzen.com", - "license": "MIT" + "license": "MIT", + "dependencies": { + "punycode": "2.1.0" + } } diff --git a/src/BezierCurve.js b/src/BezierCurve.js new file mode 100644 index 000000000..458125d81 --- /dev/null +++ b/src/BezierCurve.js @@ -0,0 +1,36 @@ +import Vector from './Vector'; + +export default class BezierCurve { + start: Vector; + startControl: Vector; + endControl: Vector; + end: Vector; + + constructor(start: Vector, startControl: Vector, endControl: Vector, end: Vector) { + this.start = start; + this.startControl = startControl; + this.endControl = endControl; + this.end = end; + } + + lerp(a: Vector, b: Vector, t: number): Vector { + return new Vector(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t); + } + + subdivide(t: number): [BezierCurve, BezierCurve] { + const ab = this.lerp(this.start, this.startControl, t); + const bc = this.lerp(this.startControl, this.endControl, t); + const cd = this.lerp(this.endControl, this.end, t); + const abbc = this.lerp(ab, bc, t); + const bccd = this.lerp(bc, cd, t); + const dest = this.lerp(abbc, bccd, t); + return [ + new BezierCurve(this.start, ab, abbc, dest), + new BezierCurve(dest, bccd, cd, this.end) + ]; + } + + reverse(): BezierCurve { + return new BezierCurve(this.end, this.endControl, this.startControl, this.start); + } +} diff --git a/src/Bounds.js b/src/Bounds.js new file mode 100644 index 000000000..5bab812d3 --- /dev/null +++ b/src/Bounds.js @@ -0,0 +1,278 @@ +/* @flow */ +'use strict'; + +import type {Border, BorderSide} from './parsing/border'; +import type {BorderRadius} from './parsing/borderRadius'; +import type {Padding} from './parsing/padding'; + +import Vector from './Vector'; +import BezierCurve from './BezierCurve'; + +export type Path = Array; + +const TOP = 0; +const RIGHT = 1; +const BOTTOM = 2; +const LEFT = 3; + +export type BoundCurves = { + topLeftOuter: [BezierCurve, BezierCurve], + topLeftInner: [BezierCurve, BezierCurve], + topRightOuter: [BezierCurve, BezierCurve], + topRightInner: [BezierCurve, BezierCurve], + bottomRightOuter: [BezierCurve, BezierCurve], + bottomRightInner: [BezierCurve, BezierCurve], + bottomLeftOuter: [BezierCurve, BezierCurve], + bottomLeftInner: [BezierCurve, BezierCurve] +}; + +export class Bounds { + top: number; + left: number; + width: number; + height: number; + + constructor(x: number, y: number, w: number, h: number) { + this.left = x; + this.top = y; + this.width = w; + this.height = h; + } + + static fromClientRect(clientRect: ClientRect): Bounds { + return new Bounds(clientRect.left, clientRect.top, clientRect.width, clientRect.height); + } +} + +export const parseBounds = (node: HTMLElement, isTransformed: boolean): Bounds => { + return isTransformed ? offsetBounds(node) : Bounds.fromClientRect(node.getBoundingClientRect()); +}; + +const offsetBounds = (node: HTMLElement): Bounds => { + const parent = + node.offsetParent instanceof HTMLElement + ? offsetBounds(node.offsetParent) + : {top: 0, left: 0}; + + return new Bounds( + node.offsetLeft + parent.left, + node.offsetTop + parent.top, + node.offsetWidth, + node.offsetHeight + ); +}; + +export const calculatePaddingBox = (bounds: Bounds, borders: Array): Bounds => { + return new Bounds( + bounds.left + borders[LEFT].borderWidth, + bounds.top + borders[TOP].borderWidth, + bounds.width - (borders[RIGHT].borderWidth + borders[LEFT].borderWidth), + bounds.height - (borders[TOP].borderWidth + borders[BOTTOM].borderWidth) + ); +}; + +export const calculateContentBox = ( + bounds: Bounds, + padding: Padding, + borders: Array +): Bounds => { + // TODO support percentage paddings + const paddingTop = padding[TOP].value; + const paddingRight = padding[RIGHT].value; + const paddingBottom = padding[BOTTOM].value; + const paddingLeft = padding[LEFT].value; + + return new Bounds( + bounds.left + paddingLeft + borders[LEFT].borderWidth, + bounds.top + paddingTop + borders[TOP].borderWidth, + bounds.width - + (borders[RIGHT].borderWidth + borders[LEFT].borderWidth + paddingLeft + paddingRight), + bounds.height - + (borders[TOP].borderWidth + borders[BOTTOM].borderWidth + paddingTop + paddingBottom) + ); +}; + +export const parsePathForBorder = (curves: BoundCurves, borderSide: BorderSide): Path => { + switch (borderSide) { + case TOP: + return createPathFromCurves( + curves.topLeftOuter, + curves.topLeftInner, + curves.topRightOuter, + curves.topRightInner + ); + case RIGHT: + return createPathFromCurves( + curves.topRightOuter, + curves.topRightInner, + curves.bottomRightOuter, + curves.bottomRightInner + ); + case BOTTOM: + return createPathFromCurves( + curves.bottomRightOuter, + curves.bottomRightInner, + curves.bottomLeftOuter, + curves.bottomLeftInner + ); + default: + return createPathFromCurves( + curves.bottomLeftOuter, + curves.bottomLeftInner, + curves.topLeftOuter, + curves.topLeftInner + ); + } +}; + +const createPathFromCurves = ( + outer1: [BezierCurve, BezierCurve], + inner1: [BezierCurve, BezierCurve], + outer2: [BezierCurve, BezierCurve], + inner2: [BezierCurve, BezierCurve] +): Path => { + const path = []; + path.push(outer1[1]); + path.push(outer2[0]); + path.push(inner2[0].reverse()); + path.push(inner1[1].reverse()); + + return path; +}; + +export const parseBoundCurves = ( + bounds: Bounds, + borders: Array, + borderRadius: Array +): BoundCurves => { + // TODO support percentage borderRadius + const tlh = + borderRadius[0][0].value < bounds.width / 2 ? borderRadius[0][0].value : bounds.width / 2; + const tlv = + borderRadius[0][1].value < bounds.height / 2 ? borderRadius[0][1].value : bounds.height / 2; + const trh = + borderRadius[1][0].value < bounds.width / 2 ? borderRadius[1][0].value : bounds.width / 2; + const trv = + borderRadius[1][1].value < bounds.height / 2 ? borderRadius[1][1].value : bounds.height / 2; + const brh = + borderRadius[2][0].value < bounds.width / 2 ? borderRadius[2][0].value : bounds.width / 2; + const brv = + borderRadius[2][1].value < bounds.height / 2 ? borderRadius[2][1].value : bounds.height / 2; + const blh = + borderRadius[3][0].value < bounds.width / 2 ? borderRadius[3][0].value : bounds.width / 2; + const blv = + borderRadius[3][1].value < bounds.height / 2 ? borderRadius[3][1].value : bounds.height / 2; + + const topWidth = bounds.width - trh; + const rightHeight = bounds.height - brv; + const bottomWidth = bounds.width - brh; + const leftHeight = bounds.height - blv; + + return { + topLeftOuter: getCurvePoints(bounds.left, bounds.top, tlh, tlv, CORNER.TOP_LEFT).subdivide( + 0.5 + ), + topLeftInner: getCurvePoints( + bounds.left + borders[3].borderWidth, + bounds.top + borders[0].borderWidth, + Math.max(0, tlh - borders[3].borderWidth), + Math.max(0, tlv - borders[0].borderWidth), + CORNER.TOP_LEFT + ).subdivide(0.5), + topRightOuter: getCurvePoints( + bounds.left + topWidth, + bounds.top, + trh, + trv, + CORNER.TOP_RIGHT + ).subdivide(0.5), + topRightInner: getCurvePoints( + bounds.left + Math.min(topWidth, bounds.width + borders[3].borderWidth), + bounds.top + borders[0].borderWidth, + topWidth > bounds.width + borders[3].borderWidth ? 0 : trh - borders[3].borderWidth, + trv - borders[0].borderWidth, + CORNER.TOP_RIGHT + ).subdivide(0.5), + bottomRightOuter: getCurvePoints( + bounds.left + bottomWidth, + bounds.top + rightHeight, + brh, + brv, + CORNER.BOTTOM_RIGHT + ).subdivide(0.5), + bottomRightInner: getCurvePoints( + bounds.left + Math.min(bottomWidth, bounds.width - borders[3].borderWidth), + bounds.top + Math.min(rightHeight, bounds.height + borders[0].borderWidth), + Math.max(0, brh - borders[1].borderWidth), + brv - borders[2].borderWidth, + CORNER.BOTTOM_RIGHT + ).subdivide(0.5), + bottomLeftOuter: getCurvePoints( + bounds.left, + bounds.top + leftHeight, + blh, + blv, + CORNER.BOTTOM_LEFT + ).subdivide(0.5), + bottomLeftInner: getCurvePoints( + bounds.left + borders[3].borderWidth, + bounds.top + leftHeight, + Math.max(0, blh - borders[3].borderWidth), + blv - borders[2].borderWidth, + CORNER.BOTTOM_LEFT + ).subdivide(0.5) + }; +}; + +const CORNER = { + TOP_LEFT: 0, + TOP_RIGHT: 1, + BOTTOM_RIGHT: 2, + BOTTOM_LEFT: 3 +}; + +type Corner = $Values; + +const getCurvePoints = ( + x: number, + y: number, + r1: number, + r2: number, + position: Corner +): BezierCurve => { + const kappa = 4 * ((Math.sqrt(2) - 1) / 3); + const ox = r1 * kappa; // control point offset horizontal + const oy = r2 * kappa; // control point offset vertical + const xm = x + r1; // x-middle + const ym = y + r2; // y-middle + + switch (position) { + case CORNER.TOP_LEFT: + return new BezierCurve( + new Vector(x, ym), + new Vector(x, ym - oy), + new Vector(xm - ox, y), + new Vector(xm, y) + ); + case CORNER.TOP_RIGHT: + return new BezierCurve( + new Vector(x, y), + new Vector(x + ox, y), + new Vector(xm, ym - oy), + new Vector(xm, ym) + ); + case CORNER.BOTTOM_RIGHT: + return new BezierCurve( + new Vector(xm, y), + new Vector(xm, y + oy), + new Vector(x + ox, ym), + new Vector(x, ym) + ); + } + return new BezierCurve( + new Vector(xm, ym), + new Vector(xm - ox, ym), + new Vector(x, y + oy), + new Vector(x, y) + ); +}; diff --git a/src/CanvasRenderer.js b/src/CanvasRenderer.js new file mode 100644 index 000000000..ec3798852 --- /dev/null +++ b/src/CanvasRenderer.js @@ -0,0 +1,364 @@ +/* @flow */ +'use strict'; +import type Color from './Color'; +import type Size from './Size'; +import type {Path, BoundCurves} from './Bounds'; +import type {TextBounds} from './TextBounds'; +import type {BackgroundImage} from './parsing/background'; +import type {Border, BorderSide} from './parsing/border'; + +import Vector from './Vector'; +import BezierCurve from './BezierCurve'; + +import type NodeContainer from './NodeContainer'; +import type TextContainer from './TextContainer'; +import type {ImageStore} from './ImageLoader'; +import type StackingContext from './StackingContext'; + +import { + calculateBackgroundSize, + calculateBackgroundPosition, + calculateBackgroundRepeatPath +} from './parsing/background'; +import {BORDER_STYLE} from './parsing/border'; +import { + parseBoundCurves, + parsePathForBorder, + calculateContentBox, + calculatePaddingBox +} from './Bounds'; + +export type RenderOptions = { + scale: number, + backgroundColor: ?Color, + imageStore: ImageStore +}; + +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) { + this.renderNodeBackgroundAndBorders(container); + this.renderNodeContent(container); + } + + renderNodeContent(container: NodeContainer) { + if (container.textNodes.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.textNodes.forEach(this.renderTextNode, this); + } + + 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.drawImage( + image, + 0, + 0, + width, + height, + contentBox.left, + contentBox.top, + contentBox.width, + contentBox.height + ); + } + } + } + + renderNodeBackgroundAndBorders(container: NodeContainer) { + const curvePoints = parseBoundCurves( + container.bounds, + container.style.border, + container.style.borderRadius + ); + + this.renderBackground(container); + container.style.border.forEach((border, side) => { + this.renderBorder(border, side, curvePoints); + }); + } + + renderTextNode(textContainer: TextContainer) { + textContainer.bounds.forEach(this.renderText, this); + } + + renderText(text: TextBounds) { + this.ctx.fillText(text.text, text.bounds.left, text.bounds.top + text.bounds.height); + } + + renderBackground(container: NodeContainer) { + if (container.bounds.height > 0 && container.bounds.width > 0) { + this.renderBackgroundColor(container); + this.renderBackgroundImage(container); + } + } + + renderBackgroundColor(container: NodeContainer) { + if (!container.style.background.backgroundColor.isTransparent()) { + this.rectangle( + container.bounds.left, + container.bounds.top, + container.bounds.width, + container.bounds.height, + container.style.background.backgroundColor + ); + } + } + + renderBackgroundImage(container: NodeContainer) { + container.style.background.backgroundImage.forEach(backgroundImage => { + if (backgroundImage.source.method === 'url' && backgroundImage.source.args.length) { + this.renderBackgroundRepeat(container, backgroundImage); + } + }); + } + + 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 size = calculateBackgroundSize(background, image, bounds); + const position = calculateBackgroundPosition(background.position, size, bounds); + const path = calculateBackgroundRepeatPath(background, position, size, paddingBox); + 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, size), 'repeat'); + this.ctx.translate(offsetX, offsetY); + this.ctx.fill(); + this.ctx.translate(-offsetX, -offsetY); + } + } + + resizeImage(image: HTMLImageElement, 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(); + 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) { + 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/Color.js b/src/Color.js new file mode 100644 index 000000000..506c5a60d --- /dev/null +++ b/src/Color.js @@ -0,0 +1,249 @@ +/* @flow */ +'use strict'; + +// http://dev.w3.org/csswg/css-color/ + +type ColorArray = [number, number, number, number | null]; + +const HEX3 = /^#([a-f0-9]{3})$/i; +const hex3 = (value: string): ColorArray | false => { + const match = value.match(HEX3); + if (match) { + return [ + parseInt(match[1][0] + match[1][0], 16), + parseInt(match[1][1] + match[1][1], 16), + parseInt(match[1][2] + match[1][2], 16), + null + ]; + } + return false; +}; + +const HEX6 = /^#([a-f0-9]{6})$/i; +const hex6 = (value: string): ColorArray | false => { + const match = value.match(HEX6); + if (match) { + return [ + parseInt(match[1].substring(0, 2), 16), + parseInt(match[1].substring(2, 4), 16), + parseInt(match[1].substring(4, 6), 16), + null + ]; + } + return false; +}; + +const RGB = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/; +const rgb = (value: string): ColorArray | false => { + const match = value.match(RGB); + if (match) { + return [Number(match[1]), Number(match[2]), Number(match[3]), null]; + } + return false; +}; + +const RGBA = /^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d?\.?\d+)\s*\)$/; +const rgba = (value: string): ColorArray | false => { + const match = value.match(RGBA); + if (match && match.length > 4) { + return [Number(match[1]), Number(match[2]), Number(match[3]), Number(match[4])]; + } + return false; +}; + +const fromArray = (array: Array): ColorArray => { + return [ + Math.min(array[0], 255), + Math.min(array[1], 255), + Math.min(array[2], 255), + array.length > 3 ? array[3] : null + ]; +}; + +const namedColor = (name: string): ColorArray | false => { + const color: ColorArray | void = NAMED_COLORS[name.toLowerCase()]; + return color ? color : false; +}; + +export default class Color { + r: number; + g: number; + b: number; + a: number | null; + + constructor(value: string | Array) { + const [r, g, b, a] = Array.isArray(value) + ? fromArray(value) + : hex3(value) || + rgb(value) || + rgba(value) || + namedColor(value) || + hex6(value) || [0, 0, 0, null]; + this.r = r; + this.g = g; + this.b = b; + this.a = a; + } + + isTransparent(): boolean { + return this.a === 0; + } + + toString(): string { + return this.a !== null && this.a !== 1 + ? `rgba(${this.r},${this.g},${this.b},${this.a})` + : `rgb(${this.r},${this.g},${this.b})`; + } +} + +const NAMED_COLORS = { + transparent: [0, 0, 0, 0], + aliceblue: [240, 248, 255, null], + antiquewhite: [250, 235, 215, null], + aqua: [0, 255, 255, null], + aquamarine: [127, 255, 212, null], + azure: [240, 255, 255, null], + beige: [245, 245, 220, null], + bisque: [255, 228, 196, null], + black: [0, 0, 0, null], + blanchedalmond: [255, 235, 205, null], + blue: [0, 0, 255, null], + blueviolet: [138, 43, 226, null], + brown: [165, 42, 42, null], + burlywood: [222, 184, 135, null], + cadetblue: [95, 158, 160, null], + chartreuse: [127, 255, 0, null], + chocolate: [210, 105, 30, null], + coral: [255, 127, 80, null], + cornflowerblue: [100, 149, 237, null], + cornsilk: [255, 248, 220, null], + crimson: [220, 20, 60, null], + cyan: [0, 255, 255, null], + darkblue: [0, 0, 139, null], + darkcyan: [0, 139, 139, null], + darkgoldenrod: [184, 134, 11, null], + darkgray: [169, 169, 169, null], + darkgreen: [0, 100, 0, null], + darkgrey: [169, 169, 169, null], + darkkhaki: [189, 183, 107, null], + darkmagenta: [139, 0, 139, null], + darkolivegreen: [85, 107, 47, null], + darkorange: [255, 140, 0, null], + darkorchid: [153, 50, 204, null], + darkred: [139, 0, 0, null], + darksalmon: [233, 150, 122, null], + darkseagreen: [143, 188, 143, null], + darkslateblue: [72, 61, 139, null], + darkslategray: [47, 79, 79, null], + darkslategrey: [47, 79, 79, null], + darkturquoise: [0, 206, 209, null], + darkviolet: [148, 0, 211, null], + deeppink: [255, 20, 147, null], + deepskyblue: [0, 191, 255, null], + dimgray: [105, 105, 105, null], + dimgrey: [105, 105, 105, null], + dodgerblue: [30, 144, 255, null], + firebrick: [178, 34, 34, null], + floralwhite: [255, 250, 240, null], + forestgreen: [34, 139, 34, null], + fuchsia: [255, 0, 255, null], + gainsboro: [220, 220, 220, null], + ghostwhite: [248, 248, 255, null], + gold: [255, 215, 0, null], + goldenrod: [218, 165, 32, null], + gray: [128, 128, 128, null], + green: [0, 128, 0, null], + greenyellow: [173, 255, 47, null], + grey: [128, 128, 128, null], + honeydew: [240, 255, 240, null], + hotpink: [255, 105, 180, null], + indianred: [205, 92, 92, null], + indigo: [75, 0, 130, null], + ivory: [255, 255, 240, null], + khaki: [240, 230, 140, null], + lavender: [230, 230, 250, null], + lavenderblush: [255, 240, 245, null], + lawngreen: [124, 252, 0, null], + lemonchiffon: [255, 250, 205, null], + lightblue: [173, 216, 230, null], + lightcoral: [240, 128, 128, null], + lightcyan: [224, 255, 255, null], + lightgoldenrodyellow: [250, 250, 210, null], + lightgray: [211, 211, 211, null], + lightgreen: [144, 238, 144, null], + lightgrey: [211, 211, 211, null], + lightpink: [255, 182, 193, null], + lightsalmon: [255, 160, 122, null], + lightseagreen: [32, 178, 170, null], + lightskyblue: [135, 206, 250, null], + lightslategray: [119, 136, 153, null], + lightslategrey: [119, 136, 153, null], + lightsteelblue: [176, 196, 222, null], + lightyellow: [255, 255, 224, null], + lime: [0, 255, 0, null], + limegreen: [50, 205, 50, null], + linen: [250, 240, 230, null], + magenta: [255, 0, 255, null], + maroon: [128, 0, 0, null], + mediumaquamarine: [102, 205, 170, null], + mediumblue: [0, 0, 205, null], + mediumorchid: [186, 85, 211, null], + mediumpurple: [147, 112, 219, null], + mediumseagreen: [60, 179, 113, null], + mediumslateblue: [123, 104, 238, null], + mediumspringgreen: [0, 250, 154, null], + mediumturquoise: [72, 209, 204, null], + mediumvioletred: [199, 21, 133, null], + midnightblue: [25, 25, 112, null], + mintcream: [245, 255, 250, null], + mistyrose: [255, 228, 225, null], + moccasin: [255, 228, 181, null], + navajowhite: [255, 222, 173, null], + navy: [0, 0, 128, null], + oldlace: [253, 245, 230, null], + olive: [128, 128, 0, null], + olivedrab: [107, 142, 35, null], + orange: [255, 165, 0, null], + orangered: [255, 69, 0, null], + orchid: [218, 112, 214, null], + palegoldenrod: [238, 232, 170, null], + palegreen: [152, 251, 152, null], + paleturquoise: [175, 238, 238, null], + palevioletred: [219, 112, 147, null], + papayawhip: [255, 239, 213, null], + peachpuff: [255, 218, 185, null], + peru: [205, 133, 63, null], + pink: [255, 192, 203, null], + plum: [221, 160, 221, null], + powderblue: [176, 224, 230, null], + purple: [128, 0, 128, null], + rebeccapurple: [102, 51, 153, null], + red: [255, 0, 0, null], + rosybrown: [188, 143, 143, null], + royalblue: [65, 105, 225, null], + saddlebrown: [139, 69, 19, null], + salmon: [250, 128, 114, null], + sandybrown: [244, 164, 96, null], + seagreen: [46, 139, 87, null], + seashell: [255, 245, 238, null], + sienna: [160, 82, 45, null], + silver: [192, 192, 192, null], + skyblue: [135, 206, 235, null], + slateblue: [106, 90, 205, null], + slategray: [112, 128, 144, null], + slategrey: [112, 128, 144, null], + snow: [255, 250, 250, null], + springgreen: [0, 255, 127, null], + steelblue: [70, 130, 180, null], + tan: [210, 180, 140, null], + teal: [0, 128, 128, null], + thistle: [216, 191, 216, null], + tomato: [255, 99, 71, null], + turquoise: [64, 224, 208, null], + violet: [238, 130, 238, null], + wheat: [245, 222, 179, null], + white: [255, 255, 255, null], + whitesmoke: [245, 245, 245, null], + yellow: [255, 255, 0, null], + yellowgreen: [154, 205, 50, null] +}; diff --git a/src/Feature.js b/src/Feature.js new file mode 100644 index 000000000..b85a36b3b --- /dev/null +++ b/src/Feature.js @@ -0,0 +1,34 @@ +const testRangeBounds = document => { + const TEST_HEIGHT = 123; + + if (document.createRange) { + const range = document.createRange(); + if (range.getBoundingClientRect) { + const testElement = document.createElement('boundtest'); + testElement.style.height = `${TEST_HEIGHT}px`; + testElement.style.display = 'block'; + document.body.appendChild(testElement); + + range.selectNode(testElement); + const rangeBounds = range.getBoundingClientRect(); + const rangeHeight = Math.round(rangeBounds.height); + document.body.removeChild(testElement); + if (rangeHeight === TEST_HEIGHT) { + return true; + } + } + } + + return false; +}; + +const FEATURES = { + get SUPPORT_RANGE_BOUNDS() { + 'use strict'; + const value = testRangeBounds(document); + Object.defineProperty(FEATURES, 'SUPPORT_RANGE_BOUNDS', {value}); + return value; + } +}; + +export default FEATURES; diff --git a/src/ImageLoader.js b/src/ImageLoader.js new file mode 100644 index 000000000..2d844e2e4 --- /dev/null +++ b/src/ImageLoader.js @@ -0,0 +1,100 @@ +/* @flow */ +'use strict'; + +import type NodeContainer from './NodeContainer'; +import type Options from './index'; +import type Logger from './Logger'; + +type ImageCache = {[string]: Promise}; + +export default class ImageLoader { + origin: string; + options: Options; + _link: HTMLAnchorElement; + cache: ImageCache; + logger: Logger; + + constructor(options: Options, logger: Logger) { + this.options = options; + this.origin = this.getOrigin(window.location.href); + this.cache = {}; + this.logger = logger; + } + + loadImage(src: string): ?string { + if (this.hasImageInCache(src)) { + return src; + } + + if (this.options.allowTaint === true || this.isInlineImage(src) || this.isSameOrigin(src)) { + return this.addImage(src, src); + } else if (typeof this.options.proxy === 'string' && !this.isSameOrigin(src)) { + // TODO proxy + } + } + + isInlineImage(src: string): boolean { + return /data:image\/.*;base64,/i.test(src); + } + + hasImageInCache(key: string): boolean { + return typeof this.cache[key] !== 'undefined'; + } + + addImage(key: string, src: string): string { + if (__DEV__) { + this.logger.log(`Added image ${key.substring(0, 256)}`); + } + this.cache[key] = new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + /* + if (cors) { + img.crossOrigin = 'anonymous'; + } + */ + img.src = src; + if (img.complete === true) { + resolve(img); + } + }); + return key; + } + + isSameOrigin(url: string): boolean { + return this.getOrigin(url) === this.origin; + } + + getOrigin(url: string): string { + const link = this._link || (this._link = document.createElement('a')); + link.href = url; + link.href = link.href; // IE9, LOL! - http://jsfiddle.net/niklasvh/2e48b/ + return link.protocol + link.hostname + link.port; + } + + ready(): Promise { + const keys = Object.keys(this.cache); + return Promise.all(keys.map(str => this.cache[str])).then(images => { + if (__DEV__) { + this.logger.log('Finished loading images', images); + } + return new ImageStore(keys, images); + }); + } +} + +export class ImageStore { + _keys: Array; + _images: Array; + + constructor(keys: Array, images: Array) { + this._keys = keys; + this._images = images; + } + + get(key: string): ?HTMLImageElement { + const index = this._keys.indexOf(key); + return index === -1 ? null : this._images[index]; + } +} diff --git a/src/Length.js b/src/Length.js new file mode 100644 index 000000000..6eb9e80e6 --- /dev/null +++ b/src/Length.js @@ -0,0 +1,36 @@ +/* @flow */ +'use strict'; + +export const LENGTH_TYPE = { + PX: 0, + PERCENTAGE: 1 +}; + +export type LengthType = $Values; + +export default class Length { + type: LengthType; + value: number; + + constructor(value: string) { + this.type = + value.substr(value.length - 1) === '%' ? LENGTH_TYPE.PERCENTAGE : LENGTH_TYPE.PX; + const parsedValue = parseFloat(value); + if (__DEV__ && isNaN(parsedValue)) { + console.error(`Invalid value given for Length: "${value}"`); + } + this.value = isNaN(parsedValue) ? 0 : parsedValue; + } + + isPercentage(): boolean { + return this.type === LENGTH_TYPE.PERCENTAGE; + } + + getAbsoluteValue(parentLength: number): number { + return this.isPercentage() ? parentLength * (this.value / 100) : this.value; + } + + static create(v): Length { + return new Length(v); + } +} diff --git a/src/Logger.js b/src/Logger.js new file mode 100644 index 000000000..18d48a731 --- /dev/null +++ b/src/Logger.js @@ -0,0 +1,19 @@ +/* @flow */ +'use strict'; + +export default class Logger { + start: number; + + constructor() { + this.start = Date.now(); + } + + log(...args: any) { + Function.prototype.bind + .call(window.console.log, window.console) + .apply( + window.console, + [Date.now() - this.start + 'ms', 'html2canvas:'].concat([].slice.call(args, 0)) + ); + } +} diff --git a/src/NodeContainer.js b/src/NodeContainer.js new file mode 100644 index 000000000..6ae84fae9 --- /dev/null +++ b/src/NodeContainer.js @@ -0,0 +1,141 @@ +/* @flow */ +'use strict'; + +import type {Background} from './parsing/background'; +import type {Border} from './parsing/border'; +import type {BorderRadius} from './parsing/borderRadius'; +import type {DisplayBit} from './parsing/display'; +import type {Float} from './parsing/float'; +import type {Font} from './parsing/font'; +import type {Padding} from './parsing/padding'; +import type {Position} from './parsing/position'; +import type {TextTransform} from './parsing/textTransform'; +import type {TextDecoration} from './parsing/textDecoration'; +import type {Transform} from './parsing/transform'; +import type {zIndex} from './parsing/zIndex'; + +import type {Bounds} from './Bounds'; +import type ImageLoader from './ImageLoader'; + +import type TextContainer from './TextContainer'; + +import Color from './Color'; + +import {contains} from './Util'; +import {parseBackground} from './parsing/background'; +import {parseBorder} from './parsing/border'; +import {parseBorderRadius} from './parsing/borderRadius'; +import {parseDisplay, DISPLAY} from './parsing/display'; +import {parseCSSFloat, FLOAT} from './parsing/float'; +import {parseFont} from './parsing/font'; +import {parseLetterSpacing} from './parsing/letterSpacing'; +import {parsePadding} from './parsing/padding'; +import {parsePosition, POSITION} from './parsing/position'; +import {parseTextDecoration} from './parsing/textDecoration'; +import {parseTextTransform} from './parsing/textTransform'; +import {parseTransform} from './parsing/transform'; +import {parseZIndex} from './parsing/zIndex'; + +import {parseBounds} from './Bounds'; + +type StyleDeclaration = { + background: Background, + border: Array, + borderRadius: Array, + color: Color, + display: DisplayBit, + float: Float, + font: Font, + letterSpacing: number, + opacity: number, + padding: Padding, + position: Position, + textDecoration: TextDecoration, + textTransform: TextTransform, + transform: Transform, + zIndex: zIndex +}; + +export default class NodeContainer { + name: ?string; + parent: ?NodeContainer; + style: StyleDeclaration; + textNodes: Array; + bounds: Bounds; + image: ?string; + + constructor(node: HTMLElement, parent: ?NodeContainer, imageLoader: ImageLoader) { + this.parent = parent; + this.textNodes = []; + const style = node.ownerDocument.defaultView.getComputedStyle(node, null); + const display = parseDisplay(style.display); + + this.style = { + background: parseBackground(style, imageLoader), + border: parseBorder(style), + borderRadius: parseBorderRadius(style), + color: new Color(style.color), + display: display, + float: parseCSSFloat(style.float), + font: parseFont(style), + letterSpacing: parseLetterSpacing(style.letterSpacing), + opacity: parseFloat(style.opacity), + padding: parsePadding(style), + position: parsePosition(style.position), + textDecoration: parseTextDecoration(style), + textTransform: parseTextTransform(style.textTransform), + transform: parseTransform(style), + zIndex: parseZIndex(style.zIndex) + }; + this.image = + node instanceof HTMLImageElement + ? imageLoader.loadImage(node.currentSrc || node.src) + : null; + this.bounds = parseBounds(node, this.isTransformed()); + if (__DEV__) { + this.name = `${node.tagName.toLowerCase()}${node.id + ? `#${node.id}` + : ''}${node.className.split(' ').map(s => (s.length ? `.${s}` : '')).join('')}`; + } + } + isInFlow(): boolean { + return this.isRootElement() && !this.isFloating() && !this.isAbsolutelyPositioned(); + } + isVisible(): boolean { + return !contains(this.style.display, DISPLAY.NONE) && this.style.opacity > 0; + } + isAbsolutelyPositioned(): boolean { + return this.style.position !== POSITION.STATIC && this.style.position !== POSITION.RELATIVE; + } + isPositioned(): boolean { + return this.style.position !== POSITION.STATIC; + } + isFloating(): boolean { + return this.style.float !== FLOAT.NONE; + } + isRootElement(): boolean { + return this.parent === null; + } + isTransformed(): boolean { + return this.style.transform !== null; + } + isPositionedWithZIndex(): boolean { + return this.isPositioned() && !this.style.zIndex.auto; + } + isInlineLevel(): boolean { + return ( + contains(this.style.display, DISPLAY.INLINE) || + contains(this.style.display, DISPLAY.INLINE_BLOCK) || + contains(this.style.display, DISPLAY.INLINE_FLEX) || + contains(this.style.display, DISPLAY.INLINE_GRID) || + contains(this.style.display, DISPLAY.INLINE_LIST_ITEM) || + contains(this.style.display, DISPLAY.INLINE_TABLE) + ); + } + isInlineBlockOrInlineTable(): boolean { + return ( + contains(this.style.display, DISPLAY.INLINE_BLOCK) || + contains(this.style.display, DISPLAY.INLINE_TABLE) + ); + } +} diff --git a/src/NodeParser.js b/src/NodeParser.js new file mode 100644 index 000000000..16c5faf5b --- /dev/null +++ b/src/NodeParser.js @@ -0,0 +1,95 @@ +/* @flow */ +'use strict'; +import type ImageLoader from './ImageLoader'; +import type Logger from './Logger'; +import StackingContext from './StackingContext'; +import NodeContainer from './NodeContainer'; +import TextContainer from './TextContainer'; + +export const NodeParser = ( + node: HTMLElement, + imageLoader: ImageLoader, + logger: Logger +): StackingContext => { + const container = new NodeContainer(node, null, imageLoader); + const stack = new StackingContext(container, null, true); + + if (__DEV__) { + logger.log(`Starting node parsing`); + } + + parseNodeTree(node, container, stack, imageLoader); + + if (__DEV__) { + logger.log(`Finished parsing node tree`); + } + + return stack; +}; + +const IGNORED_NODE_NAMES = ['SCRIPT', 'HEAD', 'TITLE', 'OBJECT', 'BR', 'OPTION']; + +const parseNodeTree = ( + node: HTMLElement, + parent: NodeContainer, + stack: StackingContext, + imageLoader: ImageLoader +): void => { + node.childNodes.forEach((childNode: Node) => { + if (childNode instanceof Text) { + if (childNode.data.trim().length > 0) { + parent.textNodes.push(new TextContainer(childNode, parent)); + } + } else if (childNode instanceof HTMLElement) { + if (IGNORED_NODE_NAMES.indexOf(childNode.nodeName) === -1) { + const container = new NodeContainer(childNode, parent, imageLoader); + if (container.isVisible()) { + const treatAsRealStackingContext = createsRealStackingContext( + container, + childNode + ); + if (treatAsRealStackingContext || createsStackingContext(container)) { + // for treatAsRealStackingContext:false, any positioned descendants and descendants + // which actually create a new stacking context should be considered part of the parent stacking context + const parentStack = + treatAsRealStackingContext || container.isPositioned() + ? stack.getRealParentStackingContext() + : stack; + const childStack = new StackingContext( + container, + parentStack, + treatAsRealStackingContext + ); + parentStack.contexts.push(childStack); + parseNodeTree(childNode, container, childStack, imageLoader); + } else { + stack.children.push(container); + parseNodeTree(childNode, container, stack, imageLoader); + } + } + } + } + }); +}; + +const createsRealStackingContext = (container: NodeContainer, node: HTMLElement): boolean => { + return ( + container.isRootElement() || + container.isPositionedWithZIndex() || + container.style.opacity < 1 || + container.isTransformed() || + isBodyWithTransparentRoot(container, node) + ); +}; + +const createsStackingContext = (container: NodeContainer): boolean => { + return container.isPositioned() || container.isFloating(); +}; + +const isBodyWithTransparentRoot = (container: NodeContainer, node: HTMLElement): boolean => { + return ( + node.nodeName === 'BODY' && + container.parent instanceof NodeContainer && + container.parent.style.background.backgroundColor.isTransparent() + ); +}; diff --git a/src/Size.js b/src/Size.js new file mode 100644 index 000000000..1611430e8 --- /dev/null +++ b/src/Size.js @@ -0,0 +1,12 @@ +/* @flow */ +'use strict'; + +export default class Size { + width: number; + height: number; + + constructor(width: number, height: number) { + this.width = width; + this.height = height; + } +} diff --git a/src/StackingContext.js b/src/StackingContext.js new file mode 100644 index 000000000..65cfe3720 --- /dev/null +++ b/src/StackingContext.js @@ -0,0 +1,37 @@ +/* @flow */ +'use strict'; + +import NodeContainer from './NodeContainer'; +import {POSITION} from './parsing/position'; + +export default class StackingContext { + container: NodeContainer; + parent: ?StackingContext; + contexts: Array; + children: Array; + treatAsRealStackingContext: boolean; + + constructor( + container: NodeContainer, + parent: ?StackingContext, + treatAsRealStackingContext: boolean + ) { + this.container = container; + this.parent = parent; + this.contexts = []; + this.children = []; + this.treatAsRealStackingContext = treatAsRealStackingContext; + } + + getOpacity(): number { + return this.parent + ? this.container.style.opacity * this.parent.getOpacity() + : this.container.style.opacity; + } + + getRealParentStackingContext(): StackingContext { + return !this.parent || this.treatAsRealStackingContext + ? this + : this.parent.getRealParentStackingContext(); + } +} diff --git a/src/TextBounds.js b/src/TextBounds.js new file mode 100644 index 000000000..05e9acfba --- /dev/null +++ b/src/TextBounds.js @@ -0,0 +1,102 @@ +/* @flow */ +'use strict'; + +import {ucs2} from 'punycode'; +import type TextContainer from './TextContainer'; +import {Bounds} from './Bounds'; +import {TEXT_DECORATION} from './parsing/textDecoration'; + +import FEATURES from './Feature'; + +const UNICODE = /[^\u0000-\u00ff]/; + +const hasUnicodeCharacters = (text: string): boolean => UNICODE.test(text); + +const encodeCodePoint = (codePoint: number): string => ucs2.encode([codePoint]); + +export class TextBounds { + text: string; + bounds: Bounds; + + constructor(text: string, bounds: Bounds) { + this.text = text; + this.bounds = bounds; + } +} + +export const parseTextBounds = (textContainer: TextContainer, node: Text): Array => { + const codePoints = ucs2.decode(textContainer.text); + const letterRendering = + textContainer.parent.style.letterSpacing !== 0 || hasUnicodeCharacters(textContainer.text); + const textList = letterRendering ? codePoints.map(encodeCodePoint) : splitWords(codePoints); + const length = textList.length; + const textBounds = []; + let offset = 0; + for (let i = 0; i < length; i++) { + let text = textList[i]; + if ( + textContainer.parent.style.textDecoration !== TEXT_DECORATION.NONE || + text.trim().length > 0 + ) { + if (FEATURES.SUPPORT_RANGE_BOUNDS) { + textBounds.push(new TextBounds(text, getRangeBounds(node, offset, text.length))); + } + } + offset += text.length; + } + return textBounds; + + /* + else if (container.node && typeof container.node.data === 'string') { + var replacementNode = container.node.splitText(text.length); + var bounds = this.getWrapperBounds(container.node, container.parent.hasTransform()); + container.node = replacementNode; + return bounds; + }*/ +}; + +const getRangeBounds = (node: Text, offset: number, length: number): Bounds => { + const range = node.ownerDocument.createRange(); + range.setStart(node, offset); + range.setEnd(node, offset + length); + return Bounds.fromClientRect(range.getBoundingClientRect()); +}; + +const splitWords = (codePoints: Array): Array => { + const words = []; + let i = 0; + let onWordBoundary = false; + let word; + while (codePoints.length) { + if (isWordBoundary(codePoints[i]) === onWordBoundary) { + word = codePoints.splice(0, i); + if (word.length) { + words.push(ucs2.encode(word)); + } + onWordBoundary = !onWordBoundary; + i = 0; + } else { + i++; + } + + if (i >= codePoints.length) { + word = codePoints.splice(0, i); + if (word.length) { + words.push(ucs2.encode(word)); + } + } + } + return words; +}; + +const isWordBoundary = (characterCode: number): boolean => { + return ( + [ + 32, // + 13, // \r + 10, // \n + 9, // \t + 45 // - + ].indexOf(characterCode) !== -1 + ); +}; diff --git a/src/TextContainer.js b/src/TextContainer.js new file mode 100644 index 000000000..2e16d2ad0 --- /dev/null +++ b/src/TextContainer.js @@ -0,0 +1,43 @@ +/* @flow */ +'use strict'; + +import type NodeContainer from './NodeContainer'; +import type {TextTransform} from './parsing/textTransform'; +import type {TextBounds} from './TextBounds'; +import {TEXT_TRANSFORM} from './parsing/textTransform'; +import {parseTextBounds} from './TextBounds'; + +export default class TextContainer { + text: string; + parent: NodeContainer; + bounds: Array; + + constructor(node: Text, parent: NodeContainer) { + this.text = transform(node.data, parent.style.textTransform); + this.parent = parent; + this.bounds = parseTextBounds(this, node); + } +} + +const CAPITALIZE = /(^|\s|:|-|\(|\))([a-z])/g; + +const transform = (text: string, transform: TextTransform) => { + switch (transform) { + case TEXT_TRANSFORM.LOWERCASE: + return text.toLowerCase(); + case TEXT_TRANSFORM.CAPITALIZE: + return text.replace(CAPITALIZE, capitalize); + case TEXT_TRANSFORM.UPPERCASE: + return text.toUpperCase(); + default: + return text; + } +}; + +function capitalize(m, p1, p2) { + if (m.length > 0) { + return p1 + p2.toUpperCase(); + } + + return m; +} diff --git a/src/Util.js b/src/Util.js new file mode 100644 index 000000000..cfbc179cb --- /dev/null +++ b/src/Util.js @@ -0,0 +1,4 @@ +/* @flow */ +'use strict'; + +export const contains = (bit: number, value: number): boolean => (bit & value) !== 0; diff --git a/src/Vector.js b/src/Vector.js new file mode 100644 index 000000000..5f97473aa --- /dev/null +++ b/src/Vector.js @@ -0,0 +1,20 @@ +/* @flow */ +'use strict'; + +export default class Vector { + x: number; + y: number; + + constructor(x: number, y: number) { + this.x = x; + this.y = y; + if (__DEV__) { + if (isNaN(x)) { + console.error(`Invalid x value given for Vector`); + } + if (isNaN(y)) { + console.error(`Invalid y value given for Vector`); + } + } + } +} diff --git a/src/clone.js b/src/clone.js deleted file mode 100644 index bafaa09b6..000000000 --- a/src/clone.js +++ /dev/null @@ -1,104 +0,0 @@ -var log = require('./log'); - -function restoreOwnerScroll(ownerDocument, x, y) { - if (ownerDocument.defaultView && (x !== ownerDocument.defaultView.pageXOffset || y !== ownerDocument.defaultView.pageYOffset)) { - ownerDocument.defaultView.scrollTo(x, y); - } -} - -function cloneCanvasContents(canvas, clonedCanvas) { - try { - if (clonedCanvas) { - clonedCanvas.width = canvas.width; - clonedCanvas.height = canvas.height; - clonedCanvas.getContext("2d").putImageData(canvas.getContext("2d").getImageData(0, 0, canvas.width, canvas.height), 0, 0); - } - } catch(e) { - log("Unable to copy canvas content from", canvas, e); - } -} - -function cloneNode(node, javascriptEnabled) { - var clone = node.nodeType === 3 ? document.createTextNode(node.nodeValue) : node.cloneNode(false); - - var child = node.firstChild; - while(child) { - if (javascriptEnabled === true || child.nodeType !== 1 || child.nodeName !== 'SCRIPT') { - clone.appendChild(cloneNode(child, javascriptEnabled)); - } - child = child.nextSibling; - } - - if (node.nodeType === 1) { - clone._scrollTop = node.scrollTop; - clone._scrollLeft = node.scrollLeft; - if (node.nodeName === "CANVAS") { - cloneCanvasContents(node, clone); - } else if (node.nodeName === "TEXTAREA" || node.nodeName === "SELECT") { - clone.value = node.value; - } - } - - return clone; -} - -function initNode(node) { - if (node.nodeType === 1) { - node.scrollTop = node._scrollTop; - node.scrollLeft = node._scrollLeft; - - var child = node.firstChild; - while(child) { - initNode(child); - child = child.nextSibling; - } - } -} - -module.exports = function(ownerDocument, containerDocument, width, height, options, x ,y) { - var documentElement = cloneNode(ownerDocument.documentElement, options.javascriptEnabled); - var container = containerDocument.createElement("iframe"); - - container.className = "html2canvas-container"; - container.style.visibility = "hidden"; - container.style.position = "fixed"; - container.style.left = "-10000px"; - container.style.top = "0px"; - container.style.border = "0"; - container.width = width; - container.height = height; - container.scrolling = "no"; // ios won't scroll without it - containerDocument.body.appendChild(container); - - return new Promise(function(resolve) { - var documentClone = container.contentWindow.document; - - /* Chrome doesn't detect relative background-images assigned in inline